Repository: wix/Detox
Pull request #2771 (2021-05-06 11:32:01)

Add getAttributes support for Android
## Description

<!--
Thank you for contributing!

Step 1: Before submitting a pull request that introduces a new functionality or fixes a bug,
please open an issue where we could discuss the suggestion, problem - and potential ways to fix.
-->

- This pull request fixes https://github.com/wix/Detox/issues/2083

<!--
Step 2: Provide an overview of how your fix / enhancement works.
If possible, provide screenshots of the before and after states (even for simple command line options - show the terminal).
-->

In this pull request, I have introduced support for attributes for android including for View, TextView and Checkbox.

<!--
Step 3: Please review the checklist below.
-->

---


> _For features/enhancements:_
 - [x] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

> _For API changes:_
 - [ ] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.


noomorph: Kudos, @jonathanmos! 

@d4vidi , WDYT , when can we take this into processing?
d4vidi: @noomorph of course!

Patch:
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index b109d5a91..c9ac379ef 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -115,8 +115,11 @@ dependencies {
     api 'androidx.test.espresso:espresso-web:3.2.0'
 }
 
-// Third-party deps.
+// Third-party/extension deps.
 dependencies {
+    implementation('com.google.android.material:material:1.2.1') {
+        because 'Material components are mentioned explicitly (e.g. Slider in get-attributes handler)'
+    }
     implementation 'org.apache.commons:commons-lang3:3.7' // Needed by invoke. Warning: Upgrading to newer versions is not seamless.
     implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0'
 }
diff --git a/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt
index d99d5e108..bccf08dc2 100644
--- a/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt
+++ b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt
@@ -10,6 +10,7 @@ import androidx.test.espresso.action.ViewActions
 import com.wix.detox.espresso.action.DetoxSingleTap
 import com.wix.detox.espresso.action.DetoxMultiTap
 import com.wix.detox.espresso.action.TakeViewScreenshotAction
+import com.wix.detox.espresso.action.GetAttributesAction
 import com.wix.detox.espresso.action.ScreenshotResult
 import com.wix.detox.espresso.ViewActionWithResult
 
@@ -24,4 +25,7 @@ public object DetoxViewActions {
 
     public fun takeScreenshot(): ViewActionWithResult<ScreenshotResult?> =
         TakeViewScreenshotAction()
+
+    public fun getAttributes(): ViewActionWithResult<String?> =
+            GetAttributesAction()
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java
index 73e6a6b7a..aba7549ad 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java
@@ -8,6 +8,7 @@ import com.wix.detox.espresso.action.DetoxMultiTap;
 import com.wix.detox.espresso.action.RNClickAction;
 import com.wix.detox.espresso.action.ScreenshotResult;
 import com.wix.detox.espresso.action.TakeViewScreenshotAction;
+import com.wix.detox.espresso.action.GetAttributesAction;
 import com.wix.detox.action.common.MotionDir;
 import com.wix.detox.espresso.scroll.ScrollEdgeException;
 import com.wix.detox.espresso.scroll.ScrollHelper;
@@ -174,9 +175,13 @@ public class DetoxAction {
         return swipeHelper.swipeInDirection(direction, fast, normalizedOffset, normalizedStartingPointX, normalizedStartingPointY);
     }
 
+    public static ViewAction getAttributes() {
+        return new GetAttributesAction();
+    }
+
     public static ViewAction takeViewScreenshot() {
         return new ViewActionWithResult<String>() {
-            private TakeViewScreenshotAction action = new TakeViewScreenshotAction();
+            private final TakeViewScreenshotAction action = new TakeViewScreenshotAction();
 
             @Override
             public Matcher<View> getConstraints() {
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/GetAttributesAction.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/GetAttributesAction.kt
new file mode 100644
index 000000000..c7608160f
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/GetAttributesAction.kt
@@ -0,0 +1,150 @@
+package com.wix.detox.espresso.action
+
+import android.graphics.Rect
+import android.os.Build
+import android.view.View
+import android.widget.CheckBox
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.test.espresso.UiController
+import com.google.android.material.slider.Slider
+import com.wix.detox.espresso.ViewActionWithResult
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.hamcrest.Matchers.allOf
+import org.hamcrest.Matchers.notNullValue
+import org.json.JSONObject
+
+class GetAttributesAction() : ViewActionWithResult<String?> {
+    private val commonAttributes = CommonAttributes()
+    private val textViewAttributes = TextViewAttributes()
+    private val checkBoxAttributes = CheckBoxAttributes()
+    private val progressBarAttributes = ProgressBarAttributes()
+    private val sliderAttributes = SliderAttributes()
+    private var result: String = ""
+
+    override fun perform(uiController: UiController?, view: View?) {
+        view!!
+
+        val json = JSONObject()
+
+        commonAttributes.get(json, view)
+        textViewAttributes.get(json, view)
+        checkBoxAttributes.get(json, view)
+        progressBarAttributes.get(json, view)
+        sliderAttributes.get(json, view)
+
+        result = json.toString()
+    }
+
+    override fun getResult() = result
+    override fun getDescription() = "Get view attributes"
+    override fun getConstraints(): Matcher<View> = allOf(notNullValue(), Matchers.isA(View::class.java))
+}
+
+private class CommonAttributes {
+    fun get(json: JSONObject, view: View) {
+        getId(json, view)
+        getVisibility(json, view)
+        getContentDescription(json, view)
+        getAlpha(json, view)
+        getElevation(json, view)
+        getHeight(json, view)
+        getWidth(json, view)
+        getHasFocus(json, view)
+        getIsEnabled(json, view)
+    }
+
+    private fun getId(json: JSONObject, view: View) =
+            view.tag?.let {
+                json.put("identifier", it.toString())
+            }
+
+    private fun getVisibility(json: JSONObject, view: View) {
+        json.put("visibility", visibilityMap[view.visibility])
+        json.put("visible", view.getLocalVisibleRect(Rect()))
+    }
+
+    private fun getContentDescription(json: JSONObject, view: View) =
+            view.contentDescription?.let {
+                json.put("label", it)
+            }
+
+    private fun getElevation(json: JSONObject, view: View) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            json.put("elevation", view.elevation)
+        }
+    }
+
+    private fun getAlpha(json: JSONObject, view: View) = json.put("alpha", view.alpha)
+    private fun getHeight(json: JSONObject, view: View) = json.put("height", view.height)
+    private fun getWidth(json: JSONObject, view: View) = json.put("width", view.width)
+    private fun getIsEnabled(json: JSONObject, view: View) = json.put("enabled", view.isEnabled)
+    private fun getHasFocus(json: JSONObject, view: View) = json.put("focused", view.isFocused)
+
+    companion object {
+        private val visibilityMap = mapOf(View.VISIBLE to "visible", View.INVISIBLE to "invisible", View.GONE to "gone")
+    }
+}
+
+private class TextViewAttributes {
+    fun get(json: JSONObject, view: View) {
+        if (view is TextView) {
+            getText(json, view)
+            getLength(json, view)
+            getTextSize(json, view)
+            getHint(json, view)
+        }
+    }
+
+    private fun getText(rootObject: JSONObject, view: TextView) =
+            view.text?.let {
+                rootObject.put("text", it.toString())
+            }
+
+    private fun getTextSize(rootObject: JSONObject, view: TextView) =
+            rootObject.put("textSize", view.textSize)
+
+    private fun getLength(rootObject: JSONObject, view: TextView) =
+            view.text?.let {
+                rootObject.put("length", view.length())
+            }
+
+    private fun getHint(rootObject: JSONObject, view: TextView) =
+            view.hint?.let {
+                rootObject.put("placeholder", it.toString())
+            }
+}
+
+private class CheckBoxAttributes {
+    fun get(json: JSONObject, view: View) {
+        if (view is CheckBox) {
+            getCheckboxValue(json, view)
+        }
+    }
+
+    private fun getCheckboxValue(rootObject: JSONObject, view: CheckBox) =
+            rootObject.put("value", view.isChecked)
+}
+
+private class ProgressBarAttributes {
+    fun get(json: JSONObject, view: View) {
+        if (view is ProgressBar) {
+            getProgressBarValue(json, view)
+        }
+    }
+
+    private fun getProgressBarValue(rootObject: JSONObject, view: ProgressBar) =
+            rootObject.put("value", view.progress)
+}
+
+private class SliderAttributes {
+    fun get(json: JSONObject, view: View) {
+        if (view is Slider) {
+            getSliderValue(json, view)
+        }
+    }
+
+    private fun getSliderValue(rootObject: JSONObject, view: Slider) =
+        rootObject.put("value", view.value)
+}
diff --git a/detox/src/android/actions/native.js b/detox/src/android/actions/native.js
index b24213c9c..9edf525ec 100644
--- a/detox/src/android/actions/native.js
+++ b/detox/src/android/actions/native.js
@@ -110,6 +110,13 @@ class SwipeAction extends Action {
   }
 }
 
+class GetAttributes extends Action {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.getAttributes());
+  }
+}
+
 class TakeElementScreenshot extends Action {
   constructor() {
     super();
@@ -127,6 +134,7 @@ module.exports = {
   TypeTextAction,
   ReplaceTextAction,
   ClearTextAction,
+  GetAttributes,
   ScrollAmountAction,
   ScrollAmountStopAtEdgeAction,
   ScrollEdgeAction,
diff --git a/detox/src/android/core/NativeElement.js b/detox/src/android/core/NativeElement.js
index ccd8cda2b..176445ad6 100644
--- a/detox/src/android/core/NativeElement.js
+++ b/detox/src/android/core/NativeElement.js
@@ -106,6 +106,11 @@ class NativeElement {
     });
     return filePath;
   }
+
+  async getAttributes() {
+    const result = await new ActionInteraction(this._invocationManager, this, new actions.GetAttributes()).execute();
+    return JSON.parse(result);
+  }
 }
 
 module.exports = {
diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js
index 9c29620cc..0992e62d5 100644
--- a/detox/src/android/espressoapi/DetoxAction.js
+++ b/detox/src/android/espressoapi/DetoxAction.js
@@ -168,6 +168,17 @@ class DetoxAction {
     };
   }
 
+  static getAttributes() {
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.DetoxAction"
+      },
+      method: "getAttributes",
+      args: []
+    };
+  }
+
   static takeViewScreenshot() {
     return {
       target: {
diff --git a/docs/APIRef.ActionsOnElement.md b/docs/APIRef.ActionsOnElement.md
index 700361a44..86cf5e9e4 100644
--- a/docs/APIRef.ActionsOnElement.md
+++ b/docs/APIRef.ActionsOnElement.md
@@ -23,7 +23,7 @@ Use [expectations](APIRef.Expect.md) to verify element states.
 - [`.setColumnToValue()`](#setcolumntovaluecolumn-value--ios-only) **iOS only**
 - [`.setDatePickerDate()`](#setdatepickerdatedatestring-dateformat--ios-only) **iOS only**
 - [`.adjustSliderToPosition()`](#adjustslidertopositionnormalizedposition--ios-only) **iOS only**
-- [`.getAttributes()`](#getAttributes--ios-only) **iOS only**
+- [`.getAttributes()`](#getAttributes)
 - [`.takeScreenshot(name)`](#takescreenshotname-android-only) **Android only**
 
 ### `tap(point)`
@@ -231,32 +231,44 @@ Manipulates the UI to change the displayed value of the slider element to a new
 await element(by.id('slider')).adjustSliderToPosition(0.75);
 ```
 
-### `getAttributes()`  iOS only
+### `getAttributes()`
 
-Returns an object, representing the attributes of the element.
+Returns an object, representing various attributes of the element.
 
 Retrieved attributes are:
 
-- `text`—the text value of the element <br/>
-- `label`—the label of the element (matches `accessibilityLabel`) <br/>
-- `value`—the value of the element (matches `accessibilityValue`) <br/>
-- `placeholder`—the placeholder text value of the element <br/>
-- `identifier`—the identifier of the element (matches `accessibilityIdentifier`) <br/>
-- `enabled`—whether or not the element is enabled for user interaction <br/>
-- `activationPoint`—the activation point of the element, in element coordinate space <br/>
-- `normalizedActivationPoint`—the activation point of the element, in normalized percentage ([0.0, 1.0]) <br/>
-- `hittable`—whether the element is hittable at the activation point <br/>
-- `visible`—whether the element is visible at the activation point <br/>
-- `frame`—the frame of the element, in screen coordinate space <br/>
-- `elementFrame`—the frame of the element, in container coordinate space <br/>
-- `elementBounds`—the bounds of the element, in element coordinate space <br/>
-- `safeAreaInsets`—the safe area insets of the element, in element coordinate space <br/>
-- `elementSafeBounds`—the safe area bounds of the element, in element coordinate space <br/>
-- `date`—the date of the element (in case the element is a date picker) <br/>
-- `normalizedSliderPosition`—the normalized slider position (in case the element is a slider) <br/>
-- `contentOffset`—the content offset (in case the element is a scroll view) <br/>
-- `contentInset`—the content inset (in case the element is a scroll view) <br/>
-- `adjustedContentInset`—the adjusted content inset (in case the element is a scroll view) <br/>
+- `text`: The text value of any textual element.
+- `label`: The label of the element. Matches `accessibilityLabel` for ios, and `contentDescription` for android.
+- `placeholder`: The placeholder text value of the element. Matches `hint` on android.
+- `enabled`: Whether or not the element is enabled for user interaction.
+- `identifier`: The identifier of the element. Matches `accessibilityIdentifier` on iOS, and the main view tag, on Android - both commonly **holding the component's test ID in React Native apps**.
+- `visible`: whether the element is visible. On iOS, visibility is calculated for the [activation point](https://developer.apple.com/documentation/objectivec/nsobject/1615179-accessibilityactivationpoint). On Android, the attribute directly holds the value returned by [View.getLocalVisibleRect()](https://developer.android.com/reference/kotlin/android/view/View#getglobalvisiblerect)).
+- `value`: the value of the element, where applicable. For example: the position of a slider, or whether a checkbox has been marked. Matches `accessibilityValue`, on iOS.
+
+###### iOS-Only
+- `activationPoint`: The [activation point](https://developer.apple.com/documentation/objectivec/nsobject/1615179-accessibilityactivationpoint) of the element, in element coordinate space.
+- `normalizedActivationPoint`: The activation point of the element, in normalized percentage ([0.0, 1.0]).
+- `hittable`: Whether the element is hittable at the activation point.
+- `frame`: The frame of the element, in screen coordinate space.
+- `elementFrame`: The frame of the element, in container coordinate space.
+- `elementBounds`: The bounds of the element, in element coordinate space.
+- `safeAreaInsets`: The safe area insets of the element, in element coordinate space.
+- `elementSafeBounds`: The safe area bounds of the element, in element coordinate space.
+- `date`: The date of the element (in case the element is a date picker).
+- `normalizedSliderPosition`: The normalized slider position (in case the element is a slider).
+- `contentOffset`: The content offset (in case the element is a scroll view).
+- `contentInset`: The content inset (in case the element is a scroll view).
+- `adjustedContentInset`: The adjusted content inset (in case the element is a scroll view).
+
+###### Android-Only
+- `visibility`: The OS visibility type associated with the element: `visible`, `invisible` or `gone`.
+- `width`: Width of the element, in pixels.
+- `height`: Height of the element, in pixels.
+- `elevation`: Elevation of the element.
+- `alpha`: Alpha value for the element.
+- `focused`: Whether the element is the one currently in focus.
+- `textSize`: The text size for the text element.
+- `length`: The length of the text element (character count).
 
 If the value for a given attribute is null or cannot be otherwise computed, the key will not be present, but empty strings may be found in the object.
 
@@ -306,4 +318,4 @@ Simulates a pinch on the element with the provided options.
 
 ```js
 await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0);
-```
+```
\ No newline at end of file


Test patch:
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt
new file mode 100644
index 000000000..1ac3e31b9
--- /dev/null
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt
@@ -0,0 +1,239 @@
+package com.wix.detox.espresso.action
+
+import android.view.View
+import android.widget.CheckBox
+import android.widget.ProgressBar
+import android.widget.TextView
+import com.google.android.material.slider.Slider
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
+import org.assertj.core.api.Assertions.assertThat
+import org.json.JSONObject
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class GetAttributesActionTest {
+    lateinit var view: View
+    lateinit var uut: GetAttributesAction
+
+    @Before
+    fun setup() {
+        view = mock()
+        uut = GetAttributesAction()
+    }
+
+    private fun givenViewTag(value: Any?) { whenever(view.tag).doReturn(value) }
+    private fun givenNoViewTag() = givenViewTag(null)
+    private fun givenVisibility(value: Int) { whenever(view.visibility).doReturn(value) }
+    private fun givenVisibilityRectAvailability(value: Boolean) { whenever(view.getLocalVisibleRect(any())).doReturn(value) }
+    private fun givenContentDescription(value: String) { whenever(view.contentDescription).doReturn(value) }
+
+    private fun perform(v: View = view): JSONObject {
+        uut.perform(null, v)
+        return JSONObject(uut.getResult())
+    }
+
+    @Test
+    fun `should declare non-null view constraint`() {
+        assertThat(uut.constraints.matches(null)).isFalse()
+        assertThat(uut.constraints.matches(view)).isTrue()
+        assertThat(uut.constraints.matches(1)).isFalse()
+    }
+
+    @Test
+    fun `should return view-tag as ID attribute`() {
+        val testId = "mock-test-ID"
+        givenViewTag(testId)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("identifier")).isEqualTo(testId)
+    }
+
+    @Test
+    fun `should not return ID if view has no tag`() {
+        givenNoViewTag()
+
+        val resultJson = perform()
+        assertThat(resultJson.has("identifier")).isFalse()
+    }
+
+    @Test
+    fun `should stringify non-text tags set on view`() {
+        givenViewTag(1234)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("identifier")).isEqualTo("1234")
+    }
+
+    @Test
+    fun `should return visibility attributes for a logically visible view`() {
+        givenVisibility(View.VISIBLE)
+        givenVisibilityRectAvailability(false)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("visibility")).isEqualTo("visible")
+        assertThat(resultJson.opt("visible")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return visibility attributes for an effectively visible view`() {
+        givenVisibility(View.VISIBLE)
+        givenVisibilityRectAvailability(true)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("visibility")).isEqualTo("visible")
+        assertThat(resultJson.opt("visible")).isEqualTo(true)
+    }
+
+    @Test
+    fun `should return visibility attributes for an invisible view`() {
+        givenVisibility(View.INVISIBLE)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("visibility")).isEqualTo("invisible")
+        assertThat(resultJson.opt("visible")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return visibility attributes for view that is gone`() {
+        givenVisibility(View.GONE)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("visibility")).isEqualTo("gone")
+        assertThat(resultJson.opt("visible")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return label according to content-description`() {
+        val contentDescription = "content-description-mock"
+        givenContentDescription(contentDescription)
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("label")).isEqualTo(contentDescription)
+    }
+
+    @Test
+    fun `should not return label if content description no set`() {
+        val resultJson = perform()
+        assertThat(resultJson.opt("label")).isNull()
+    }
+
+    @Test
+    fun `should return common values`() {
+        view = mock {
+            on { alpha } doReturn 0.42f
+            on { width } doReturn 123
+            on { height } doReturn 456
+            on { elevation } doReturn 0.314f
+        }
+
+        val resultJson = perform()
+        assertThat(resultJson.opt("alpha")).isEqualTo(0.42)
+        assertThat(resultJson.opt("width")).isEqualTo(123)
+        assertThat(resultJson.opt("height")).isEqualTo(456)
+        assertThat(resultJson.opt("elevation")).isEqualTo(0.314)
+    }
+
+    @Test
+    fun `should return enable and disabled states`() {
+        val enabledView: View = mock {
+            on { isEnabled } doReturn true
+        }
+        val disabledView: View = mock {
+            on { isEnabled } doReturn false
+        }
+
+        assertThat(perform(enabledView).opt("enabled")).isEqualTo(true)
+        assertThat(perform(disabledView).opt("enabled")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return whether focused or blurred`() {
+        val focusedView: View = mock {
+            on { isFocused } doReturn true
+        }
+        val blurredView: View = mock {
+            on { isFocused } doReturn false
+        }
+
+        assertThat(perform(focusedView).opt("focused")).isEqualTo(true)
+        assertThat(perform(blurredView).opt("focused")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return check-box state via value attribute`() {
+        val checkedCheckBox: CheckBox = mock {
+            on { isChecked } doReturn true
+        }
+        val uncheckedCheckBox: CheckBox = mock {
+            on { isChecked } doReturn false
+        }
+
+        assertThat(perform(checkedCheckBox).opt("value")).isEqualTo(true)
+        assertThat(perform(uncheckedCheckBox).opt("value")).isEqualTo(false)
+    }
+
+    @Test
+    fun `should return ProgressBar (or SeekBar) "progress" via value attribute`() {
+        val progressBar: ProgressBar = mock {
+            on { progress } doReturn 42
+        }
+
+        val resultJson = perform(progressBar)
+        assertThat(resultJson.opt("value")).isEqualTo(42)
+    }
+
+    @Test
+    fun `should return Slider state through value attribute`() {
+        val slider: Slider = mock {
+            on { value } doReturn 0.42f
+        }
+
+        val resultJson = perform(slider)
+        assertThat(resultJson.opt("value")).isEqualTo(0.42)
+    }
+
+    @Test
+    fun `should return text attributes for a TextView`() {
+        val textView: TextView = mock {
+            on { text } doReturn "mock-text"
+            on { textSize } doReturn 24f
+            on { length() } doReturn 111
+        }
+
+        val resultJson = perform(textView)
+        assertThat(resultJson.opt("text")).isEqualTo("mock-text")
+        assertThat(resultJson.opt("textSize")).isEqualTo(24)
+        assertThat(resultJson.opt("length")).isEqualTo(111)
+    }
+
+    @Test
+    fun `should not return text attributes for a text-less TextView`() {
+        val textView: TextView = mock {
+            on { text } doReturn null
+            on { length() } doReturn 0
+        }
+
+        val resultJson = perform(textView)
+        assertThat(resultJson.has("text")).isFalse()
+        assertThat(resultJson.has("length")).isFalse()
+    }
+
+    @Test
+    fun `should return text hint via "placeholder" attribute, if applicable`() {
+        val textViewWithHint: TextView = mock {
+            on { hint } doReturn "hint-text-mock"
+        }
+        val textView: TextView = mock {
+            on { hint } doReturn null
+        }
+
+        assertThat(perform(textViewWithHint).opt("placeholder")).isEqualTo("hint-text-mock")
+        assertThat(perform(textView).has("placeholder")).isFalse()
+    }
+}
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index 758306650..0c916f1c6 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -225,6 +225,16 @@ describe('AndroidExpect', () => {
       it('should fail to find index-based element given invalid args', async () => {
         await expectToThrow(() => e.element(e.by.id('ScrollView799')).atIndex('NaN'));
       });
+
+      it('should retrieve attributes', async () => {
+        const execResult = {
+          text: 'hello',
+          value: 1,
+        }
+        mockExecutor.executeResult = Promise.resolve(JSON.stringify(execResult));
+        const result = await e.element(e.by.id('UniqueId005')).getAttributes();
+        expect(result).toEqual(execResult);
+      });
     });
 
     describe('element screenshots', () => {
diff --git a/detox/test/android/app/build.gradle b/detox/test/android/app/build.gradle
index 6f29b5bc9..131110c0b 100644
--- a/detox/test/android/app/build.gradle
+++ b/detox/test/android/app/build.gradle
@@ -81,12 +81,8 @@ android {
 }
 
 dependencies {
-    if (rnInfo.isRN60OrHigher) {
-        implementation 'androidx.appcompat:appcompat:1.1.0'
-        implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
-    } else {
-        implementation 'com.android.support:appcompat-v7:28.0.0'
-    }
+    implementation 'androidx.appcompat:appcompat:1.3.0'
+    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
 
     fromSourceImplementation(project(path: ':ReactAndroid'))
 
diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js
index 4b2f0b218..825547928 100644
--- a/detox/test/e2e/03.actions.test.js
+++ b/detox/test/e2e/03.actions.test.js
@@ -188,11 +188,4 @@ describe('Actions', () => {
     await expect(element(by.id('sliderWithASimpleID'))).toHaveSliderPosition(0.75);
     await expect(element(by.id('sliderWithASimpleID'))).toHaveValue("75%");
   });
-
-  it(':ios: should get element attributes', async () => {
-    const jestExpect = require('expect');
-    const attributes = await element(by.text('Tap Me')).getAttributes();
-    jestExpect(attributes.text).toBe('Tap Me');
-    jestExpect(attributes.label).toBe('Tap Me');
-  });
 });
diff --git a/detox/test/e2e/33.attributes.test.js b/detox/test/e2e/33.attributes.test.js
new file mode 100644
index 000000000..9908b4bec
--- /dev/null
+++ b/detox/test/e2e/33.attributes.test.js
@@ -0,0 +1,66 @@
+const jestExpect = require('expect');
+
+describe('Attributes', () => {
+  const expectedText = 'TextView';
+
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('Attributes')).tap();
+  });
+
+  it('should get common attributes for view', async () => {
+    const attribs = await element(by.id('viewId')).getAttributes();
+    jestExpect(attribs.visible).toBe(true);
+    jestExpect(attribs.enabled).toBe(true);
+  });
+
+  it(':android: should get attributes for view', async () => {
+    const attribs = await element(by.id('viewId')).getAttributes();
+
+    jestExpect(attribs.alpha).toBe(1);
+    jestExpect(attribs.visibility).toBe("visible");
+    jestExpect(attribs.elevation).toBe(0);
+    jestExpect(attribs.height).toBe(263);
+    jestExpect(attribs.width).toBe(263);
+    jestExpect(attribs.focused).toBe(false);
+  });
+
+  it('should get common attributes for simple text', async () => {
+    const attribs = await element(by.id('textViewId')).getAttributes();
+    jestExpect(attribs.text).toBe(expectedText);
+    jestExpect(attribs.label).toBe(expectedText);
+  });
+
+  it(':android: should get attributes for simple text', async () => {
+    const attribs = await element(by.id('textViewId')).getAttributes();
+
+    jestExpect(attribs.text).toBe(expectedText);
+    jestExpect(attribs.length).toBe(expectedText.length);
+    jestExpect(attribs.textSize).toBe(37.0);
+  });
+
+  it(':android: should get attributes for text-input', async () => {
+    let attribs = await element(by.id('blurredTextInputId')).getAttributes();
+    jestExpect(attribs.focused).toEqual(false);
+    jestExpect(attribs.text).toEqual('blurred');
+
+    attribs = await element(by.id('focusedTextInputId')).getAttributes();
+    jestExpect(attribs.focused).toEqual(true);
+    jestExpect(attribs.text).toEqual('focused');
+    jestExpect(attribs.placeholder).toEqual('palace-holder');
+  });
+
+  it(':android: should get attributes for checkbox', async () => {
+    let attribs = await element(by.id('checkboxId')).getAttributes();
+    jestExpect(attribs.value).toBe(false);
+
+    await element(by.id('checkboxId')).tap();
+    attribs = await element(by.id('checkboxId')).getAttributes();
+    jestExpect(attribs.value).toBe(true);
+  });
+
+  it(':android: should get attributes for slider', async () => {
+    const attribs = await element(by.id('sliderId')).getAttributes();
+    jestExpect(attribs.value).toEqual(0.5 * 128); // Why 1 is 128 in RN? I'm not sure... maybe px vs. dp?! :shrug:
+  });
+});
diff --git a/detox/test/package.json b/detox/test/package.json
index dc9944f4e..26d3c11bf 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -29,6 +29,7 @@
   },
   "dependencies": {
     "@react-native-community/async-storage": "^1.12.0",
+    "@react-native-community/checkbox": "^0.5.7",
     "moment": "^2.24.0",
     "react": "16.11.x",
     "react-native": "0.62.x",
diff --git a/detox/test/src/Screens/AttributesScreen.js b/detox/test/src/Screens/AttributesScreen.js
new file mode 100644
index 000000000..0f1d692dd
--- /dev/null
+++ b/detox/test/src/Screens/AttributesScreen.js
@@ -0,0 +1,45 @@
+import React, { Component } from 'react';
+import {
+  Text,
+  View,
+  Slider,
+  TextInput,
+} from 'react-native';
+import CheckBox from '@react-native-community/checkbox';
+
+export default class AttributesScreen extends Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      checked: false
+    }
+  }
+
+  changeCheckboxState() {
+    this.setState({
+      checked: !this.state.checked
+    })
+  }
+
+  render() {
+    return (
+      <View style={{flex: 1, justifyContent: 'center'}}>
+        <View testID={'viewId'} width={100} height={100}/>
+        <Text testID={'textViewId'} accessibilityLabel={'TextView'}>TextView</Text>
+        <CheckBox testID={'checkboxId'}/>
+        <TextInput
+          testID={'focusedTextInputId'} value={'focused'} placeholder={'palace-holder'}
+          style={{ height: 40, borderColor: 'gray', borderWidth: 1, marginBottom: 10, marginHorizontal: 20, padding: 5 }}
+          autoFocus={true}
+        />
+        <TextInput
+          testID={'blurredTextInputId'} value={'blurred'} placeholder={'palace-holder'}
+          style={{ height: 40, borderColor: 'gray', borderWidth: 1, marginBottom: 10, marginHorizontal: 20, padding: 5 }}
+          autoFocus={false}
+        />
+        <Slider testID={'sliderId'} value={0.5} />
+      </View>
+    );
+  }
+}
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index 5ea666fa7..f33a0bd8c 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -26,6 +26,7 @@ import VirtualizedListStressScreen from './VirtualizedListStressScreen';
 import WebViewScreen from './WebViewScreen';
 import VisibilityScreen from './VisibilityScreen';
 import ExpectationsScreen from './ExpectationsScreen';
+import AttributesScreen from './AttributesScreen';
 
 export {
   SanityScreen,
@@ -56,4 +57,5 @@ export {
   VirtualizedListStressScreen,
   VisibilityScreen,
   ExpectationsScreen,
+  AttributesScreen,
 };
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 5acb49d5b..a5e3d32ad 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -43,7 +43,7 @@ class example extends Component {
       <TouchableOpacity onPress={() => {
         onPressCallback();
       }}>
-        <Text style={{color: 'blue', marginBottom: 10}}>{title}</Text>
+        <Text style={{color: 'blue', marginBottom: 8}}>{title}</Text>
       </TouchableOpacity>
     );
   }
@@ -97,7 +97,7 @@ class example extends Component {
     console.log("App@render: JS rendering main screen");
     return (
       <View style={{flex: 1, paddingTop: 10, justifyContent: 'center', alignItems: 'center'}}>
-        <Text style={{fontSize: 20, marginBottom: 10}}>
+        <Text style={{fontSize: 18, marginBottom: 10}}>
           Choose a test
         </Text>
         {this.renderScreenButton('Language', Screens.LanguageScreen)}
@@ -122,6 +122,7 @@ class example extends Component {
         {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
         {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
         {isAndroid && this.renderScreenButton('WebView', Screens.WebViewScreen)}
+        {this.renderScreenButton('Attributes', Screens.AttributesScreen)}
 
         { /* TODO: Push this into a dedicated screen */ }
         <View style={{flexDirection: 'row', justifyContent: 'center'}}>