diff --git a/build.gradle b/build.gradle index b26c8eec8..d921bb0c0 100644 --- a/build.gradle +++ b/build.gradle @@ -76,7 +76,10 @@ dependencies { compile 'org.springframework:spring-context:5.0.3.RELEASE' compile 'org.aspectj:aspectjweaver:1.8.13' compile 'org.openpnp:opencv:3.2.0-1' - + compile 'javax.websocket:javax.websocket-api:1.1' + compile 'org.glassfish.tyrus:tyrus-client:1.1' + compile 'org.glassfish.tyrus:tyrus-container-grizzly:1.1' + testCompile 'junit:junit:4.12' testCompile 'org.hamcrest:hamcrest-all:1.3' } diff --git a/src/main/java/io/appium/java_client/android/AndroidDriver.java b/src/main/java/io/appium/java_client/android/AndroidDriver.java index 2232c63c3..6ff2f7d54 100644 --- a/src/main/java/io/appium/java_client/android/AndroidDriver.java +++ b/src/main/java/io/appium/java_client/android/AndroidDriver.java @@ -54,7 +54,7 @@ public class AndroidDriver FindsByAndroidUIAutomator, LocksDevice, HasAndroidSettings, HasDeviceDetails, HasSupportedPerformanceDataType, AuthenticatesByFinger, CanRecordScreen, SupportsSpecialEmulatorCommands, - SupportsNetworkStateManagement, HasAndroidClipboard { + SupportsNetworkStateManagement, ListensToLogcatMessages, HasAndroidClipboard { private static final String ANDROID_PLATFORM = MobilePlatform.ANDROID; diff --git a/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java new file mode 100644 index 000000000..4b2629815 --- /dev/null +++ b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java @@ -0,0 +1,138 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.android; + +import static io.appium.java_client.service.local.AppiumServiceBuilder.DEFAULT_APPIUM_PORT; +import static org.openqa.selenium.remote.DriverCommand.EXECUTE_SCRIPT; + +import com.google.common.collect.ImmutableMap; + +import io.appium.java_client.ExecutesMethod; +import io.appium.java_client.ws.StringWebSocketClient; +import org.openqa.selenium.remote.RemoteWebDriver; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.function.Consumer; + +public interface ListensToLogcatMessages extends ExecutesMethod { + StringWebSocketClient logcatClient = new StringWebSocketClient(); + + /** + * Start logcat messages broadcast via web socket. + * This method assumes that Appium server is running on localhost and + * is assigned to the default port (4723). + */ + default void startLogcatBroadcast() { + startLogcatBroadcast("localhost", DEFAULT_APPIUM_PORT); + } + + /** + * Start logcat messages broadcast via web socket. + * This method assumes that Appium server is assigned to the default port (4723). + * + * @param host the name of the host where Appium server is running + */ + default void startLogcatBroadcast(String host) { + startLogcatBroadcast(host, DEFAULT_APPIUM_PORT); + } + + /** + * Start logcat messages broadcast via web socket. + * + * @param host the name of the host where Appium server is running + * @param port the port of the host where Appium server is running + */ + default void startLogcatBroadcast(String host, int port) { + execute(EXECUTE_SCRIPT, ImmutableMap.of("script", "mobile: startLogsBroadcast", + "args", Collections.emptyList())); + final URI endpointUri; + try { + endpointUri = new URI(String.format("ws://%s:%s/ws/session/%s/appium/device/logcat", + host, port, ((RemoteWebDriver) this).getSessionId())); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + logcatClient.connect(endpointUri); + } + + /** + * Adds a new log messages broadcasting handler. + * Several handlers might be assigned to a single server. + * Multiple calls to this method will cause such handler + * to be called multiple times. + * + * @param handler a function, which accepts a single argument, which is the actual log message + */ + default void addLogcatMessagesListener(Consumer handler) { + logcatClient.addMessageHandler(handler); + } + + /** + * Adds a new log broadcasting errors handler. + * Several handlers might be assigned to a single server. + * Multiple calls to this method will cause such handler + * to be called multiple times. + * + * @param handler a function, which accepts a single argument, which is the actual exception instance + */ + default void addLogcatErrorsListener(Consumer handler) { + logcatClient.addErrorHandler(handler); + } + + /** + * Adds a new log broadcasting connection handler. + * Several handlers might be assigned to a single server. + * Multiple calls to this method will cause such handler + * to be called multiple times. + * + * @param handler a function, which is executed as soon as the client is successfully + * connected to the web socket + */ + default void addLogcatConnectionListener(Runnable handler) { + logcatClient.addConnectionHandler(handler); + } + + /** + * Adds a new log broadcasting disconnection handler. + * Several handlers might be assigned to a single server. + * Multiple calls to this method will cause such handler + * to be called multiple times. + * + * @param handler a function, which is executed as soon as the client is successfully + * disconnected from the web socket + */ + default void addLogcatDisconnectionListener(Runnable handler) { + logcatClient.addDisconnectionHandler(handler); + } + + /** + * Removes all existing logcat handlers. + */ + default void removeAllLogcatListeners() { + logcatClient.removeAllHandlers(); + } + + /** + * Stops logcat messages broadcast via web socket. + */ + default void stopLogcatBroadcast() { + execute(EXECUTE_SCRIPT, ImmutableMap.of("script", "mobile: stopLogsBroadcast", + "args", Collections.emptyList())); + } +} diff --git a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java index 08522521c..c8b44414e 100644 --- a/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java +++ b/src/main/java/io/appium/java_client/service/local/AppiumServiceBuilder.java @@ -75,7 +75,7 @@ public final class AppiumServiceBuilder File.separator + BUILD_FOLDER + File.separator + LIB_FOLDER + File.separator + MAIN_JS; - private static final int DEFAULT_APPIUM_PORT = 4723; + public static final int DEFAULT_APPIUM_PORT = 4723; private static final String BASH = "bash"; private static final String CMD_EXE = "cmd.exe"; private static final String NODE = "node"; diff --git a/src/main/java/io/appium/java_client/ws/CanHandleConnects.java b/src/main/java/io/appium/java_client/ws/CanHandleConnects.java new file mode 100644 index 000000000..47cf91d09 --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/CanHandleConnects.java @@ -0,0 +1,43 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import java.util.List; + +public interface CanHandleConnects { + + /** + * @return The list of web socket connection handlers. + */ + List getConnectionHandlers(); + + /** + * Register a new message handler. + * + * @param handler a callback function, which is going to be executed when web socket connection event arrives + */ + default void addConnectionHandler(Runnable handler) { + getConnectionHandlers().add(handler); + } + + /** + * Removes existing web socket connection handlers. + */ + default void removeConnectionHandlers() { + getConnectionHandlers().clear(); + } +} diff --git a/src/main/java/io/appium/java_client/ws/CanHandleDisconnects.java b/src/main/java/io/appium/java_client/ws/CanHandleDisconnects.java new file mode 100644 index 000000000..52c429726 --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/CanHandleDisconnects.java @@ -0,0 +1,43 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import java.util.List; + +public interface CanHandleDisconnects { + + /** + * @return The list of web socket disconnection handlers. + */ + List getDisconnectionHandlers(); + + /** + * Register a new web socket disconnect handler. + * + * @param handler a callback function, which is going to be executed when web socket disconnect event arrives + */ + default void addDisconnectionHandler(Runnable handler) { + getDisconnectionHandlers().add(handler); + } + + /** + * Removes existing disconnection handlers. + */ + default void removeDisconnectionHandlers() { + getDisconnectionHandlers().clear(); + } +} diff --git a/src/main/java/io/appium/java_client/ws/CanHandleErrors.java b/src/main/java/io/appium/java_client/ws/CanHandleErrors.java new file mode 100644 index 000000000..86f2b4e9e --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/CanHandleErrors.java @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import java.util.List; +import java.util.function.Consumer; + +public interface CanHandleErrors { + + /** + * @return The list of web socket error handlers. + */ + List> getErrorHandlers(); + + /** + * Register a new error handler. + * + * @param handler a callback function, which accepts the received exception instance as a parameter + */ + default void addErrorHandler(Consumer handler) { + getErrorHandlers().add(handler); + } + + /** + * Removes existing error handlers. + */ + default void removeErrorHandlers() { + getErrorHandlers().clear(); + } +} diff --git a/src/main/java/io/appium/java_client/ws/CanHandleMessages.java b/src/main/java/io/appium/java_client/ws/CanHandleMessages.java new file mode 100644 index 000000000..f1688f3d3 --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/CanHandleMessages.java @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import java.util.List; +import java.util.function.Consumer; + +public interface CanHandleMessages { + /** + * @return The list of web socket message handlers. + */ + List> getMessageHandlers(); + + /** + * Register a new message handler. + * + * @param handler a callback function, which accepts the received message as a parameter + */ + default void addMessageHandler(Consumer handler) { + getMessageHandlers().add(handler); + } + + /** + * Removes existing message handlers. + */ + default void removeMessageHandlers() { + getMessageHandlers().clear(); + } +} + diff --git a/src/main/java/io/appium/java_client/ws/StringWebSocketClient.java b/src/main/java/io/appium/java_client/ws/StringWebSocketClient.java new file mode 100644 index 000000000..ebb99d7da --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/StringWebSocketClient.java @@ -0,0 +1,148 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import org.openqa.selenium.WebDriverException; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import javax.websocket.ClientEndpoint; +import javax.websocket.OnClose; +import javax.websocket.OnError; +import javax.websocket.OnMessage; +import javax.websocket.OnOpen; +import javax.websocket.Session; + +@ClientEndpoint +public class StringWebSocketClient extends WebSocketClient implements + CanHandleMessages, CanHandleErrors, CanHandleConnects, CanHandleDisconnects { + private final List> messageHandlers = new CopyOnWriteArrayList<>(); + private final List> errorHandlers = new CopyOnWriteArrayList<>(); + private final List connectHandlers = new CopyOnWriteArrayList<>(); + private final List disconnectHandlers = new CopyOnWriteArrayList<>(); + + private volatile Session session; + + @Override + public void connect(URI endpoint) { + if (session != null) { + if (endpoint.equals(this.getEndpoint())) { + return; + } + + removeAllHandlers(); + try { + session.close(); + } catch (IOException e) { + // ignore + } + session = null; + } + super.connect(endpoint); + } + + /** + * This event if fired when the client is successfully + * connected to a web socket. + * + * @param session the actual web socket session instance + */ + @OnOpen + public void onOpen(Session session) { + this.session = session; + getConnectionHandlers().forEach(Runnable::run); + } + + /** + * This event if fired when the client is + * disconnected from a web socket. + */ + @OnClose + public void onClose() { + this.session = null; + getDisconnectionHandlers().forEach(Runnable::run); + } + + /** + * This event if fired when there is an unexpected + * error in web socket connection. + * + * @param cause the actual error reason + */ + @OnError + public void onError(Throwable cause) { + this.session = null; + getErrorHandlers().forEach(x -> x.accept(cause)); + throw new WebDriverException(cause); + } + + /** + * This event if fired when there is a + * new message from the web socket. + * + * @param message the actual message content. + */ + @OnMessage + public void onMessage(String message) { + getMessageHandlers().forEach(x -> x.accept(message)); + } + + /** + * @return The list of all registered web socket messages handlers. + */ + @Override + public List> getMessageHandlers() { + return messageHandlers; + } + + /** + * @return The list of all registered web socket error handlers. + */ + @Override + public List> getErrorHandlers() { + return errorHandlers; + } + + /** + * @return The list of all registered web socket connection handlers. + */ + @Override + public List getConnectionHandlers() { + return connectHandlers; + } + + /** + * @return The list of all registered web socket disconnection handlers. + */ + @Override + public List getDisconnectionHandlers() { + return disconnectHandlers; + } + + /** + * Remove all the registered handlers. + */ + public void removeAllHandlers() { + removeMessageHandlers(); + removeErrorHandlers(); + removeConnectionHandlers(); + removeDisconnectionHandlers(); + } +} diff --git a/src/main/java/io/appium/java_client/ws/WebSocketClient.java b/src/main/java/io/appium/java_client/ws/WebSocketClient.java new file mode 100644 index 000000000..0101e5d2f --- /dev/null +++ b/src/main/java/io/appium/java_client/ws/WebSocketClient.java @@ -0,0 +1,53 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * 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 io.appium.java_client.ws; + +import org.openqa.selenium.WebDriverException; + +import java.io.IOException; +import java.net.URI; +import javax.websocket.ContainerProvider; +import javax.websocket.DeploymentException; + +public abstract class WebSocketClient { + private URI endpoint; + + private void setEndpoint(URI endpoint) { + this.endpoint = endpoint; + } + + public URI getEndpoint() { + return this.endpoint; + } + + /** + * Connects web socket client. + * + * @param endpoint The full address of an endpoint to connect to. + * Usually starts with 'ws://'. + */ + public void connect(URI endpoint) { + try { + ContainerProvider + .getWebSocketContainer() + .connectToServer(this, endpoint); + setEndpoint(endpoint); + } catch (IOException | DeploymentException e) { + throw new WebDriverException(e); + } + } +} diff --git a/src/test/java/io/appium/java_client/android/AndroidLogcatListenerTest.java b/src/test/java/io/appium/java_client/android/AndroidLogcatListenerTest.java new file mode 100644 index 000000000..5cfdee5c2 --- /dev/null +++ b/src/test/java/io/appium/java_client/android/AndroidLogcatListenerTest.java @@ -0,0 +1,38 @@ +package io.appium.java_client.android; + +import static org.junit.Assert.assertTrue; + +import org.apache.commons.lang3.time.DurationFormatUtils; +import org.junit.Test; + +import java.time.Duration; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public class AndroidLogcatListenerTest extends BaseAndroidTest { + + @Test + public void verifyLogcatListenerCanBeAssigned() { + final Semaphore messageSemaphore = new Semaphore(1); + final Duration timeout = Duration.ofSeconds(15); + + driver.addLogcatMessagesListener((msg) -> messageSemaphore.release()); + driver.addLogcatConnectionListener(() -> System.out.println("Connected to the web socket")); + driver.addLogcatDisconnectionListener(() -> System.out.println("Disconnected from the web socket")); + driver.addLogcatErrorsListener(Throwable::printStackTrace); + try { + driver.startLogcatBroadcast(); + messageSemaphore.acquire(); + // This is needed for pushing some internal log messages + driver.runAppInBackground(Duration.ofSeconds(1)); + assertTrue(String.format("Didn't receive any log message after %s timeout", + DurationFormatUtils.formatDuration(timeout.toMillis(), "H:mm:ss", true)), + messageSemaphore.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } finally { + messageSemaphore.release(); + driver.stopLogcatBroadcast(); + } + } +}