diff --git a/README.md b/README.md index 7616714..1453360 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # Purpose -[![Build Status](https://travis-ci.org/openatx/android-uiautomator-server.svg?branch=master)](https://travis-ci.org/openatx/android-uiautomator-server) [UIAutomator](http://developer.android.com/tools/testing/testing_ui.html) is a great tool to perform Android UI testing, but to do it, you have to write java @@ -83,52 +82,18 @@ $ adb shell am broadcast -a ADB_GET_CLIPBOARD Broadcasting: Intent { act=ADB_GET_CLIPBOARD flg=0x400000 } Broadcast completed: result=0 -# Get clipboard (with data, base64 encoded) -$ adb shell am broadcast -a ADB_GET_CLIPBOARD -Broadcasting: Intent { act=ADB_GET_CLIPBOARD flg=0x400000 } -Broadcast completed: result=-1, data="5LqG6Kej5Lyg57uf5paH5YyW" -``` - -- [Editor Code](https://developer.android.com/reference/android/view/inputmethod/EditorInfo) -- [Key Event](https://developer.android.com/reference/android/view/KeyEvent) - -# Change GPS mock location -You can change mock location from terminal using adb in order to test GPS on real devices. - -``` -adb [-s ] shell am broadcast -a send.mock [-e lat ] [-e lon ] - [-e alt ] [-e accurate ] -``` - -For example: - -``` -adb shell am broadcast -a send.mock -e lat 15.3 -e lon 99 -``` - -## Show toast - -``` -adb shell am start -n com.github.uiautomator/.ToastActivity -e message hello -``` - -## Float window - -``` -adb shell am start -n com.github.uiautomator/.ToastActivity -e showFloatWindow true # show -adb shell am start -n com.github.uiautomator/.ToastActivity -e showFloatWindow false # hide -``` - -# How to use with Python +# How to use ```python -import uiautomator2 as u2 -d = u2.connect() +from uiautomator import device as d +d.info d.screen.on() d(text="Settings").click() d(scrollable=True).scroll.vert.forward() +d().gestureM((100,200),(100,300),(100,400)).to((100,400),(100,400),(100,400),100) + ``` Refer to python wrapper library [uiautomator](https://github.com/xiaocong/uiautomator). @@ -143,6 +108,7 @@ conventional-changelog -p grunt -i CHANGELOG.md -s -r 0 # Notes +If you have any idea, please email xiaocong@gmail.com, hongbin.bao@gmail.com or [submit tickets](https://github.com/xiaocong/uiautomator/issues/new). If you have any idea, please email codeskyblue@gmail.com or [submit tickets](https://github.com/openatx/android-uiautomator-server/issues/new). # Dependencies @@ -171,3 +137,5 @@ Clipboard, Thanks to @fplust - https://github.com/willerce/WhatsInput - https://github.com/senzhk/ADBKeyBoard - https://github.com/amotzte/android-mock-location-for-development +# TODO +- android O support diff --git a/app/build.gradle b/app/build.gradle index 34d276d..8e73ad5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,11 +37,6 @@ android { compileSdkVersion 28 buildToolsVersion '28.0.3' - // version code history - // 1: original version - // 2: update all dependencies to latest - // 6: input method, battery,rotation monitor - defaultConfig { applicationId "com.github.uiautomator" minSdkVersion 18 @@ -65,54 +60,92 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' signingConfig signingConfigs.release } + + debug { + minifyEnabled false + signingConfig signingConfigs.release + } } + android { lintOptions { abortOnError false } - } + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + //test.java.srcDirs += 'src/test/kotlin' + //androidTest.java.srcDirs += 'src/androidTest' + } - defaultConfig { - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } - packagingOptions { - exclude 'LICENSE.txt' - exclude 'META-INF/LICENSE' - exclude 'META-INF/NOTICE' + packagingOptions { + exclude 'LICENSE.txt' + exclude 'META-INF/LICENSE' + exclude 'META-INF/NOTICE' + } } - // fix try-with-resource warning - // ref: https://stackoverflow.com/questions/40408628/try-with-resources-requires-api-level-19-okhttp - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + configurations.all { + resolutionStrategy.force 'com.android.support:support-annotations:27.1.1' } -} -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - // server - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'org.nanohttpd:nanohttpd:2.3.1' - implementation 'com.squareup.okhttp3:okhttp:3.11.0' - implementation 'commons-cli:commons-cli:1.3.1' - - // test - androidTestImplementation 'androidx.test:runner:1.3.0' - - androidTestImplementation 'androidx.test:rules:1.3.0' - androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' - androidTestImplementation 'androidx.core:core:1.3.0' - androidTestImplementation 'androidx.annotation:annotation:1.1.0' - androidTestImplementation 'com.github.briandilley.jsonrpc4j:jsonrpc4j:1.5.0' - - implementation project(':permission') -} + dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.android.support:appcompat-v7:28.0.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.2.60" + // server + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'org.nanohttpd:nanohttpd:2.3.1' + implementation 'com.squareup.okhttp3:okhttp:3.11.0' + implementation 'commons-cli:commons-cli:1.3.1' + implementation 'com.fasterxml.jackson.core:jackson-core:2.11.1' + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.11.1' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.11.1' + + // test + androidTestImplementation 'androidx.test:runner:1.3.0' + + androidTestImplementation 'androidx.test:rules:1.3.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation 'androidx.core:core:1.3.0' + androidTestImplementation 'androidx.annotation:annotation:1.1.0' + androidTestImplementation 'com.github.briandilley.jsonrpc4j:jsonrpc4j:1.5.0' + + implementation project(':permission') + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'com.android.support.test.uiautomator:uiautomator-v18:2.1.3' + androidTestImplementation 'com.android.support:support-v4:28.0.0' + androidTestImplementation 'com.android.support:support-annotations:27.1.1' + androidTestImplementation 'com.nanohttpd:nanohttpd:2.1.1' + androidTestImplementation 'com.fasterxml.jackson.core:jackson-core:2.5.3' + androidTestImplementation 'com.fasterxml.jackson.core:jackson-annotations:2.5.3' + androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.5.3' + androidTestImplementation 'com.github.briandilley.jsonrpc4j:jsonrpc4j:1.1' + androidTestImplementation 'javax.servlet:servlet-api:2.5' + androidTestImplementation 'javax.portlet:portlet-api:2.0' + + // fix try-with-resource warning + // ref: https://stackoverflow.com/questions/40408628/try-with-resources-requires-api-level-19-okhttp + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + } + buildscript { + repositories { + mavenCentral() + } + } -repositories { - mavenCentral() -} + repositories { + mavenCentral() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorService.java b/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorService.java index f35fd8f..af8e599 100644 --- a/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorService.java +++ b/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorService.java @@ -24,11 +24,16 @@ package com.github.uiautomator.stub; import android.os.RemoteException; + import androidx.test.uiautomator.UiObjectNotFoundException; +import com.github.uiautomator.stub.exceptions.NotImplementedException; +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; import com.googlecode.jsonrpc4j.JsonRpcError; import com.googlecode.jsonrpc4j.JsonRpcErrors; +import java.util.List; + public interface AutomatorService { final static int ERROR_CODE_BASE = -32000; @@ -167,6 +172,7 @@ public interface AutomatorService { * @return the absolute path name of dumped file. */ @Deprecated + @JsonRpcErrors({@JsonRpcError(exception=UiAutomator2Exception.class, code=ERROR_CODE_BASE)}) String dumpWindowHierarchy(boolean compressed, String filename); /** @@ -175,6 +181,7 @@ public interface AutomatorService { * @param compressed use compressed layout hierarchy or not using setCompressedLayoutHeirarchy method. Ignore the parameter in case the API level lt 18. * @return the absolute path name of dumped file. */ + @JsonRpcErrors({@JsonRpcError(exception=UiAutomator2Exception.class, code=ERROR_CODE_BASE)}) String dumpWindowHierarchy(boolean compressed); /** @@ -541,6 +548,22 @@ public interface AutomatorService { @JsonRpcErrors({@JsonRpcError(exception = UiObjectNotFoundException.class, code = ERROR_CODE_BASE - 2), @JsonRpcError(exception = NotImplementedException.class, code = ERROR_CODE_BASE - 3)}) boolean gesture(Selector obj, Point startPoint1, Point startPoint2, Point endPoint1, Point endPoint2, int steps) throws UiObjectNotFoundException, NotImplementedException; + //FOR 3 + /** + * Generates a 3-pointer gesture with arbitrary starting and ending points. + * @param obj the target ui object. ?? + * @param startPoint1 start point of pointer 1 + * @param startPoint2 start point of pointer 2 + * @param startPoint3 start point of pointer 3 + * @param endPoint1 end point of pointer 1 + * @param endPoint2 end point of pointer 2 + * @param endPoint3 end point of pointer 3 + * @param steps the number of steps for the gesture. Steps are injected about 5 milliseconds apart, so 100 steps may take around 0.5 seconds to complete. + * @return true if all touch events for this gesture are injected successfully, false otherwise + * @throws UiObjectNotFoundException + */ + @JsonRpcErrors({@JsonRpcError(exception=UiObjectNotFoundException.class, code=ERROR_CODE_BASE-2), @JsonRpcError(exception=NotImplementedException.class, code=ERROR_CODE_BASE-3)}) + boolean gesture(Selector obj, Point startPoint1, Point startPoint2, Point startPoint3, Point endPoint1, Point endPoint2, Point endPoint3, int steps) throws UiObjectNotFoundException, NotImplementedException; /** * Performs a two-pointer gesture, where each pointer moves diagonally toward the other, from the edges to the center of this UiObject . * @@ -1049,4 +1072,20 @@ public interface AutomatorService { * @return Clipboard data or null */ String getClipboard(); + + /** + * Set Configurator. + * @param obj the configurator information to be set. + * @throws NotImplementedException + */ + @JsonRpcErrors({@JsonRpcError(exception=NotImplementedException.class, code=ERROR_CODE_BASE-3)}) + List finds(Selector obj) throws NotImplementedException; + + /** + * toast. + * @param switchStatus the toast information to be get and stop. + * @throws NotImplementedException + */ + @JsonRpcErrors({@JsonRpcError(exception=NotImplementedException.class, code=ERROR_CODE_BASE)}) + String toast(String switchStatus) throws NotImplementedException; } diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorServiceImpl.java b/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorServiceImpl.java index 136afdb..c801815 100644 --- a/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorServiceImpl.java +++ b/app/src/androidTest/java/com/github/uiautomator/stub/AutomatorServiceImpl.java @@ -35,6 +35,10 @@ import android.os.Looper; import android.os.RemoteException; import android.os.SystemClock; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.MotionEvent.PointerCoords; + import androidx.test.InstrumentationRegistry; import androidx.test.uiautomator.Configurator; import androidx.test.uiautomator.Direction; @@ -47,19 +51,22 @@ import androidx.test.uiautomator.UiScrollable; import androidx.test.uiautomator.UiSelector; import androidx.test.uiautomator.Until; -import android.view.KeyEvent; -import android.view.MotionEvent; - import com.github.uiautomator.ToastHelper; +import com.github.uiautomator.stub.exceptions.NotImplementedException; +import com.github.uiautomator.stub.helper.NotificationListener; +import com.github.uiautomator.stub.helper.ReflectionUtils; +import com.github.uiautomator.stub.helper.XMLHierarchy; import com.github.uiautomator.stub.watcher.ClickUiObjectWatcher; import com.github.uiautomator.stub.watcher.PressKeysWatcher; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.Timer; import java.util.TimerTask; @@ -139,8 +146,11 @@ public void setToastListener(boolean enabled) { } else { getUiAutomation().setOnAccessibilityEventListener(null); } - } + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + this.uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); + Device.getInstance().init(device, uiAutomation); + } /** * It's to test if the service is alive. * @@ -290,6 +300,24 @@ public String dumpWindowHierarchy(boolean compressed, String filename) { * @param compressed use compressed layout hierarchy or not using setCompressedLayoutHeirarchy method. Ignore the parameter in case the API level lt 18. * @return the absolute path name of dumped file. */ +// @Override +// public String dumpWindowHierarchy(boolean compressed) { +// device.setCompressedLayoutHeirarchy(compressed); +// try { +// ByteArrayOutputStream os = new ByteArrayOutputStream(); +// device.dumpWindowHierarchy(os); +// os.close(); +// return os.toString("UTF-8"); +// } catch (FileNotFoundException e) { +// e.printStackTrace(); +// } catch (IOException e) { +// e.printStackTrace(); +// } finally { +// device.setCompressedLayoutHeirarchy(false); +// } +// return null; +// } + @Override public String dumpWindowHierarchy(boolean compressed) { device.setCompressedLayoutHeirarchy(compressed); @@ -307,11 +335,13 @@ public String dumpWindowHierarchy(boolean compressed) { } catch (IOException e) { // ignore } + ReflectionUtils.clearAccessibilityCache(); + return XMLHierarchy.getRawStringHierarchy(); } - - return null; } + + /** * Take a screenshot of current window and store it as PNG The screenshot is adjusted per screen rotation * @@ -933,7 +963,7 @@ public ObjInfo[] objInfoOfAllInstances(Selector obj) { /** * Generates a two-pointer gesture with arbitrary starting and ending points. * - * @param obj the target ui object. ?? + * @param obj the target ui object. * @param startPoint1 start point of pointer 1 * @param startPoint2 start point of pointer 2 * @param endPoint1 end point of pointer 1 @@ -951,6 +981,81 @@ private boolean gesture(UiObject obj, Point startPoint1, Point startPoint2, Poin return obj.performTwoPointerGesture(startPoint1.toPoint(), startPoint2.toPoint(), endPoint1.toPoint(), endPoint2.toPoint(), steps); } + //FOR 3 + @Override + public boolean gesture(Selector obj, Point startPoint1, Point startPoint2, Point startPoint3, Point endPoint1, Point endPoint2, Point endPoint3, int steps) throws UiObjectNotFoundException, NotImplementedException { + return gesture(device.findObject(obj.toUiSelector()), startPoint1, startPoint2, startPoint3, endPoint1, endPoint2, endPoint3, steps); + } + //TODO other way to inject multi pointers + private boolean gesture(UiObject obj, Point startPoint1, Point startPoint2, Point startPoint3, Point endPoint1, Point endPoint2, Point endPoint3, int steps) throws UiObjectNotFoundException, NotImplementedException { + //PointerCoords[] pcs = new PointerCoords[3]; + PointerCoords[] points1 = new PointerCoords[steps+2]; + PointerCoords[] points2 = new PointerCoords[steps+2]; + PointerCoords[] points3 = new PointerCoords[steps+2]; + float eventX1 = startPoint1.getX(); + float eventY1 = startPoint1.getY(); + float eventX2 = startPoint2.getX(); + float eventY2 = startPoint2.getY(); + float eventX3 = startPoint3.getX(); + float eventY3 = startPoint3.getY(); + float offY1 = (endPoint1.getY() - eventY1)/steps; + float offY2 = (endPoint2.getY() - eventY2)/steps; + float offY3 = (endPoint3.getY() - eventY3)/steps; + float offX1 = (endPoint1.getX() - eventX1)/steps; + float offX2 = (endPoint2.getX() - eventX2)/steps; + float offX3 = (endPoint3.getX() - eventX3)/steps; + + for (int i = 0; i < steps + 1; i++) { + PointerCoords p1 = new PointerCoords(); + p1.x = eventX1; + p1.y = eventY1; + p1.pressure = 1; + p1.size = 2; + points1[i] = p1; + PointerCoords p2 = new PointerCoords(); + p2.x = eventX2; + p2.y = eventY2; + p2.pressure = 1; + p2.size = 2; + points2[i] = p2; + PointerCoords p3 = new PointerCoords(); + p3.x = eventX3; + p3.y = eventY3; + p3.pressure = 1; + p3.size = 2; + points3[i] = p3; + eventX1 += offX1; + eventY1 += offY1; + eventX2 += offX2; + eventY2 += offY2; + eventX3 += offX3; + eventY3 += offY3; + } + + // ending pointers coordinates + PointerCoords p1 = new PointerCoords(); + p1.x = endPoint1.getX(); + p1.y = endPoint1.getY(); + p1.pressure = 1; + p1.size = 2; + points1[steps + 1] = p1; + PointerCoords p2 = new PointerCoords(); + p2.x = endPoint2.getX(); + p2.y = endPoint2.getY(); + p2.pressure = 1; + p2.size = 2; + points2[steps + 1] = p2; + PointerCoords p3 = new PointerCoords(); + p3.x = endPoint3.getX(); + p3.y = endPoint3.getY(); + p3.pressure = 1; + p3.size = 2; + points3[steps + 1] = p3; + return obj.performMultiPointerGesture(points1, points2, points3); + + } + + /** * Performs a two-pointer gesture, where each pointer moves diagonally toward the other, from the edges to the center of this UiObject . * @@ -1578,6 +1683,25 @@ public boolean gesture(String obj, Point startPoint1, Point startPoint2, Point e return gesture(getUiObject(obj), startPoint1, startPoint2, endPoint1, endPoint2, steps); } + /** + * Generates a 3-pointer gesture with arbitrary starting and ending points. + * + * @param obj the id of target ui object. ?? + * @param startPoint1 start point of pointer 1 + * @param startPoint2 start point of pointer 2 + * @param startPoint3 start point of pointer 3 + * @param endPoint1 end point of pointer 1 + * @param endPoint2 end point of pointer 2 + * @param endPoint3 end point of pointer 3 + * @param steps the number of steps for the gesture. Steps are injected about 5 milliseconds apart, so 100 steps may take around 0.5 seconds to complete. + * @return true if all touch events for this gesture are injected successfully, false otherwise + * @throws UiObjectNotFoundException + */ + public boolean gesture(String obj, Point startPoint1, Point startPoint2, Point startPoint3, Point endPoint1, Point endPoint2, Point endPoint3, int steps) throws UiObjectNotFoundException, NotImplementedException { + return gesture(getUiObject(obj), startPoint1, startPoint2, startPoint3, endPoint1, endPoint2, endPoint3, steps); + } + + /** * Performs a two-pointer gesture, where each pointer moves diagonally toward the other, from the edges to the center of this UiObject . * @@ -1622,6 +1746,7 @@ public boolean swipe(String obj, String dir, int steps) throws UiObjectNotFoundE return swipe(getUiObject(obj), dir, steps); } + /** * Waits a specified length of time for a view to become visible. This method waits until the view becomes visible on the display, or until the timeout has elapsed. You can use this method in situations where the content that you want to select is not immediately displayed. * @@ -1682,4 +1807,30 @@ public String getClipboard() { } return null; } + + @Override + public List finds(Selector obj) throws NotImplementedException { + List objs = new ArrayList<>(); + List obj2s = device.findObjects(obj.toBySelector()); + for(int i=0;i toastMsg = NotificationListener.getInstance().getToastMSGs(); + StringBuilder sb = new StringBuilder(); + for(CharSequence tmp:toastMsg){ + sb.append(tmp.toString()); + } + return sb.toString(); + } + return null; + } } diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/Device.java b/app/src/androidTest/java/com/github/uiautomator/stub/Device.java new file mode 100644 index 0000000..ea9e9e4 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/Device.java @@ -0,0 +1,76 @@ +package com.github.uiautomator.stub; + +import static com.github.uiautomator.stub.helper.ReflectionUtils.getField; +import static com.github.uiautomator.stub.helper.ReflectionUtils.invoke; +import static com.github.uiautomator.stub.helper.ReflectionUtils.method; + +import android.app.UiAutomation; +import androidx.test.uiautomator.UiDevice; +import android.view.Display; + +import com.github.uiautomator.stub.helper.core.InteractionController; +import com.github.uiautomator.stub.helper.core.QueryController; + +/** + * device common method + */ +public class Device { + private static volatile Device instance = null; + private static final String FIELD_QUERY_CONTROLLER = "mQueryController"; + private static final String FIELD_INTERACTION_CONTROLLER = "mInteractionController"; + private static final String METHOD_GET_DEFAULT_DISPLAY = "getDefaultDisplay"; + private UiDevice uiDevice; + private UiAutomation uiAutomation; + private QueryController mQueryController; + private InteractionController mInteractionController; + + private Device(){ + } + + public static Device getInstance(){ + if(instance == null){ + synchronized (Device.class){ + if(instance == null){ + instance = new Device(); + } + } + } + return instance; + } + + public void init(UiDevice uiDevice,UiAutomation uiAutomation){ + this.uiDevice = uiDevice; + this.uiAutomation = uiAutomation; + try{ + mQueryController = new QueryController(getField(UiDevice.class, FIELD_QUERY_CONTROLLER, uiDevice)); + }catch (Exception e){ + Log.e("get query controller error", e); + } + try { + mInteractionController = new InteractionController(getField(UiDevice.class, FIELD_INTERACTION_CONTROLLER, uiDevice)); + } catch (Exception e) { + Log.e("get query controller error", e); + } + + } + + public UiDevice getUiDevice(){ + return uiDevice; + } + + public UiAutomation getUiAutomation(){ return uiAutomation; } + + public Display getDefaultDisplay(){ + return (Display) invoke(method(uiDevice.getClass(),METHOD_GET_DEFAULT_DISPLAY), uiDevice); + } + + public QueryController getQueryController(){ + return mQueryController; + } + + public InteractionController getInteractionController() { + return mInteractionController; + } + + +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/Log.java b/app/src/androidTest/java/com/github/uiautomator/stub/Log.java index da654fd..228e24a 100644 --- a/app/src/androidTest/java/com/github/uiautomator/stub/Log.java +++ b/app/src/androidTest/java/com/github/uiautomator/stub/Log.java @@ -33,8 +33,14 @@ public static void d(String msg) { public static void i(String msg, String s) { android.util.Log.i(TAG, msg); } - + public static void i(String msg) { + android.util.Log.i(TAG, msg); + } public static void e(String msg) { android.util.Log.e(TAG, msg); } + + public static void e(String msg,Throwable e) { + android.util.Log.e(TAG, msg, e); + } } diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/Stub.java b/app/src/androidTest/java/com/github/uiautomator/stub/Stub.java index 702da13..f59ba8e 100644 --- a/app/src/androidTest/java/com/github/uiautomator/stub/Stub.java +++ b/app/src/androidTest/java/com/github/uiautomator/stub/Stub.java @@ -31,7 +31,6 @@ import androidx.test.filters.SdkSuppress; import androidx.test.runner.AndroidJUnit4; import androidx.test.uiautomator.By; -import androidx.test.uiautomator.Configurator; import androidx.test.uiautomator.UiDevice; import androidx.test.uiautomator.UiObjectNotFoundException; import androidx.test.uiautomator.Until; @@ -41,9 +40,9 @@ import com.googlecode.jsonrpc4j.ErrorResolver; import com.googlecode.jsonrpc4j.JsonRpcServer; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.runner.RunWith; import java.io.PrintWriter; @@ -67,7 +66,7 @@ public class Stub { int PORT = 9008; AutomatorHttpServer server = new AutomatorHttpServer(PORT); - @Before + @BeforeEach public void setUp() throws Exception { launchService(); JsonRpcServer jrs = new JsonRpcServer(new ObjectMapper(), new AutomatorServiceImpl(), AutomatorService.class); @@ -125,7 +124,7 @@ private void startMonitorService(Context context) { context.startService(intent); } - @After + @AfterEach public void tearDown() { server.stop(); Context context = InstrumentationRegistry.getContext(); diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/Stub.kt b/app/src/androidTest/java/com/github/uiautomator/stub/Stub.kt new file mode 100644 index 0000000..5606da7 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/Stub.kt @@ -0,0 +1,70 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2015 xiaocong@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.github.uiautomator.stub + +import androidx.test.InstrumentationRegistry +import androidx.test.filters.SdkSuppress +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import com.fasterxml.jackson.databind.ObjectMapper +import com.googlecode.jsonrpc4j.JsonRpcServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.filters.LargeTest; +import androidx.test.filters.FlakyTest; + +/** + * Use JUnit test to start the uiautomator jsonrpc server. + * @author xiaocong@gmail.com + */ +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 18) +public class Stub { + val PORT = 9008 + val server: AutomatorHttpServer by lazy { AutomatorHttpServer(PORT) } + + @Before + public fun setUp() { + server.route("/jsonrpc/0", JsonRpcServer(ObjectMapper(), AutomatorServiceImpl(), AutomatorService::class.java)) + server.start() + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).wakeUp() + } + + @After + public fun tearDown() { + server.stop() + } + + @Test + @LargeTest + @FlakyTest + @Throws(InterruptedException::class) + public fun testUIAutomatorStub() { + while (server.isAlive()) + Thread.sleep(100) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/NotImplementedException.java b/app/src/androidTest/java/com/github/uiautomator/stub/exceptions/NotImplementedException.java similarity index 97% rename from app/src/androidTest/java/com/github/uiautomator/stub/NotImplementedException.java rename to app/src/androidTest/java/com/github/uiautomator/stub/exceptions/NotImplementedException.java index 9bf1cd6..d27a2e3 100644 --- a/app/src/androidTest/java/com/github/uiautomator/stub/NotImplementedException.java +++ b/app/src/androidTest/java/com/github/uiautomator/stub/exceptions/NotImplementedException.java @@ -21,7 +21,7 @@ * OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.github.uiautomator.stub; +package com.github.uiautomator.stub.exceptions; import android.os.Build; diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/exceptions/UiAutomator2Exception.java b/app/src/androidTest/java/com/github/uiautomator/stub/exceptions/UiAutomator2Exception.java new file mode 100644 index 0000000..d6df536 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/exceptions/UiAutomator2Exception.java @@ -0,0 +1,17 @@ +package com.github.uiautomator.stub.exceptions; + +public class UiAutomator2Exception extends RuntimeException { + private static final long serialVersionUID = -1592305571101012889L; + + public UiAutomator2Exception(String message) { + super(message); + } + + public UiAutomator2Exception(Throwable t) { + super(t); + } + + public UiAutomator2Exception(String message, Throwable t) { + super(message, t); + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/NotificationListener.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/NotificationListener.java new file mode 100644 index 0000000..e2ec4cb --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/NotificationListener.java @@ -0,0 +1,88 @@ +package com.github.uiautomator.stub.helper; + +import android.app.UiAutomation; +import android.view.accessibility.AccessibilityEvent; +import com.github.uiautomator.stub.Device; +import java.util.ArrayList; +import java.util.List; +import static java.lang.System.currentTimeMillis; + +/** + * 监听toast信息 + */ +public final class NotificationListener { + private static List toastMessages = new ArrayList(); + private final static NotificationListener INSTANCE = new NotificationListener(); + private Thread toastThread = null; + private boolean stopLooping = false; + + private NotificationListener(){ + + } + + public static NotificationListener getInstance(){ + return INSTANCE; + } + + /** + * Listens for Notification Messages + */ + public void start(){ + if(toastThread == null){ + toastThread = new Thread(new Listener()); + toastThread.setDaemon(true); + toastThread.start(); + stopLooping = false; + } + } + + public void stop(){ + stopLooping = true; + try{ + if(toastThread.isAlive()){ + toastThread.stop(); + } + }catch (Exception e){ + + } + toastThread = null; + } + + public List getToastMSGs() { + List result = new ArrayList(); + result.addAll(toastMessages); + toastMessages.clear(); + return result; + } + + private class Listener implements Runnable{ + @Override + public void run() { + while (!stopLooping) { + AccessibilityEvent accessibilityEvent = null; + //return true if the AccessibilityEvent type is NOTIFICATION type + UiAutomation.AccessibilityEventFilter eventFilter = new UiAutomation.AccessibilityEventFilter() { + @Override + public boolean accept(AccessibilityEvent event) { + return event.getEventType() == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED; + } + }; + Runnable runnable = new Runnable() { + @Override + public void run() { + // Not performing any event. + } + }; + try { + //wait for AccessibilityEvent filter + accessibilityEvent = Device.getInstance().getUiAutomation() + .executeAndWaitForEvent(runnable /*executable event*/, eventFilter /* event to filter*/, 500 /*time out in ms*/); + } catch (Exception ignore) {} + + if (accessibilityEvent != null) { + toastMessages.addAll(accessibilityEvent.getText()); + } + } + } + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/ReflectionUtils.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/ReflectionUtils.java new file mode 100644 index 0000000..20423b5 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/ReflectionUtils.java @@ -0,0 +1,95 @@ +package com.github.uiautomator.stub.helper; + +import com.github.uiautomator.stub.Log; +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; + +public class ReflectionUtils { + + /** + * Clears the in-process Accessibility cache, removing any stale references. Because the + * AccessibilityInteractionClient singleton stores copies of AccessibilityNodeInfo instances, + * calls to public APIs such as `recycle` do not guarantee cached references get updated. See + * the android.view.accessibility AIC and ANI source code for more information. + */ + public static boolean clearAccessibilityCache() throws UiAutomator2Exception { + boolean success = false; + try { + final Class c = Class + .forName("android.view.accessibility.AccessibilityInteractionClient"); + final Method getInstance = ReflectionUtils.method(c, "getInstance"); + final Object instance = getInstance.invoke(null); + final Method clearCache = ReflectionUtils.method(instance.getClass(), + "clearCache"); + clearCache.invoke(instance); + success = true; + } catch (IllegalAccessException e) { + Log.e("Failed to clear Accessibility Node cache. ", e); + } catch (InvocationTargetException e) { + Log.e("Failed to clear Accessibility Node cache. ", e); + } catch (ClassNotFoundException e) { + Log.e("Failed to clear Accessibility Node cache. ", e); + } + return success; + } + + public static Class getClass(final String name) throws UiAutomator2Exception { + try { + return Class.forName(name); + } catch (final ClassNotFoundException e) { + final String msg = String.format("unable to find class %s", name); + throw new UiAutomator2Exception(msg, e); + } + } + + public static Object getField(final Class clazz, final String fieldName, final Object object) throws UiAutomator2Exception { + try { + final Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(object); + } catch (final Exception e) { + final String msg = String.format("error while getting field %s from object %s", fieldName, object); + Log.e(msg + " " + e.getMessage()); + throw new UiAutomator2Exception(msg, e); + } + } + + public static Object getField(final String field, final Object object) throws UiAutomator2Exception { + return getField(object.getClass(), field, object); + } + + public static Object getField(final String className, final String field, final Object object) throws UiAutomator2Exception { + return getField(getClass(className), field, object); + } + + public static Object invoke(final Method method, final Object object, final Object... parameters) throws UiAutomator2Exception { + try { + return method.invoke(object, parameters); + } catch (final Exception e) { + final String msg = String.format("error while invoking method %s on object %s with parameters %s", method, object, Arrays.toString(parameters)); + Log.e(msg + " " + e.getMessage()); + throw new UiAutomator2Exception(msg, e); + } + } + + public static Method method(final Class clazz, final String methodName, final Class... parameterTypes) throws UiAutomator2Exception { + try { + final Method method = clazz.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + + return method; + } catch (final Exception e) { + final String msg = String.format("error while getting method %s from class %s with parameter types %s", methodName, clazz, Arrays.toString(parameterTypes)); + Log.e(msg + " " + e.getMessage()); + throw new UiAutomator2Exception(msg, e); + } + } + + public static Method method(final String className, final String method, final Class... parameterTypes) throws UiAutomator2Exception { + return method(getClass(className), method, parameterTypes); + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/XMLHierarchy.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/XMLHierarchy.java new file mode 100644 index 0000000..a74adbb --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/XMLHierarchy.java @@ -0,0 +1,176 @@ +package com.github.uiautomator.stub.helper; + +import android.os.SystemClock; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.github.uiautomator.stub.Device; +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; +import com.github.uiautomator.stub.helper.core.AccessibilityNodeInfoDumper; + +import org.w3c.dom.Document; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import java.io.StringReader; +import java.util.HashMap; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +public abstract class XMLHierarchy { + + + private static XPathExpression compileXpath(String xpathExpression) throws UiAutomator2Exception { + XPath xpath = XPathFactory.newInstance().newXPath(); + XPathExpression exp = null; + try { + exp = xpath.compile(xpathExpression); + } catch (XPathExpressionException e) { + throw new UiAutomator2Exception("Invalid XPath expression: ", e); + } + return exp; + } + + public static InputSource getRawXMLHierarchy() throws UiAutomator2Exception { + AccessibilityNodeInfo root = getRootAccessibilityNode(); + return getRawXMLHierarchy(root); + } + + + public static InputSource getRawXMLHierarchy(AccessibilityNodeInfo root) throws UiAutomator2Exception { + String xmlDump = AccessibilityNodeInfoDumper.getWindowXMLHierarchy(root); + return new InputSource(new StringReader(xmlDump)); + } + + /** + * 获取字符串形式 + * @return + * @throws UiAutomator2Exception + */ + public static String getRawStringHierarchy() throws UiAutomator2Exception { + AccessibilityNodeInfo root = getCurstomRootAccessibilityNode(); + return AccessibilityNodeInfoDumper.getWindowXMLHierarchy(root); + } + + public static Node getFormattedXMLDoc() throws UiAutomator2Exception { + return formatXMLInput(getRawXMLHierarchy()); + } + + + public static Node formatXMLInput(InputSource input) { + XPath xpath = XPathFactory.newInstance().newXPath(); + + Node root = null; + try { + root = (Node) xpath.evaluate("/", input, XPathConstants.NODE); + } catch (XPathExpressionException e) { + throw new RuntimeException("Could not read xml hierarchy: ", e); + } + + HashMap instances = new HashMap(); + + // rename all the nodes with their "class" attribute + // add an instance attribute + annotateNodes(root, instances); + + return root; + } + + + private static void annotateNodes(Node node, HashMap instances) { + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + visitNode(children.item(i), instances); + annotateNodes(children.item(i), instances); + } + } + } + + // set the node's tag name to the same as it's android class. + // also number all instances of each class with an "instance" number. It increments + // for each class separately. + // this allows use to use class and instance to identify a node. + // we also take this chance to clean class names that might have dollar signs in + // them (and other odd characters) + private static void visitNode(Node node, HashMap instances) { + + Document doc = node.getOwnerDocument(); + NamedNodeMap attributes = node.getAttributes(); + + String androidClass; + try { + androidClass = attributes.getNamedItem("class").getNodeValue(); + } catch (Exception e) { + return; + } + + androidClass = cleanTagName(androidClass); + + if (!instances.containsKey(androidClass)) { + instances.put(androidClass, 0); + } + Integer instance = instances.get(androidClass); + + Node attrNode = doc.createAttribute("instance"); + attrNode.setNodeValue(instance.toString()); + attributes.setNamedItem(attrNode); + + doc.renameNode(node, node.getNamespaceURI(), androidClass); + + instances.put(androidClass, instance + 1); + } + + private static String cleanTagName(String name) { + name = name.replaceAll("[$@#&]", "."); + return name.replaceAll("\\s", ""); + } + + /** + * 减少等待时间 + * @return + * @throws UiAutomator2Exception + */ + public static AccessibilityNodeInfo getCurstomRootAccessibilityNode() throws UiAutomator2Exception { + final long timeoutMillis = 3000; + Device.getInstance().getUiDevice().waitForIdle(timeoutMillis); + long end = SystemClock.uptimeMillis() + 10000; + while (true) { + AccessibilityNodeInfo root = Device.getInstance().getQueryController().getAccessibilityRootNode(); + if (root != null) { + return root; + } + long remainingMillis = end - SystemClock.uptimeMillis(); + if (remainingMillis < 0) { + throw new UiAutomator2Exception( + String.format("Timed out after %d milliseconds waiting for root AccessibilityNodeInfo", + timeoutMillis)); + } + SystemClock.sleep(Math.min(250, remainingMillis)); + } + } + + public static AccessibilityNodeInfo getRootAccessibilityNode() throws UiAutomator2Exception { + final long timeoutMillis = 10000; + Device.getInstance().getUiDevice().waitForIdle(timeoutMillis); + long end = SystemClock.uptimeMillis() + timeoutMillis; + while (true) { + AccessibilityNodeInfo root = Device.getInstance().getQueryController().getAccessibilityRootNode(); + if (root != null) { + return root; + } + long remainingMillis = end - SystemClock.uptimeMillis(); + if (remainingMillis < 0) { + throw new UiAutomator2Exception( + String.format("Timed out after %d milliseconds waiting for root AccessibilityNodeInfo", + timeoutMillis)); + } + SystemClock.sleep(Math.min(250, remainingMillis)); + } + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/AccessibilityNodeInfoDumper.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/AccessibilityNodeInfoDumper.java new file mode 100644 index 0000000..b88f0e0 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/AccessibilityNodeInfoDumper.java @@ -0,0 +1,218 @@ +package com.github.uiautomator.stub.helper.core; + +import android.graphics.Point; +import android.graphics.Rect; +import android.os.Build; +import android.os.SystemClock; +import android.util.Xml; +import android.view.Display; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.github.uiautomator.stub.Device; +import com.github.uiautomator.stub.Log; +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; + +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.regex.Pattern; + + +/** + * The AccessibilityNodeInfoDumper in Android Open Source Project contains a lot of bugs which will + * stay in old android versions forever. By coping the code of the latest version it is ensured that + * all patches become available on old android versions.

down ported bugs are e.g. { @link + * https://code.google.com/p/android/issues/detail?id=62906 } { @link + * https://code.google.com/p/android/issues/detail?id=58733 } + */ +public class AccessibilityNodeInfoDumper { + private static final String[] NAF_EXCLUDED_CLASSES = new String[]{android.widget.GridView.class.getName(), android.widget.GridLayout.class.getName(), android.widget.ListView.class.getName(), android.widget.TableLayout.class.getName()}; + // XML 1.0 Legal Characters (http://stackoverflow.com/a/4237934/347155) + // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + private static Pattern XML10Pattern = Pattern.compile("[^" + "\u0009\r\n" + "\u0020-\uD7FF" + "\uE000-\uFFFD" + "\ud800\udc00-\udbff\udfff" + "]"); + + /** + * Using {@link AccessibilityNodeInfo} this method will walk the layout hierarchy and return + * String object of xml hierarchy + * + * @param root The root accessibility node. + */ + public static String getWindowXMLHierarchy(AccessibilityNodeInfo root) throws UiAutomator2Exception { + final long startTime = SystemClock.uptimeMillis(); + StringWriter xmlDump = new StringWriter(); + try { + + XmlSerializer serializer = Xml.newSerializer(); + serializer.setOutput(xmlDump); + serializer.startDocument("UTF-8", true); + serializer.startTag("", "hierarchy"); + + if (root != null) { + int width = -1; + int height = -1; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + // getDefaultDisplay method available since API level 18 + Display display = Device.getInstance().getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + width = size.x; + height = size.y; + + serializer.attribute("", "rotation", Integer.toString(display.getRotation())); + } + + dumpNodeRec(root, serializer, 0, width, height); + } + + serializer.endTag("", "hierarchy"); + serializer.endDocument(); + + /*FileWriter writer = new FileWriter(dumpFile); + writer.write(stringWriter.toString()); + writer.close();*/ + } catch (IOException e) { + Log.e("failed to dump window to file", e); + } + final long endTime = SystemClock.uptimeMillis(); + Log.i("Fetch time: " + (endTime - startTime) + "ms"); + return xmlDump.toString(); + } + + + private static void dumpNodeRec(AccessibilityNodeInfo node, XmlSerializer serializer, int index, int width, int height) throws IOException { + serializer.startTag("", "node"); + if (!nafExcludedClass(node) && !nafCheck(node)) + serializer.attribute("", "NAF", Boolean.toString(true)); + serializer.attribute("", "index", Integer.toString(index)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + serializer.attribute("", "resource-id", safeCharSeqToString(node.getViewIdResourceName())); + } + serializer.attribute("", "text", safeCharSeqToString(node.getText())); + serializer.attribute("", "class", safeCharSeqToString(node.getClassName())); + serializer.attribute("", "package", safeCharSeqToString(node.getPackageName())); + serializer.attribute("", "content-desc", safeCharSeqToString(node.getContentDescription())); + serializer.attribute("", "checkable", Boolean.toString(node.isCheckable())); + serializer.attribute("", "checked", Boolean.toString(node.isChecked())); + serializer.attribute("", "clickable", Boolean.toString(node.isClickable())); + serializer.attribute("", "enabled", Boolean.toString(node.isEnabled())); + serializer.attribute("", "focusable", Boolean.toString(node.isFocusable())); + serializer.attribute("", "focused", Boolean.toString(node.isFocused())); + serializer.attribute("", "scrollable", Boolean.toString(node.isScrollable())); + serializer.attribute("", "long-clickable", Boolean.toString(node.isLongClickable())); + serializer.attribute("", "password", Boolean.toString(node.isPassword())); + serializer.attribute("", "selected", Boolean.toString(node.isSelected())); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + serializer.attribute("", "bounds", getVisibleBoundsInScreen(node, width, height).toShortString()); + } + int count = node.getChildCount(); + for (int i = 0; i < count; i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + if (child.isVisibleToUser()) { + dumpNodeRec(child, serializer, i, width, height); + child.recycle(); + } else { + Log.i(String.format("Skipping invisible child: %s", child.toString())); + } + } else { + Log.i(String.format("Null child %d/%d, parent: %s", i, count, node.toString())); + } + } + serializer.endTag("", "node"); + } + + /** + * The list of classes to exclude my not be complete. We're attempting to only reduce noise from + * standard layout classes that may be falsely configured to accept clicks and are also + * enabled. + * + * @return true if node is excluded. + */ + private static boolean nafExcludedClass(AccessibilityNodeInfo node) { + String className = safeCharSeqToString(node.getClassName()); + for (String excludedClassName : NAF_EXCLUDED_CLASSES) { + if (className.endsWith(excludedClassName)) return true; + } + return false; + } + + /** + * We're looking for UI controls that are enabled, clickable but have no text nor + * content-description. Such controls configuration indicate an interactive control is present + * in the UI and is most likely not accessibility friendly. We refer to such controls here as + * NAF controls (Not Accessibility Friendly) + * + * @return false if a node fails the check, true if all is OK + */ + private static boolean nafCheck(AccessibilityNodeInfo node) { + boolean isNaf = node.isClickable() && node.isEnabled() && safeCharSeqToString(node.getContentDescription()).isEmpty() && safeCharSeqToString(node.getText()).isEmpty(); + if (!isNaf) return true; + // check children since sometimes the containing element is clickable + // and NAF but a child's text or description is available. Will assume + // such layout as fine. + return childNafCheck(node); + } + + /** + * This should be used when it's already determined that the node is NAF and a further check of + * its children is in order. A node maybe a container such as LinerLayout and may be set to be + * clickable but have no text or content description but it is counting on one of its children + * to fulfill the requirement for being accessibility friendly by having one or more of its + * children fill the text or content-description. Such a combination is considered by this + * dumper as acceptable for accessibility. + * + * @return false if node fails the check. + */ + private static boolean childNafCheck(AccessibilityNodeInfo node) { + int childCount = node.getChildCount(); + for (int x = 0; x < childCount; x++) { + AccessibilityNodeInfo childNode = node.getChild(x); + if (childNode == null) { + Log.i(String.format("Null child %d/%d, parent: %s", x, childCount, node.toString())); + continue; + } + if (!safeCharSeqToString(childNode.getContentDescription()).isEmpty() || !safeCharSeqToString(childNode.getText()).isEmpty()) + return true; + if (childNafCheck(childNode)) return true; + } + return false; + } + + private static String safeCharSeqToString(CharSequence cs) { + if (cs == null) return ""; + else { + return stripInvalidXMLChars(cs); + } + } + + // Original Google code here broke UTF characters + private static String stripInvalidXMLChars(CharSequence charSequence) { + final StringBuilder sb = new StringBuilder(charSequence.length()); + sb.append(charSequence); + return XML10Pattern.matcher(sb.toString()).replaceAll("?"); + } + + /** + * Returns the node's bounds clipped to the size of the display + * + * @param width pixel width of the display + * @param height pixel height of the display + * @return null if node is null, else a Rect containing visible bounds + */ + static Rect getVisibleBoundsInScreen(AccessibilityNodeInfo node, int width, int height) { + if (node == null) { + return null; + } + // targeted node's bounds + Rect nodeRect = new Rect(); + node.getBoundsInScreen(nodeRect); + Rect displayRect = new Rect(); + displayRect.top = 0; + displayRect.left = 0; + displayRect.right = width; + displayRect.bottom = height; + nodeRect.intersect(displayRect); + return nodeRect; + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/InteractionController.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/InteractionController.java new file mode 100644 index 0000000..453f0a4 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/InteractionController.java @@ -0,0 +1,60 @@ +package com.github.uiautomator.stub.helper.core; + +import android.view.InputEvent; +import android.view.MotionEvent.PointerCoords; + +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; +import com.github.uiautomator.stub.helper.ReflectionUtils; + +public class InteractionController { + + public static final String METHOD_PERFORM_MULTI_POINTER_GESTURE = "performMultiPointerGesture"; + private static final String CLASS_INTERACTION_CONTROLLER = "android.support.test.uiautomator.InteractionController"; + private static final String METHOD_SEND_KEY = "sendKey"; + private static final String METHOD_SEND_TEXT = "sendText"; + private static final String METHOD_INJECT_EVENT_SYNC = "injectEventSync"; + private static final String METHOD_TOUCH_DOWN = "touchDown"; + private static final String METHOD_TOUCH_UP = "touchUp"; + private static final String METHOD_TOUCH_MOVE = "touchMove"; + private static final String METHOD_CLICK = "clickNoSync"; + private static final String METHOD_LONG_CLICK = "longTapNoSync"; + private final Object interactionController; + + public InteractionController(Object interactionController) { + this.interactionController = interactionController; + } + + public boolean sendKey(int keyCode, int metaState) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_SEND_KEY, int.class, int.class), interactionController, keyCode, metaState); + } + + public boolean sendText(String text) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_SEND_TEXT, String.class), interactionController, text); + } + + public boolean injectEventSync(InputEvent event) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_INJECT_EVENT_SYNC, InputEvent.class), interactionController, event); + } + + public boolean touchDown(int x, int y) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_TOUCH_DOWN, int.class, int.class), interactionController, x, y); + } + + public boolean touchUp(int x, int y) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_TOUCH_UP, int.class, int.class), interactionController, x, y); + } + + public boolean touchMove(int x, int y) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_TOUCH_MOVE, int.class, int.class), interactionController, x, y); + } + public boolean clickNoSync(int x,int y) throws UiAutomator2Exception{ + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_CLICK, int.class, int.class), interactionController, x, y); + } + public boolean longTapNoSync(int x,int y) throws UiAutomator2Exception{ + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_LONG_CLICK, int.class, int.class), interactionController, x, y); + } + + public Boolean performMultiPointerGesture(PointerCoords[][] pcs) throws UiAutomator2Exception { + return (Boolean) ReflectionUtils.invoke(ReflectionUtils.method(CLASS_INTERACTION_CONTROLLER, METHOD_PERFORM_MULTI_POINTER_GESTURE, PointerCoords[][].class), interactionController, (Object) pcs); + } +} diff --git a/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/QueryController.java b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/QueryController.java new file mode 100644 index 0000000..1d006f1 --- /dev/null +++ b/app/src/androidTest/java/com/github/uiautomator/stub/helper/core/QueryController.java @@ -0,0 +1,25 @@ +package com.github.uiautomator.stub.helper.core; + +import android.view.accessibility.AccessibilityNodeInfo; + +import com.github.uiautomator.stub.exceptions.UiAutomator2Exception; + +import static com.github.uiautomator.stub.helper.ReflectionUtils.invoke; +import static com.github.uiautomator.stub.helper.ReflectionUtils.method; + +public class QueryController { + + private static final String CLASS_QUERY_CONTROLLER = "android.support.test.uiautomator.QueryController"; + private static final String METHOD_GET_ACCESSIBILITY_ROOT_NODE = "getRootNode"; + + private final Object queryController; + + public QueryController(Object queryController) { + this.queryController = queryController; + } + + public AccessibilityNodeInfo getAccessibilityRootNode() throws UiAutomator2Exception { + return (AccessibilityNodeInfo) invoke(method(CLASS_QUERY_CONTROLLER, METHOD_GET_ACCESSIBILITY_ROOT_NODE), queryController); + } + +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0e6a18e..798a546 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -20,12 +20,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> +