Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
525defb
feat: Implement logcat message broadcasting via WebSocket in AndroidD…
Dor-bl Nov 15, 2025
f174355
test: Add integration tests for logcat message broadcasting in Androi…
Dor-bl Nov 15, 2025
1ba8dc6
test: Enhance logcat broadcast error handling in LogcatBroadcastTests
Dor-bl Nov 15, 2025
29f6149
test: Add LogcatBroadcastTests for logcat message broadcasting functi…
Dor-bl Nov 15, 2025
18dbd45
chore: Remove unused using directives in IListensToLogcatMessages int…
Dor-bl Nov 15, 2025
9a47096
refactor: Use 'using' statement for errorSemaphore in CanHandleErrors…
Dor-bl Nov 15, 2025
aa1dc9e
test: Improve error handling in StopLogcatBroadcast method
Dor-bl Nov 15, 2025
9b4e34e
feat: Implement IDisposable interface in StringWebSocketClient for re…
Dor-bl Nov 15, 2025
8fb8e98
feat: Enhance IDisposable implementation in StringWebSocketClient for…
Dor-bl Nov 15, 2025
297e8d7
refactor: Replace static LogcatClient with instance variable for impr…
Dor-bl Nov 15, 2025
2f92495
fix: Convert handler collections to arrays before invocation to preve…
Dor-bl Nov 15, 2025
f5ea24e
fix: Handle WebSocketException and TaskCanceledException in connectio…
Dor-bl Nov 15, 2025
96e42b2
feat: Update StartLogcatBroadcast methods to async for improved perfo…
Dor-bl Nov 16, 2025
6b6f00b
feat: Update CanAddAndRemoveMultipleListeners test to use async for i…
Dor-bl Nov 16, 2025
aba9987
fix: Remove unnecessary blank lines in CanAddAndRemoveMultipleListene…
Dor-bl Nov 16, 2025
1313907
feat: Update VerifyLogcatListenerCanBeAssigned test to async for impr…
Dor-bl Nov 16, 2025
b373cbb
refactor: Simplify CanAddAndRemoveMultipleListeners test by using 'us…
Dor-bl Nov 16, 2025
edcd995
feat: Update LogcatBroadcast tests to use async for improved performa…
Dor-bl Nov 16, 2025
147506a
feat: Ensure receive task completion before closing WebSocket connection
Dor-bl Nov 16, 2025
bcfe603
feat: Enhance WebSocket connection handling with semaphore for thread…
Dor-bl Nov 16, 2025
9817502
fix: Remove unnecessary whitespace in CanStartLogcatBroadcastWithCust…
Dor-bl Nov 16, 2025
09701b3
refactor: Replace direct handler additions with dedicated methods in …
Dor-bl Nov 16, 2025
4b5fe71
fix: Remove initialization of ClientWebSocket in StringWebSocketClien…
Dor-bl Nov 16, 2025
4c98fbb
feat: Update StopLogcatBroadcast method to be asynchronous
Dor-bl Nov 16, 2025
1a03163
fix: Use ToArray() on ErrorHandlers to avoid modification during iter…
Dor-bl Nov 16, 2025
8b4f000
feat: Make StopLogcatBroadcast method asynchronous in tests
Dor-bl Nov 16, 2025
a1458c9
chore: Add remarks to StartLogcatBroadcast methods regarding temporar…
Dor-bl Nov 20, 2025
d0269c4
fix: Update StartLogcatBroadcast method to use loopback address
Dor-bl Nov 25, 2025
38a9314
fix: Enhance WebDriverException message with timestamp for WebSocket …
Dor-bl Nov 25, 2025
2694cbf
fix: Update host variable in CanStartLogcatBroadcastWithCustomHost te…
Dor-bl Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion src/Appium.Net/Appium/Android/AndroidDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,25 @@
using OpenQA.Selenium.Appium.Enums;
using OpenQA.Selenium.Appium.Interfaces;
using OpenQA.Selenium.Appium.Service;
using OpenQA.Selenium.Appium.WebSocket;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using OpenQA.Selenium.Appium.Android.Enums;
using System.Threading.Tasks;

namespace OpenQA.Selenium.Appium.Android
{
public class AndroidDriver : AppiumDriver,
IStartsActivity,
INetworkActions, IHasClipboard, IHasPerformanceData,
ISendsKeyEvents,
IPushesFiles, IHasSettings
IPushesFiles, IHasSettings, IListensToLogcatMessages
{
private static readonly string Platform = MobilePlatform.Android;
private readonly StringWebSocketClient _logcatClient = new();
private const int DefaultAppiumPort = 4723;

/// <summary>
/// Initializes a new instance of the AndroidDriver class
Expand Down Expand Up @@ -427,5 +431,104 @@ public AppState GetAppState(string appId) =>
}
)?.ToString() ?? throw new InvalidOperationException("ExecuteScript returned null for mobile:queryAppState")
);

#region Logcat Broadcast

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// This implementation uses a custom WebSocket endpoint and is temporary.
/// In the future, this functionality will be replaced with WebDriver BiDi log events
/// when BiDi support for Android device logs becomes available.
/// </remarks>
public async Task StartLogcatBroadcast() => await StartLogcatBroadcast("127.0.0.1", DefaultAppiumPort);

/// <summary>
/// Start logcat messages broadcast via web socket.
/// This method assumes that Appium server is assigned to the default port (4723).
/// </summary>
/// <param name="host">The name of the host where Appium server is running.</param>
/// <remarks>
/// This implementation uses a custom WebSocket endpoint and is temporary.
/// In the future, this functionality will be replaced with WebDriver BiDi log events
/// when BiDi support for Android device logs becomes available.
/// </remarks>
public async Task StartLogcatBroadcast(string host) => await StartLogcatBroadcast(host, DefaultAppiumPort);

/// <summary>
/// Start logcat messages broadcast via web socket.
/// </summary>
/// <param name="host">The name of the host where Appium server is running.</param>
/// <param name="port">The port of the host where Appium server is running.</param>
/// <remarks>
/// This implementation uses a custom WebSocket endpoint and is temporary.
/// In the future, this functionality will be replaced with WebDriver BiDi log events
/// when BiDi support for Android device logs becomes available.
/// </remarks>
public async Task StartLogcatBroadcast(string host, int port)
{
ExecuteScript("mobile: startLogsBroadcast", new Dictionary<string, object>());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KazuCocoa, I attempted several different methods using BiDI, but unfortunately, none of them worked for me.
I'm also experiencing a problem with the BiDi test that you added recently (this might be connected to my issue).
When I attempt to establish a connection, I encounter this error:

❌ FAILED: RunBiDiScript (1358ms)
   System.Net.WebSockets.WebSocketException : Unable to connect to the remote server
                                                                                     ----> System.Net.Http.HttpRequestException : IPv4 address 0.0.0.0 and IPv6 address ::0 are unspecified addresses that cannot be used as a target address. (Parameter 'hostName') (0.0.0.0:4723)
                          ----> System.ArgumentException : IPv4 address 0.0.0.0 and IPv6 address ::0 are unspecified addresses that cannot be used as a target address. (Parameter 'hostName')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you share the appium server-side log as well?

I'm also experiencing a problem with the BiDi test that you added recently (this might be connected to my issue).

When i ran dotnet test test/integration/Appium.Net.Integration.Tests.csproj -f net8.0 --filter "FullyQualifiedName~Appium.Net.Integration.Tests.Android.BiDiTests" on my local:

[f6166589][AndroidUiautomator2Driver@63b2] Responding to client with driver.createSession() result: {"capabilities":{"platformName":"Android","webSocketUrl":"ws://192.168.5.20:4723/bidi/f6166589-401f-45e0-854a-073385995062","automationName":"UIAutomator2","deviceName":"emulator-5554","platform":"LINUX","webStorageEnabled":false,"takesScreenshot":true,"javascriptEnabled":true,"databaseEnabled":false,"networkConnectionEnabled":true,"locationContextEnabled":false,"warnings":{},"desired":{"platformName":"Android","webSocketUrl":true,"automationName":"UIAutomator2","deviceName":"Android Emulator"},"deviceUDID":"emulator-5554","pixelRatio":"2.625","statBarHeight":63,"viewportRect":{"left":0,"top":63,"width":1080,"height":2337},"deviceApiLevel":32,"platformVersion":"12","deviceManufacturer":"Google","deviceModel":"sdk_gphone64_arm64","deviceScreenSize":"1080x2400","deviceScreenDensity":420}}
[f6166589][HTTP] <-- POST /session 200 20100 ms - 858
[AppiumDriver@12ee] Bidi websocket connection made for session f6166589-401f-45e0-854a-073385995062
[AndroidUiautomator2Driver@63b2] --> BIDI message #1
[AndroidUiautomator2Driver@63b2] Executing bidi command 'session.status' with params {} by passing to driver method 'bidiStatus'
[AndroidUiautomator2Driver@63b2] Responding to bidi command 'session.status' with {"ready":true,"message":"AndroidUiautomator2Driver is ready to accept commands"}
[AndroidUiautomator2Driver@63b2] <-- BIDI message #1
[HTTP] <-- GET /bidi/f6166589-401f-45e0-854a-073385995062 - - ms - -
[AndroidUiautomator2Driver@63b2] BiDi socket connection closed (code 1006, reason: '')
[f6166589][HTTP] --> DELETE /session/f6166589-401f-45e0-854a-073385995062
[f6166589][AndroidUiautomator2Driver@63b2] Calling AppiumDriver.deleteSession() with args: ["f6166589-401f-45e0-854a-073385995062"]
[f6166589][AppiumDriver@12ee] Event 'quitSessionRequested' logged at 1763333223903 (14:47:03 GMT-0800 (Pacific Standard Time))
[f6166589][AppiumDriver@12ee] Removing session f6166589-401f-45e0-854a-073385995062 from our master session list
[f6166589][AppiumDriver@12ee] Closing bidi socket(s) associated with session f6166589-401f-45e0-854a-073385995062

appium was 3.1.1, uia2 driver was 6.1.0

Copy link
Collaborator Author

@Dor-bl Dor-bl Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KazuCocoa, does BiDi require a specific minimum version of Appium/UIAutomator2 to function?

EDIT: Looks like I'm a bit behind with the versions:

https://gist.github.com/Dor-bl/95d17ca78974d6883da682fba6e49d10

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimal could be appium 2 and uia2 3.7.10. Old ones might have issues though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look at the log.entryAdded hanlding in each driver implementation tonight

cc @mykola-mokhnach

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opened an issue on the Appium server:
appium/appium#21741

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in appium/appium-android-driver#1031, the context property in the source object is optional and should not be relied on. While I agree with @KazuCocoa in that there's no harm in adding it on the driver side, if Selenium is expecting this property, then this issue should be primarily fixed on the Selenium side.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nvborisenko, can we apply a fix on the Selenium side, following the above comment?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I can accept it. But why:

In this case NATIVE_APP is magic string. Is it even required when we want to subscribe?

We subscribe like:

await bidi.Log.OnEntryAddedAsync(Console.WriteLine, new() { Contexts = ["NATIVE_APP"] });

According spec contexts property is not required: https://w3c.github.io/webdriver-bidi/#cddl-type-sessionsubscribeparameters. But appium server-side implementation requires it.

var endpointUri = new Uri($"ws://{host}:{port}/ws/session/{SessionId}/appium/device/logcat");
await _logcatClient.ConnectAsync(endpointUri);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which accepts a single argument, which is the actual log message.</param>
public void AddLogcatMessagesListener(Action<string> handler) =>
_logcatClient.AddMessageHandler(handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which accepts a single argument, which is the actual exception instance.</param>
public void AddLogcatErrorsListener(Action<Exception> handler) =>
_logcatClient.AddErrorHandler(handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which is executed as soon as the client is successfully connected to the web socket.</param>
public void AddLogcatConnectionListener(Action handler) =>
_logcatClient.AddConnectionHandler(handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which is executed as soon as the client is successfully disconnected from the web socket.</param>
public void AddLogcatDisconnectionListener(Action handler) =>
_logcatClient.AddDisconnectionHandler(handler);

/// <summary>
/// Removes all existing logcat handlers.
/// </summary>
public void RemoveAllLogcatListeners() => _logcatClient.RemoveAllHandlers();

/// <summary>
/// Stops logcat messages broadcast via web socket.
/// </summary>
public async Task StopLogcatBroadcast()
{
ExecuteScript("mobile: stopLogsBroadcast", new Dictionary<string, object>());
await _logcatClient.DisconnectAsync();
}

#endregion
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//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.

using System;
using System.Threading.Tasks;

namespace OpenQA.Selenium.Appium.Android.Interfaces
{
/// <summary>
/// Interface for handling Android logcat message broadcasts via WebSocket.
/// </summary>
public interface IListensToLogcatMessages
{
/// <summary>
/// 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).
/// </summary>
Task StartLogcatBroadcast();

/// <summary>
/// Start logcat messages broadcast via web socket.
/// This method assumes that Appium server is assigned to the default port (4723).
/// </summary>
/// <param name="host">The name of the host where Appium server is running.</param>
Task StartLogcatBroadcast(string host);

/// <summary>
/// Start logcat messages broadcast via web socket.
/// </summary>
/// <param name="host">The name of the host where Appium server is running.</param>
/// <param name="port">The port of the host where Appium server is running.</param>
Task StartLogcatBroadcast(string host, int port);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which accepts a single argument, which is the actual log message.</param>
void AddLogcatMessagesListener(Action<string> handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which accepts a single argument, which is the actual exception instance.</param>
void AddLogcatErrorsListener(Action<Exception> handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which is executed as soon as the client is successfully connected to the web socket.</param>
void AddLogcatConnectionListener(Action handler);

/// <summary>
/// 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.
/// </summary>
/// <param name="handler">A function, which is executed as soon as the client is successfully disconnected from the web socket.</param>
void AddLogcatDisconnectionListener(Action handler);

/// <summary>
/// Removes all existing logcat handlers.
/// </summary>
void RemoveAllLogcatListeners();

/// <summary>
/// Stops logcat messages broadcast via web socket.
/// </summary>
Task StopLogcatBroadcast();
}
}
41 changes: 41 additions & 0 deletions src/Appium.Net/Appium/WebSocket/ICanHandleConnects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//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.

using System;
using System.Collections.Generic;

namespace OpenQA.Selenium.Appium.WebSocket
{
/// <summary>
/// Interface for handling WebSocket connections.
/// </summary>
public interface ICanHandleConnects
{
/// <summary>
/// Gets the list of web socket connection handlers.
/// </summary>
List<Action> ConnectionHandlers { get; }

/// <summary>
/// Register a new connection handler.
/// </summary>
/// <param name="handler">A callback function, which is going to be executed when web socket connection event arrives.</param>
void AddConnectionHandler(Action handler);

/// <summary>
/// Removes existing web socket connection handlers.
/// </summary>
void RemoveConnectionHandlers();
}
}
41 changes: 41 additions & 0 deletions src/Appium.Net/Appium/WebSocket/ICanHandleDisconnects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//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.

using System;
using System.Collections.Generic;

namespace OpenQA.Selenium.Appium.WebSocket
{
/// <summary>
/// Interface for handling WebSocket disconnections.
/// </summary>
public interface ICanHandleDisconnects
{
/// <summary>
/// Gets the list of web socket disconnection handlers.
/// </summary>
List<Action> DisconnectionHandlers { get; }

/// <summary>
/// Register a new web socket disconnect handler.
/// </summary>
/// <param name="handler">A callback function, which is going to be executed when web socket disconnect event arrives.</param>
void AddDisconnectionHandler(Action handler);

/// <summary>
/// Removes existing disconnection handlers.
/// </summary>
void RemoveDisconnectionHandlers();
}
}
41 changes: 41 additions & 0 deletions src/Appium.Net/Appium/WebSocket/ICanHandleErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//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.

using System;
using System.Collections.Generic;

namespace OpenQA.Selenium.Appium.WebSocket
{
/// <summary>
/// Interface for handling WebSocket errors.
/// </summary>
public interface ICanHandleErrors
{
/// <summary>
/// Gets the list of web socket error handlers.
/// </summary>
List<Action<Exception>> ErrorHandlers { get; }

/// <summary>
/// Register a new error handler.
/// </summary>
/// <param name="handler">A callback function, which accepts the received exception instance as a parameter.</param>
void AddErrorHandler(Action<Exception> handler);

/// <summary>
/// Removes existing error handlers.
/// </summary>
void RemoveErrorHandlers();
}
}
42 changes: 42 additions & 0 deletions src/Appium.Net/Appium/WebSocket/ICanHandleMessages.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//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.

using System;
using System.Collections.Generic;

namespace OpenQA.Selenium.Appium.WebSocket
{
/// <summary>
/// Interface for handling WebSocket messages.
/// </summary>
/// <typeparam name="T">The type of message to handle.</typeparam>
public interface ICanHandleMessages<T>
{
/// <summary>
/// Gets the list of web socket message handlers.
/// </summary>
List<Action<T>> MessageHandlers { get; }

/// <summary>
/// Register a new message handler.
/// </summary>
/// <param name="handler">A callback function, which accepts the received message as a parameter.</param>
void AddMessageHandler(Action<T> handler);

/// <summary>
/// Removes existing message handlers.
/// </summary>
void RemoveMessageHandlers();
}
}
Loading