diff --git a/playwright/src/main/java/com/microsoft/playwright/Android.java b/playwright/src/main/java/com/microsoft/playwright/Android.java new file mode 100644 index 000000000..a36857afa --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/Android.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright; + +import java.util.List; +import java.util.Map; + +/** + * Playwright has experimental support for Android automation. This includes + * Chrome for Android and Android + * WebView. + * + *

+ * Requirements + *

+ * + *

+ * Known limitations + *

+ *

How to run + * + *

An example of the Android automation script would be: + *

{@code
+ * import com.microsoft.playwright.*;
+ * import java.util.regex.Pattern;
+ * import java.util.List;
+ * 
+ * public class Example {
+ *   public static void main(String[] args) {
+ *     try (Playwright playwright = Playwright.create()) {
+ *       Android android = playwright.android();
+ *       List devices = android.devices();
+ *       AndroidDevice device = devices.get(0);
+ *       System.out.println("Model: " + device.model());
+ *       System.out.println("Serial: " + device.serial());
+ *       // Take screenshot of the whole device.
+ *       ScreenshotOptions screenshotOptions = new ScreenshotOptions();
+ *       screenshotOptions.setPath("device.png");
+ *       device.screenshot(screenshotOptions);
+ * 
+ *       // --------------------- WebView -----------------------
+ *
+ *       // Launch an application with WebView.
+ *       device.shell("am force-stop org.chromium.webview_shell");
+ *       device.shell("am start -n org.chromium.webview_shell/.WebViewBrowserActivity");
+ *       // Get the WebView.
+ *       WebViewSelector selector = new WebViewSelector();
+ *       selector.setPkg("org.chromium.webview_shell");
+ *       AndroidWebView webview = device.webView(selector);
+ *       // Fill the input box.
+ *       AndroidSelector inputSelector = new AndroidSelector();
+ *       inputSelector.setRes("org.chromium.webview_shell:id/url_field");
+ *       device.fill(inputSelector, 'github.com/microsoft/playwright');
+ *       device.press(inputSelector, AndroidKey.ENTER);
+ *
+ *       // Work with WebView's page as usual.
+ *       Page page = webview.page();
+ *       WaitForNavigationOptions waitForNavigationOptions = new WaitForNavigationOptions();
+ *       waitForNavigationOptions.setUrl(Pattern.compile(".*github.com/microsoft/playwright.*"));
+ *       page.waitForNavigation(waitForNavigationOptions);
+ *       System.out.println(page.title());
+ *
+ *       // --------------------- Browser -----------------------
+ *
+ *       // Launch Chrome browser.
+ *       device.shell("am force-stop com.android.chrome");
+ *       BrowserContext context = device.launchBrowser();
+ *
+ *       // Use BrowserContext as usual.
+ *       Page page = context.newPage();
+ *       page.navigate("https://webkit.org/");
+ *       System.out.println(page.evaluate("() => window.location.href)");
+ *
+ *       context.close();
+ *   
+ *       // Close the device.
+ *       device.close();
+ *     }
+ *   }
+ * }
+ * }
+ */ +public interface Android { + /** + * Options for {@link Android#connect(String, ConnectOptions)}. + */ + class ConnectOptions { + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass + * `0` to disable timeout. + */ + public Double timeout; + /** + * Additional HTTP headers to send with the WebSocket connection. + */ + public Map headers; + /** + * Slows down operations by the specified amount of milliseconds. + */ + public Double slowMo; + + /** + * Maximum time to wait for in milliseconds. Defaults to {@code 30000} (30 seconds). Pass {@code 0} to disable timeout. The + * default value can be changed by using the {@link com.microsoft.playwright.Android#setDefaultTimeout + * BrowserContext.setDefaultTimeout()}. + */ + public ConnectOptions setTimeout(double timeout) { + this.timeout = timeout; + return this; + } + /** + * Additional HTTP headers to be sent with web socket connect request. Optional. + */ + public ConnectOptions setHeaders(Map headers) { + this.headers = headers; + return this; + } + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. Defaults to `0`. + */ + public ConnectOptions setSlowMo(double slowMo) { + this.slowMo = slowMo; + return this; + } + } + /** + * Options for {@link Android#devices(DevicesOptions)}. + */ + class DevicesOptions { + /** + * Optional port to establish ADB server connection. Default to `5037`. + */ + public Integer port; + /** + * Optional host to establish ADB server connection. Default to `127.0.0.1`. + */ + public String host; + /** + * Prevents automatic playwright driver installation on attach. Assumes that the drivers have been installed already. + */ + public Boolean omitDriverInstall; + + /** + * Optional port to connect to ADB. + */ + public DevicesOptions setPort(int port) { + this.port = port; + return this; + } + /** + * t + * Optional host to connect to ADB. + */ + public DevicesOptions setHost(String host) { + this.host = host; + return this; + } + /** + * Prevents automatic playwright driver installation on attach. Assumes that the + * drivers have been installed already. + */ + public DevicesOptions setOmitDriverInstall(boolean omitDriverInstall) { + this.omitDriverInstall = omitDriverInstall; + return this; + } + } + + /** + * This methods attaches Playwright to an existing Android device. + * + * @param wsEndpoint A browser websocket endpoint to connect to. + */ + default AndroidDevice connect(String wsEndpoint) { + return connect(wsEndpoint, null); + } + /** + * Connects to an Android device via WebSocket endpoint with custom options. + * + * @param wsEndpoint A browser websocket endpoint to connect to. + * @param options Connection options + */ + AndroidDevice connect(String wsEndpoint, ConnectOptions options); + /** + * Returns a list of detected Android devices. + */ + default List devices() { + return devices(null); + } + /** + * Returns a list of detected Android devices. + * + * @param options Connection options with optional port + */ + List devices(DevicesOptions options); + /** + * This setting will change the default maximum time for all the methods accepting {@code timeout} option. + * + * @param timeout Maximum time in milliseconds. Pass {@code 0} to disable timeout. + */ + void setDefaultTimeout(double timeout); + +} diff --git a/playwright/src/main/java/com/microsoft/playwright/AndroidDevice.java b/playwright/src/main/java/com/microsoft/playwright/AndroidDevice.java new file mode 100644 index 000000000..8a7242421 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/AndroidDevice.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright; + +import java.util.function.Consumer; + +public interface AndroidDevice extends AutoCloseable { + /** + * Emitted when the device connection gets closed. + */ + void onClose(Consumer handler); + /** + * Removes handler that was previously added with {@link #onClose onClose(Consumer)}. + */ + void offClose(Consumer handler); + /** + * Closes the device connection. + */ + void close(); + /** + * Device model. + */ + String model(); + /** + * Device serial number. + */ + String serial(); +} diff --git a/playwright/src/main/java/com/microsoft/playwright/Playwright.java b/playwright/src/main/java/com/microsoft/playwright/Playwright.java index 2e69e5f5f..7a610599a 100644 --- a/playwright/src/main/java/com/microsoft/playwright/Playwright.java +++ b/playwright/src/main/java/com/microsoft/playwright/Playwright.java @@ -87,6 +87,11 @@ public CreateOptions setEnv(Map env) { * @since v1.8 */ BrowserType webkit(); + /** + * This object can be used to launch or connect to Android, returning instances of {@code Android}. + * + */ + Android android(); /** * Terminates this instance of Playwright, will also close all created browsers if they are still running. * diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/AndroidDeviceImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/AndroidDeviceImpl.java new file mode 100644 index 000000000..5f05b10ee --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/AndroidDeviceImpl.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright.impl; + +import com.microsoft.playwright.AndroidDevice; +import com.microsoft.playwright.PlaywrightException; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +import static com.microsoft.playwright.impl.Utils.isSafeCloseError; + +public class AndroidDeviceImpl extends ChannelOwner implements AndroidDevice { + final Set contexts = new HashSet<>(); + boolean isConnectedOverWebSocket; + final TimeoutSettings timeoutSettings; + private final ListenerCollection listeners = new ListenerCollection<>(); + + enum EventType { + CLOSE, + } + AndroidDeviceImpl(ChannelOwner parent, String type, String guid, JsonObject initializer) { + super(parent, type, guid, initializer); + this.timeoutSettings = new TimeoutSettings(((AndroidImpl) parent).timeoutSettings); + } + + @Override + public void onClose(Consumer handler) { + listeners.add(EventType.CLOSE, handler); + } + + @Override + public void offClose(Consumer handler) { + listeners.remove(EventType.CLOSE, handler); + } + + @Override + public void close() { + withLogging("AndroidDevice.close", () -> closeImpl()); + } + + private void closeImpl() { + if (isConnectedOverWebSocket) { + try { + connection.close(); + } catch (IOException e) { + throw new PlaywrightException("Failed to close device connection", e); + } + } else { + try { + sendMessage("close"); + } catch (PlaywrightException e) { + if (!isSafeCloseError(e)) { + throw e; + } + } + } + } + + void didClose() { + listeners.notify(EventType.CLOSE, this); + } + + @Override + public String model() { + return initializer.get("model").getAsString(); + } + + void notifyRemoteClosed() { + for (BrowserContextImpl context : new ArrayList<>(contexts)) { + for (PageImpl page : new ArrayList<>(context.pages)) { + page.didClose(); + } + context.didClose(); + } + didClose(); + } + + @Override + public String serial() { + return initializer.get("serial").getAsString(); + } + + @Override + void handleEvent(String event, JsonObject parameters) { + if ("close".equals(event)) { + didClose(); + } + } +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/AndroidImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/AndroidImpl.java new file mode 100644 index 000000000..df775e526 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/AndroidImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright.impl; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.microsoft.playwright.Android; +import com.microsoft.playwright.AndroidDevice; +import com.microsoft.playwright.PlaywrightException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static com.microsoft.playwright.impl.Serialization.gson; + +public class AndroidImpl extends ChannelOwner implements Android { + final TimeoutSettings timeoutSettings = new TimeoutSettings(); + + public AndroidImpl(ChannelOwner parent, String type, String guid, JsonObject initializer) { + super(parent, type, guid, initializer); + } + + @Override + public void setDefaultTimeout(double timeout) { + setDefaultTimeoutImpl(timeout); + } + + void setDefaultTimeoutImpl(Double timeout) { + withLogging("Android.setDefaultTimeout", () -> { + timeoutSettings.setDefaultTimeout(timeout); + JsonObject params = new JsonObject(); + params.addProperty("timeout", timeout); + sendMessage("setDefaultTimeoutNoReply", params); + }); + } + + @Override + public List devices(DevicesOptions options) { + return withLogging("Android.devices", () -> devicesImpl(options)); + } + + private List devicesImpl(DevicesOptions options) { + if (options == null) { + options = new DevicesOptions(); + } + JsonObject params = gson().toJsonTree(options).getAsJsonObject(); + JsonElement result = sendMessage("devices", params); + List devices = new ArrayList<>(); + for (JsonElement deviceElement : result.getAsJsonObject().get("devices").getAsJsonArray()) { + JsonObject deviceJson = deviceElement.getAsJsonObject(); + AndroidDevice device = connection.getExistingObject(deviceJson.get("guid").getAsString()); + devices.add(device); + } + return devices; + } + + @Override + public AndroidDevice connect(String wsEndpoint, ConnectOptions options) { + return withLogging("Android.connect", () -> connectImpl(wsEndpoint, options)); + } + + private AndroidDevice connectImpl(String wsEndpoint, ConnectOptions options) { + if (options == null) { + options = new ConnectOptions(); + } + + if (options.headers != null && !options.headers.isEmpty()) { + options.headers.put("x-playwright-browser", "android"); + } + + JsonObject params = gson().toJsonTree(options).getAsJsonObject(); + params.addProperty("wsEndpoint", wsEndpoint); + JsonObject json = connection.localUtils().sendMessage("connect", params).getAsJsonObject(); + JsonPipe pipe = connection.getExistingObject(json.getAsJsonObject("pipe").get("guid").getAsString()); + Connection androidConnection = new Connection(pipe, connection.env, connection.localUtils); + PlaywrightImpl playwright = androidConnection.initializePlaywright(); + + if (!playwright.initializer.has("preConnectedAndroidDevice")) { + try { + androidConnection.close(); + } catch (IOException e) { + e.printStackTrace(System.err); + } + throw new PlaywrightException("Malformed endpoint. Did you use the correct wsEndpoint?"); + } + + JsonObject preConnectedDevice = playwright.initializer.getAsJsonObject("preConnectedAndroidDevice"); + AndroidDeviceImpl androidDevice = androidConnection.getExistingObject(preConnectedDevice.get("guid").getAsString()); + androidDevice.isConnectedOverWebSocket = true; + Consumer connectionCloseListener = t -> androidDevice.notifyRemoteClosed(); + pipe.onClose(connectionCloseListener); + androidDevice.onClose(b -> { + pipe.offClose(connectionCloseListener); + try { + androidConnection.close(); + } catch (IOException e) { + e.printStackTrace(System.err); + } + }); + return androidDevice; + } +} + diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java b/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java index f0d3ca5f0..937fb82af 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/Connection.java @@ -304,13 +304,13 @@ private ChannelOwner createRemoteObject(String parentGuid, JsonObject params) { ChannelOwner result = null; switch (type) { case "Android": -// result = new Android(parent, type, guid, initializer); + result = new AndroidImpl(parent, type, guid, initializer); break; case "AndroidSocket": // result = new AndroidSocket(parent, type, guid, initializer); break; case "AndroidDevice": -// result = new AndroidDevice(parent, type, guid, initializer); + result = new AndroidDeviceImpl(parent, type, guid, initializer); break; case "Artifact": result = new ArtifactImpl(parent, type, guid, initializer); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/PlaywrightImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/PlaywrightImpl.java index 38863c8e8..0abf1b6ae 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/PlaywrightImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/PlaywrightImpl.java @@ -19,6 +19,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.microsoft.playwright.APIRequest; +import com.microsoft.playwright.Android; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.PlaywrightException; import com.microsoft.playwright.Selectors; @@ -64,6 +65,7 @@ public static PlaywrightImpl createImpl(CreateOptions options, boolean forceNewD private final BrowserTypeImpl webkit; private final SelectorsImpl selectors; private final APIRequestImpl apiRequest; + private final AndroidImpl android; private SharedSelectors sharedSelectors; PlaywrightImpl(ChannelOwner parent, String type, String guid, JsonObject initializer) { @@ -71,7 +73,7 @@ public static PlaywrightImpl createImpl(CreateOptions options, boolean forceNewD chromium = parent.connection.getExistingObject(initializer.getAsJsonObject("chromium").get("guid").getAsString()); firefox = parent.connection.getExistingObject(initializer.getAsJsonObject("firefox").get("guid").getAsString()); webkit = parent.connection.getExistingObject(initializer.getAsJsonObject("webkit").get("guid").getAsString()); - + android = parent.connection.getExistingObject(initializer.getAsJsonObject("android").get("guid").getAsString()); selectors = connection.getExistingObject(initializer.getAsJsonObject("selectors").get("guid").getAsString()); apiRequest = new APIRequestImpl(this); } @@ -123,6 +125,11 @@ public Selectors selectors() { return sharedSelectors; } + @Override + public Android android() { + return android; + } + @Override public void close() { try { diff --git a/playwright/src/test/java/com/microsoft/playwright/TestAndroid.java b/playwright/src/test/java/com/microsoft/playwright/TestAndroid.java new file mode 100644 index 000000000..2bf5b069a --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/TestAndroid.java @@ -0,0 +1,20 @@ +package com.microsoft.playwright; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestAndroid extends TestAndroidBase { + @Test + void testAndroidDeviceClose() { + List events = new ArrayList<>(); + androidDevice.onClose(d -> { + events.add("close"); + }); + androidDevice.close(); + assertEquals(Arrays.asList("close"), events); + } +} diff --git a/playwright/src/test/java/com/microsoft/playwright/TestAndroidBase.java b/playwright/src/test/java/com/microsoft/playwright/TestAndroidBase.java new file mode 100644 index 000000000..e2cedf65b --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/TestAndroidBase.java @@ -0,0 +1,59 @@ +package com.microsoft.playwright; + +import org.junit.jupiter.api.*; +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static com.microsoft.playwright.Utils.nextFreePort; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class TestAndroidBase { + Playwright playwright; + Android android; + AndroidDevice androidDevice; + Server server; + Server httpsServer; + + @BeforeAll + void setupAndroid() { + playwright = Playwright.create(); + List devices = playwright.android().devices(); + assertFalse(devices.isEmpty()); + androidDevice = devices.get(0); + } + + @AfterAll + void teardownAndroid() { + if (androidDevice != null) { + androidDevice.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + @BeforeAll + void startServer() throws IOException { + server = Server.createHttp(nextFreePort()); + httpsServer = Server.createHttps(nextFreePort()); + } + + @AfterAll + void stopServer() { + if (server != null) { + server.stop(); + server = null; + } + if (httpsServer != null) { + httpsServer.stop(); + httpsServer = null; + } + } + + @AfterEach + void cleanupPage() { + server.reset(); + httpsServer.reset(); + } +}