Skip to content

Commit 919109d

Browse files
authored
Add direct navigation to UiAutomator2 support (#308)
1 parent 2df68b5 commit 919109d

File tree

14 files changed

+285
-28
lines changed

14 files changed

+285
-28
lines changed

espressoDeps.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ System.setProperty('selenium.context.platform', 'android')
55
System.setProperty('selenium.grid.examples', 'false')
66
System.setProperty('appium.with.pm2', 'true')
77
dependencies {
8-
testImplementation('io.appium:java-client') {
8+
api('io.appium:java-client') {
99
exclude group: 'org.seleniumhq.selenium', module: 'selenium-java'
1010
exclude group: 'org.seleniumhq.selenium', module: 'selenium-support'
1111
exclude group: 'org.slf4j', module: 'slf4j-api'

mac2Deps.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ System.setProperty('selenium.context.platform', 'mac-app')
55
System.setProperty('selenium.grid.examples', 'false')
66
System.setProperty('appium.with.pm2', 'true')
77
dependencies {
8-
testImplementation('io.appium:java-client') {
8+
api('io.appium:java-client') {
99
exclude group: 'org.seleniumhq.selenium', module: 'selenium-java'
1010
exclude group: 'org.seleniumhq.selenium', module: 'selenium-support'
1111
exclude group: 'org.slf4j', module: 'slf4j-api'

selenium3Deps.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ dependencies {
4444
api 'org.eclipse.jetty.websocket:websocket-client:9.4.57.v20241219'
4545
api 'org.jetbrains.kotlin:kotlin-stdlib:2.1.20'
4646
api 'org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20'
47-
testImplementation 'io.appium:java-client:7.6.0'
47+
api 'io.appium:java-client:7.6.0'
4848
testImplementation 'org.mockito:mockito-core:4.11.0'
4949
}
5050
api 'com.nordstrom.tools:testng-foundation'

selenium4Deps.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ dependencies {
4343
api 'com.beust:jcommander:1.82'
4444
api 'io.netty:netty-transport-native-epoll:4.1.119.Final'
4545
api 'io.netty:netty-transport-native-kqueue:4.1.119.Final'
46-
testImplementation 'io.appium:java-client:10.0.0'
46+
api 'io.appium:java-client:10.0.0'
4747
testImplementation 'org.mockito:mockito-core:4.11.0'
4848
}
4949
api 'com.nordstrom.tools:testng-foundation'

src/main/java/com/nordstrom/automation/selenium/annotations/PageUrl.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,18 @@
126126
* @return application package name
127127
*/
128128
String appPackage() default "{}";
129+
130+
/**
131+
* Get the Android intent action.
132+
*
133+
* @return intent action
134+
*/
135+
String action() default "{}";
136+
137+
/**
138+
* Get the Android intent category.
139+
*
140+
* @return intent category
141+
*/
142+
String category() default "{}";
129143
}

src/main/java/com/nordstrom/automation/selenium/model/ComponentContainer.java

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import java.util.ArrayList;
99
import java.util.Arrays;
1010
import java.util.Collections;
11-
import java.util.HashMap;
1211
import java.util.Iterator;
1312
import java.util.List;
1413
import java.util.Map;
@@ -21,7 +20,6 @@
2120
import org.apache.http.client.utils.URLEncodedUtils;
2221
import org.apache.http.message.BasicNameValuePair;
2322
import org.openqa.selenium.By;
24-
import org.openqa.selenium.JavascriptExecutor;
2523
import org.openqa.selenium.Keys;
2624
import org.openqa.selenium.SearchContext;
2725
import org.openqa.selenium.StaleElementReferenceException;
@@ -43,6 +41,7 @@
4341
import com.nordstrom.automation.selenium.exceptions.VacationStackTrace;
4442
import com.nordstrom.automation.selenium.interfaces.WrapsContext;
4543
import com.nordstrom.automation.selenium.model.Page.WindowState;
44+
import com.nordstrom.automation.selenium.plugins.AndroidActivityLauncher;
4645
import com.nordstrom.automation.selenium.support.Coordinator;
4746
import com.nordstrom.automation.selenium.support.SearchContextWait;
4847
import com.nordstrom.common.base.UncheckedThrow;
@@ -651,18 +650,12 @@ public void close() {
651650
* @param url target URL or activity
652651
* @param driver driver object
653652
*/
654-
@SuppressWarnings("serial")
655653
public static void getUrl(final String url, final WebDriver driver) {
656654
Objects.requireNonNull(url, "[url] must be non-null");
657655
Objects.requireNonNull(driver, "[driver] must be non-null");
658656

659657
if (url.startsWith("activity://")) {
660-
String[] components = url.split("/");
661-
((JavascriptExecutor) driver).executeScript("mobile: startActivity",
662-
new HashMap<String, String>() {{
663-
put("package", components[2]);
664-
put("appActivity", components[3]);
665-
}});
658+
AndroidActivityLauncher.startAndroidActivity(driver, url);
666659
} else {
667660
driver.get(url);
668661
}
@@ -718,8 +711,21 @@ public static String getPageUrl(final PageUrl pageUrl, final URI targetUri) {
718711

719712
// if Android activity is specified
720713
if ( ! PLACEHOLDER.equals(appPackage)) {
721-
// assemble Android activity URL
722-
result = "activity://" + appPackage + "/" + path;
714+
String action = pageUrl.action();
715+
String category = pageUrl.category();
716+
717+
URIBuilder builder = new URIBuilder().setScheme("activity").setHost(appPackage).setPath(path);
718+
719+
if (!PLACEHOLDER.equals(action)) {
720+
builder.addParameter("action", action);
721+
}
722+
723+
if (!PLACEHOLDER.equals(category)) {
724+
builder.addParameter("category", category);
725+
}
726+
727+
result = builder.toString();
728+
723729
// otherwise, if file is specified
724730
} else if ("file".equals(scheme)) {
725731
// resolve file path using context class loader

src/main/java/com/nordstrom/automation/selenium/model/ContainerMethodInterceptor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ public Object intercept(@This final Object obj, @Origin final Method method, @Al
142142
} catch (NoAlertPresentException eaten) {
143143
try {
144144
// get stale wait reference element by XPath
145-
reference = driver.findElement(By.xpath("/*"));
145+
reference = driver.findElement(By.xpath("//*"));
146146
} catch (WebDriverException e) {
147147
// get stale wait reference element by CSS selector
148148
reference = driver.findElement(By.cssSelector("*"));

src/main/java/com/nordstrom/automation/selenium/model/RobustElementFactory.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ private static synchronized InstanceCreator getCreator(final WrapsContext contex
121121
}
122122

123123
try {
124-
reference = driver.findElement(By.xpath("/*"));
124+
reference = driver.findElement(By.xpath("//*"));
125125
} catch (WebDriverException e) {
126126
reference = driver.findElement(By.cssSelector("*"));
127127
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package com.nordstrom.automation.selenium.plugins;
2+
3+
import org.openqa.selenium.HasCapabilities;
4+
import org.openqa.selenium.JavascriptExecutor;
5+
import org.openqa.selenium.WebDriver;
6+
7+
import java.net.URI;
8+
import java.net.URLDecoder;
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.ArrayList;
11+
import java.util.Collections;
12+
import java.util.HashMap;
13+
import java.util.LinkedHashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.Optional;
17+
import java.util.Map.Entry;
18+
import java.util.stream.Collectors;
19+
import java.util.stream.Stream;
20+
21+
/**
22+
* This utility class contains low-level methods that enable starting specified activities of <b>Android</b>
23+
* applications. The implementation uses {@code mobile: startActivity} with the <b>Espresso</b> engine and
24+
* {@code mobile: shell} with the <b>UiAutomator2</b> engine.
25+
*/
26+
public class AndroidActivityLauncher {
27+
28+
/**
29+
* Private constructor to prevent instantiation.
30+
*/
31+
private AndroidActivityLauncher() {
32+
throw new AssertionError("AndroidActivityLauncher is a static utility class that cannot be instantiated");
33+
}
34+
35+
/**
36+
* Start the Android activity indicated by the specified URL.
37+
* <p>
38+
* <b>NOTE</b>: The URL string implements the following format: <ul>
39+
* <li><b>scheme</b>: activity</li>
40+
* <li><b>host</b>: Android application package name</li>
41+
* <li><b>path</b>: Android application activity name</li>
42+
* <li><b>query parameters [optional]</b>: <ul>
43+
* <li><b>action</b>: intent action (e.g. - {@code android.intent.action.MAIN})</li>
44+
* <li><b>category</b>: intent category (e.g. - {@code android.intent.category.LAUNCHER})</li>
45+
* <li><b>(intent arguments)</b>: intent arguments as key/value pairs: <ul>
46+
* <li><b>NOTE</b>: Argument keys should be prefixed with one of these type specifiers: <ul>
47+
* <li><b>es:</b> = string value</li>
48+
* <li><b>ei:</b> = integer value</li>
49+
* <li><b>el:</b> = long value</li>
50+
* <li><b>ef:</b> = float value</li>
51+
* <li><b>ed:</b> = double value</li>
52+
* <li><b>ez:</b> = boolean value</li>
53+
* </ul></li>
54+
* <li><b>EXAMPLE</b>: {"es:name": "Dennis", "ei:age": 37, "ez:is-king", false}</li>
55+
* </ul></li>
56+
* </ul></li>
57+
* </ul>
58+
* <b>EXAMPLE</b>: {@code activity://io.appium.android.apis/.app.SearchInvoke} <br>
59+
* <b>NOTE</b>: Unqualified (relative) activity names like this are prefixed with the package
60+
* name to form a fully-qualified name (e.g. - {@code io.appium.android.apis.app.SearchInvoke})
61+
*
62+
* @param driver Android driver
63+
* @param activityUrl activity specifier encoded as a URL string
64+
*/
65+
public static void startAndroidActivity(final WebDriver driver, final String activityUrl) {
66+
URI uri = URI.create(activityUrl);
67+
if (!"activity".equals(uri.getScheme())) {
68+
throw new IllegalArgumentException("Unsupported scheme: " + uri.getScheme());
69+
}
70+
71+
String authority = uri.getAuthority(); // package
72+
String path = uri.getPath(); // activity (optional)
73+
if (path != null && path.startsWith("/")) path = path.substring(1);
74+
75+
Map<String, List<String>> params = parseQueryParams(uri);
76+
String action = removeSingleParam(params, "action");
77+
String category = removeSingleParam(params, "category");
78+
List<String> intentArgs = getIntentArgs(params);
79+
80+
// Detect active automation engine
81+
String engine = getAutomationEngine((HasCapabilities) driver);
82+
83+
if ("Espresso".equalsIgnoreCase(engine)) {
84+
startActivityViaScript((JavascriptExecutor) driver, authority, path, action, category, intentArgs);
85+
} else if ("UiAutomator2".equalsIgnoreCase(engine)) {
86+
startActivityViaShell((JavascriptExecutor) driver, authority, path, action, category, intentArgs);
87+
} else {
88+
throw new UnsupportedOperationException("Unsupported automation engine: " + engine);
89+
}
90+
}
91+
92+
/**
93+
* Parse the query parameters of the specified URI.
94+
* <p>
95+
* <b>NOTE</b>: This method supports parsing of repeated parameters.
96+
*
97+
* @param uri URI from which to parse query parameters
98+
* @return map of lists of strings
99+
*/
100+
private static Map<String, List<String>> parseQueryParams(final URI uri) {
101+
Map<String, List<String>> rawParams = new LinkedHashMap<>();
102+
String query = uri.getRawQuery();
103+
if (query == null) return Collections.emptyMap();
104+
105+
for (String pair : query.split("&")) {
106+
int idx = pair.indexOf('=');
107+
String key = idx > 0 ? pair.substring(0, idx) : pair;
108+
String value = idx > 0 && pair.length() > idx + 1 ? pair.substring(idx + 1) : "";
109+
key = URLDecoder.decode(key, StandardCharsets.UTF_8);
110+
value = URLDecoder.decode(value, StandardCharsets.UTF_8);
111+
rawParams.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
112+
}
113+
114+
return rawParams;
115+
}
116+
117+
/**
118+
* Remove/return the specified parameter from the provided map.
119+
* <p>
120+
* <b>NOTE</b>: The specified parameter CANNOT define multiple values. <br>
121+
* <b>NOTE</b>: The parameter is removed from the provided map if found.
122+
*
123+
* @param params map of lists of parameter values
124+
* @param key name of parameter to be retrieved
125+
* @return value of specified parameter; {@code null} if undefined
126+
* @throws IllegalStateException if specified parameter defines multiple values
127+
*/
128+
private static String removeSingleParam(final Map<String, List<String>> params, final String key) {
129+
List<String> values = params.remove(key);
130+
if (values == null) return null;
131+
if (values.size() != 1)
132+
throw new IllegalStateException("Expected exactly one value for key: " + key);
133+
return values.get(0);
134+
}
135+
136+
/**
137+
* Get the automation engine associated with the specified driver.
138+
*
139+
* @param caps Android driver as <b>HasCapabilities</b> object
140+
* @return Appium automation engine name (Espresso/UiAutomator2)
141+
*/
142+
private static String getAutomationEngine(final HasCapabilities caps) {
143+
return Optional
144+
.ofNullable((String) caps.getCapabilities().getCapability("appium:automationName"))
145+
.orElse((String) caps.getCapabilities().getCapability("automationName"));
146+
}
147+
148+
/**
149+
* Start the specified activity via the {@code mobile: startActivity} script.
150+
*
151+
* @param driver Android driver as <b>JavascriptExecutor</b>
152+
* @param appPackage Android application package name
153+
* @param activity Android application activity name
154+
* @param action [optional] intent action (e.g. - {@code android.intent.action.MAIN})
155+
* @param category [optional] intent category (e.g. - {@code android.intent.category.LAUNCHER})
156+
* @param intentArgs [optional] intent arguments
157+
*/
158+
private static void startActivityViaScript(final JavascriptExecutor driver, final String appPackage,
159+
final String activity, final String action, final String category, final List<String> intentArgs) {
160+
Map<String, Object> args = new HashMap<>();
161+
162+
args.put("appPackage", appPackage);
163+
args.put("appActivity", activity);
164+
165+
if (action != null) {
166+
args.put("intentAction", action);
167+
}
168+
169+
if (category != null) {
170+
args.put("intentCategory", category);
171+
}
172+
173+
if (!intentArgs.isEmpty()) {
174+
args.put("optionalIntentArguments", intentArgs);
175+
}
176+
177+
driver.executeScript("mobile: startActivity", args);
178+
}
179+
180+
/**
181+
* Start the specified activity via the {@code mobile: shell} script.
182+
*
183+
* @param driver Android driver as <b>JavascriptExecutor</b>
184+
* @param appPackage Android application package name
185+
* @param activity Android application activity name
186+
* @param action [optional] intent action (e.g. - {@code android.intent.action.MAIN})
187+
* @param category [optional] intent category (e.g. - {@code android.intent.category.LAUNCHER})
188+
* @param intentArgs [optional] intent arguments
189+
*/
190+
private static void startActivityViaShell(final JavascriptExecutor driver, final String appPackage,
191+
final String activity, final String action, final String category, final List<String> intentArgs) {
192+
193+
// Build the am start command
194+
List<String> cmd = new ArrayList<>();
195+
cmd.add("start");
196+
cmd.add("-n");
197+
cmd.add(appPackage + "/" + activity);
198+
199+
if (action != null) {
200+
cmd.add("-a");
201+
cmd.add(action);
202+
}
203+
204+
if (category != null) {
205+
cmd.add("-c");
206+
cmd.add(category);
207+
}
208+
209+
for (String intentArg : intentArgs) {
210+
cmd.add(intentArg);
211+
}
212+
213+
// Execute via mobile: shell
214+
Map<String, Object> args = new HashMap<>();
215+
args.put("command", "am");
216+
args.put("args", cmd.toArray(new String[0]));
217+
218+
driver.executeScript("mobile: shell", args);
219+
}
220+
221+
/**
222+
* Get application activity intent arguments.
223+
*
224+
* @param params map of lists of parameter values
225+
* @return list of intent arguments as [type, key, value] triples
226+
*/
227+
private static List<String> getIntentArgs(final Map<String, List<String>> params) {
228+
Stream<Entry<String, List<String>>> paramStream = params.entrySet().stream();
229+
230+
return paramStream.flatMap(entry -> {
231+
String key = entry.getKey();
232+
String prefix = key.contains(":") ? key.substring(0, key.indexOf(':')) : "es";
233+
String cleanKey = key.contains(":") ? key.substring(key.indexOf(':') + 1) : key;
234+
235+
// Convert multiple values into a single flattened string
236+
return Stream.of("--" + prefix, cleanKey, String.join(",", entry.getValue()));
237+
}).collect(Collectors.toList());
238+
}
239+
}

0 commit comments

Comments
 (0)