From 43c3d75ff3adb985c2ada0d5936ca1c82bf2c14f Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:49:06 +0000 Subject: [PATCH 01/10] feat(lan): wireless QR pairing, mDNS port healing, persistent root & UHID input Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/ClickZen.App/App.xaml.cs | 1 + src/ClickZen.App/Services/DeviceHub.cs | 125 ++++- src/ClickZen.App/Services/DeviceUi.cs | 5 +- src/ClickZen.App/Services/McpBackend.cs | 61 +- src/ClickZen.App/Strings/en-US/Resources.resw | 16 + src/ClickZen.App/Strings/strings.settings.tsv | 1 + src/ClickZen.App/Strings/strings.tsv | 15 + src/ClickZen.App/Strings/zh-CN/Resources.resw | 16 + .../ViewModels/DevicesViewModel.cs | 86 ++- src/ClickZen.App/Views/AddWirelessDialog.xaml | 54 +- .../Views/AddWirelessDialog.xaml.cs | 207 ++++++- src/ClickZen.App/Views/DevicesPage.xaml | 3 + src/ClickZen.App/Views/DevicesPage.xaml.cs | 2 + src/ClickZen.Core/Settings/AppSettings.cs | 2 + src/ClickZen.Core/Wireless/AdbWirelessQr.cs | 110 ++++ src/ClickZen.Core/Wireless/QrCodeEncoder.cs | 526 ++++++++++++++++++ src/ClickZen.Device/Adb/AdbService.cs | 57 ++ src/ClickZen.Device/Adb/AutoConnector.cs | 101 +++- src/ClickZen.Device/Adb/SavedDeviceStore.cs | 33 ++ .../Adb/Wireless/AdbMdnsDiscovery.cs | 354 ++++++++++++ .../Adb/Wireless/MdnsPacket.cs | 405 ++++++++++++++ .../Input/PersistentRootInjector.cs | 470 ++++++++++++++++ .../Scrcpy/Protocol/ControlMessageWriter.cs | 57 +- src/ClickZen.Device/Scrcpy/ScrcpySession.cs | 50 +- .../Scrcpy/ScrcpyTouchInjector.cs | 65 ++- src/ClickZen.Device/Scrcpy/ScrcpyTransport.cs | 40 +- src/ClickZen.Mcp/ClickZenTools.cs | 19 + src/ClickZen.Mcp/IClickZenBackend.cs | 12 + .../Wireless/QrAndCredentialsTests.cs | 67 +++ .../Adb/WirelessMdnsAndCoordinatorTests.cs | 260 +++++++++ .../Input/PersistentRootInjectorTests.cs | 170 ++++++ .../Scrcpy/ProtocolTests.cs | 17 + .../Scrcpy/ScrcpyConnectionTests.cs | 36 ++ tests/ClickZen.Mcp.Tests/FakeBackend.cs | 6 + .../ClickZen.Mcp.Tests/McpServerHostTests.cs | 16 + .../QrCodeOpticalDecodeTests.cs | 49 ++ 36 files changed, 3455 insertions(+), 59 deletions(-) create mode 100644 src/ClickZen.Core/Wireless/AdbWirelessQr.cs create mode 100644 src/ClickZen.Core/Wireless/QrCodeEncoder.cs create mode 100644 src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs create mode 100644 src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs create mode 100644 src/ClickZen.Device/Input/PersistentRootInjector.cs create mode 100644 tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs create mode 100644 tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs create mode 100644 tests/ClickZen.Device.Tests/Input/PersistentRootInjectorTests.cs create mode 100644 tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs diff --git a/src/ClickZen.App/App.xaml.cs b/src/ClickZen.App/App.xaml.cs index 186e53c..e57c32f 100644 --- a/src/ClickZen.App/App.xaml.cs +++ b/src/ClickZen.App/App.xaml.cs @@ -59,6 +59,7 @@ public App() // Devices sc.AddSingleton(); sc.AddSingleton(); + sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(sp => new ClickZen.Core.Devices.EmulatorProfileStore(sp.GetRequiredService(), sp.GetRequiredService>())); diff --git a/src/ClickZen.App/Services/DeviceHub.cs b/src/ClickZen.App/Services/DeviceHub.cs index 642b6c2..4cc8155 100644 --- a/src/ClickZen.App/Services/DeviceHub.cs +++ b/src/ClickZen.App/Services/DeviceHub.cs @@ -214,6 +214,19 @@ public DeviceHub(AdbServerHost server, AdbService adb, DeviceWatcher watcher, Au _watcher.DevicesChanged += OnDevicesChanged; _watcher.TrackingChanged += (_, tracking) => Ui(() => IsAdbAvailable = tracking); _profiles.Changed += (_, _) => Ui(SyncProfiles); + _settings.Changed += OnSettingsChanged; + } + + private void OnSettingsChanged(object? sender, EventArgs e) + { + var suppress = _settings.Current.Devices.SuppressSoftKeyboard; + foreach (var entry in Devices.ToArray()) + { + if (entry.Session is { } session) + { + _ = session.SetSuppressSoftKeyboardAsync(suppress); + } + } } public ObservableCollection Devices { get; } = []; @@ -291,25 +304,21 @@ public async Task RefreshAsync(CancellationToken ct = default) var pref = _settings.Current.Devices.InputMethod; if (pref == InputMethodPreference.Root) { - var (touch, raw) = await RootInputOfAsync(entry.Serial, ct); - if (touch is not null) - { - return new ClickZen.Device.Input.RootSendeventInjector( - (cmd, c) => _adb.RootShellAsync(entry.Serial, cmd, c), touch, - () => entry.EffectivePhysicalSize, - () => entry.Session is { } s && !s.VideoSize.IsEmpty && s.VideoSize.IsLandscape != entry.EffectivePhysicalSize.IsLandscape - ? ClickZen.Core.Geometry.DisplayRotation.Rotation90 - : ClickZen.Core.Geometry.DisplayRotation.Rotation0, - raw); - } - - _log.LogWarning("No touchscreen found for root input on {Serial}; using adb input as root", entry.Serial); - return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: true, () => entry.ActiveDisplayId); + return await GetRootInjectorCoreAsync(entry, ct); } - if (pref == InputMethodPreference.Scrcpy && entry.Session is { IsControlAvailable: true } session) + if (pref == InputMethodPreference.Scrcpy && entry.Session is { } session) { - return session.Injector; + if (session.IsControlAvailable) + { + return session.Injector; + } + + if (session.IsInputRejected && await HasRootAccessCachedAsync(entry.Serial, ct)) + { + _log.LogInformation("ROM blocked scrcpy INJECT_EVENTS on {Serial}; automatically routing input via persistent root", entry.Serial); + return await GetRootInjectorCoreAsync(entry, ct); + } } return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: false, () => entry.ActiveDisplayId); @@ -318,6 +327,68 @@ public async Task RefreshAsync(CancellationToken ct = default) private readonly System.Collections.Concurrent.ConcurrentDictionary> _rootInput = new(StringComparer.Ordinal); + private readonly System.Collections.Concurrent.ConcurrentDictionary _persistentRoot = + new(StringComparer.Ordinal); + + private readonly System.Collections.Concurrent.ConcurrentDictionary> _rootAvailability = + new(StringComparer.Ordinal); + + private async Task GetRootInjectorCoreAsync(DeviceEntry entry, CancellationToken ct) + { + var (touch, raw) = await RootInputOfAsync(entry.Serial, ct); + if (touch is not null) + { + return _persistentRoot.GetOrAdd(entry.Serial, s => + { + var fallback = new ClickZen.Device.Input.RootSendeventInjector( + (cmd, c) => _adb.RootShellAsync(s, cmd, c), touch, + () => entry.EffectivePhysicalSize, + () => entry.Session is { } sess && !sess.VideoSize.IsEmpty && sess.VideoSize.IsLandscape != entry.EffectivePhysicalSize.IsLandscape + ? ClickZen.Core.Geometry.DisplayRotation.Rotation90 + : ClickZen.Core.Geometry.DisplayRotation.Rotation0, + raw); + return ClickZen.Device.Input.PersistentRootInjector.ForAdbSocket( + _server.EndPoint, + s, + fallback, + touch, + raw, + () => entry.ActiveDisplayId, + logger: _loggers.CreateLogger()); + }); + } + + _log.LogWarning("No touchscreen found for root input on {Serial}; using adb input as root", entry.Serial); + return ClickZen.Device.Input.AdbInputInjector.For(_adb, entry.Serial, entry.Info.SdkLevel, asRoot: true, () => entry.ActiveDisplayId); + } + + private async Task HasRootAccessCachedAsync(string serial, CancellationToken ct) + { + var task = _rootAvailability.GetOrAdd(serial, async s => + { + try + { + var check = await _adb.CheckRootAsync(s, TimeSpan.FromSeconds(4), CancellationToken.None); + return check.HasRoot; + } + catch (Exception) + { + return false; + } + }); + return await task.WaitAsync(ct); + } + + private async Task ResetDeviceInputCachesAsync(string serial) + { + _rootInput.TryRemove(serial, out _); + _rootAvailability.TryRemove(serial, out _); + if (_persistentRoot.TryRemove(serial, out var rootInj)) + { + await rootInj.DisposeAsync(); + } + } + /// /// Touchscreen node and raw-event support of a rooted device, probed once per connection (getevent -p and the /// shell probe cost about a second, which every tap paid before). Failed probes are not cached. @@ -372,6 +443,7 @@ public async Task ConnectAsync(DeviceEntry entry, CancellationToken ct = default MaxFps = d.MaxFps, StayAwake = m.StayAwake, ShowTouches = m.ShowTouches, + SuppressSoftKeyboard = d.SuppressSoftKeyboard, AutoReconnect = d.AutoReconnect, }, _loggers.CreateLogger()) { @@ -382,6 +454,7 @@ public async Task ConnectAsync(DeviceEntry entry, CancellationToken ct = default entry.SessionState = s; entry.SessionError = session.LastError; }); + session.InputRejected += (_, _) => _ = HandleInputRejectedAsync(entry); entry.Session = session; Current ??= entry; await session.StartAsync(ct); @@ -527,9 +600,22 @@ public async Task DisconnectAsync(DeviceEntry entry) entry.Session = null; await session.DisposeAsync(); + await ResetDeviceInputCachesAsync(entry.Serial); entry.SessionState = SessionState.Disconnected; } + private async Task HandleInputRejectedAsync(DeviceEntry entry) + { + if (await HasRootAccessCachedAsync(entry.Serial, CancellationToken.None)) + { + Ui(() => entry.SessionError = _loc["Devices_InputSwitchedToRoot"]); + } + else + { + Ui(() => entry.SessionError = _loc["Devices_InputPermissionHint"]); + } + } + private void EnsureFfmpeg() { if (_ffmpegReady) @@ -637,6 +723,7 @@ private void Ui(Action action) public async ValueTask DisposeAsync() { + _settings.Changed -= OnSettingsChanged; _windowMonitor?.Cancel(); _displayMonitor?.Cancel(); foreach (var e in Devices.ToList()) @@ -644,6 +731,12 @@ public async ValueTask DisposeAsync() await DisconnectAsync(e); } + foreach (var kv in _persistentRoot) + { + await kv.Value.DisposeAsync(); + } + + _persistentRoot.Clear(); await _watcher.DisposeAsync(); } } diff --git a/src/ClickZen.App/Services/DeviceUi.cs b/src/ClickZen.App/Services/DeviceUi.cs index 083d2ad..85d1fc1 100644 --- a/src/ClickZen.App/Services/DeviceUi.cs +++ b/src/ClickZen.App/Services/DeviceUi.cs @@ -76,7 +76,7 @@ public static string StateText(DeviceAdbState adb, SessionState session, string? }, }; - return string.IsNullOrEmpty(error) || session is SessionState.Streaming or SessionState.Connected + return string.IsNullOrEmpty(error) ? text : $"{text}: {error}"; } @@ -102,6 +102,9 @@ public static Visibility CanStart(DeviceAdbState adb, ILiveFrameSource? frames) public static Visibility IsNetwork(ConnectionKind kind) => kind is ConnectionKind.Wireless or ConnectionKind.Emulator ? Visibility.Visible : Visibility.Collapsed; + public static Visibility CanSwitchUsbToWireless(ConnectionKind kind, DeviceAdbState adb) => + kind == ConnectionKind.Usb && adb == DeviceAdbState.Online ? Visibility.Visible : Visibility.Collapsed; + public static Visibility IsWindowKind(ConnectionKind kind) => kind == ConnectionKind.Window ? Visibility.Visible : Visibility.Collapsed; /// "Start mirroring" for adb devices, "Start capture" / "Reconnect" for window devices. diff --git a/src/ClickZen.App/Services/McpBackend.cs b/src/ClickZen.App/Services/McpBackend.cs index 652da48..86abff8 100644 --- a/src/ClickZen.App/Services/McpBackend.cs +++ b/src/ClickZen.App/Services/McpBackend.cs @@ -11,6 +11,7 @@ using ClickZen.Core.Settings; using ClickZen.Core.Variables; using ClickZen.Device.Adb; +using ClickZen.Device.Adb.Wireless; using ClickZen.Device.Scrcpy; using ClickZen.Mcp; using Microsoft.Extensions.Logging; @@ -30,6 +31,9 @@ public sealed class McpBackend : IClickZenBackend private static readonly TimeSpan FreshFrameTimeout = TimeSpan.FromMilliseconds(600); private readonly DeviceHub _hub; + private readonly AdbService _adb; + private readonly IAdbMdnsDiscovery _mdns; + private readonly SavedDeviceStore _saved; private readonly EmulatorService _emulators; private readonly AutomationService _automation; private readonly RecordingService _recording; @@ -45,10 +49,14 @@ public sealed class McpBackend : IClickZenBackend private CancellationTokenSource? _playCts; private McpPlaybackInfo? _playback; - public McpBackend(DeviceHub hub, EmulatorService emulators, AutomationService automation, RecordingService recording, VariableStore globals, + public McpBackend(DeviceHub hub, AdbService adb, IAdbMdnsDiscovery mdns, SavedDeviceStore saved, + EmulatorService emulators, AutomationService automation, RecordingService recording, VariableStore globals, IImageMatcher matcher, SettingsService settings, AppPaths paths, ILogger log) { _hub = hub; + _adb = adb; + _mdns = mdns; + _saved = saved; _emulators = emulators; _automation = automation; _recording = recording; @@ -216,6 +224,57 @@ public async Task BindEmulatorWindowAsync(string? emulator, Can }); } + public async Task> DiscoverLanDevicesAsync(CancellationToken ct) + { + var list = await _mdns.DiscoverAsync(TimeSpan.FromMilliseconds(1100), ct); + return list.Select(s => new McpLanDevice( + s.Address, + s.Host, + s.Port, + s.Kind switch + { + MdnsAdbServiceKind.TlsConnect => "tlsConnect", + MdnsAdbServiceKind.TlsPairing => "tlsPairing", + _ => "legacyTcp", + }, + s.InstanceName)).ToList(); + } + + public async Task EnableWirelessAdbAsync(string? device, int port, bool remember, CancellationToken ct) + { + var (serial, name) = await OnUi(() => + { + var e = ResolveEntry(device); + var adbSerial = e.AdbSerial ?? throw new McpToolException($"Device \"{e.DisplayName}\" has no linked ADB device."); + return (adbSerial, e.DisplayName); + }); + + var r = await _adb.EnableWirelessAsync(serial, port, ct: ct); + var saved = false; + if (r.Success && remember) + { + var colon = r.Address.LastIndexOf(':'); + if (colon > 0 && int.TryParse(r.Address[(colon + 1)..], out var parsedPort)) + { + _saved.AddOrUpdate(new SavedDevice + { + Name = name, + Host = r.Address[..colon], + Port = parsedPort, + AutoConnect = true, + }); + saved = true; + } + } + + if (r.Success) + { + await _hub.RefreshAsync(ct); + } + + return new McpWirelessEnableResult(r.Success, r.Address, r.Message, saved); + } + /// Detects again and resolves the argument; a clear error lists the running emulators when nothing matches. private async Task ResolveEmulatorAsync(string? emulator, CancellationToken ct) { diff --git a/src/ClickZen.App/Strings/en-US/Resources.resw b/src/ClickZen.App/Strings/en-US/Resources.resw index b132d8c..cf4e588 100644 --- a/src/ClickZen.App/Strings/en-US/Resources.resw +++ b/src/ClickZen.App/Strings/en-US/Resources.resw @@ -58,6 +58,7 @@ Start mirroring Stop mirroring Set as current + Switch to Wi-Fi Disconnect Saved wireless devices Give a wireless device a name when connecting to save it for one-click or automatic reconnection. @@ -66,6 +67,9 @@ Remove Connected to {0} Disconnected {0} + Wireless ADB enabled and connected to {0}; you can unplug USB now + System blocked standard touch injection; switched to Root fast injection + System blocked touch injection: enable "USB debugging (Security settings)" or "Disable permission monitoring" in Developer options Paired. Now enter the IP address and port shown on the phone's Wireless debugging screen to connect. No local emulators found. Make sure the emulator is running with ADB enabled. Connected {0} emulator(s). @@ -84,9 +88,20 @@ The ADB server could not be started. Connection refused. Check that wireless debugging is on and the address is correct. Add wireless device + QR & LAN Connect by IP Pair with code Scan emulators + Android 11+: on the phone open Developer options -> Wireless debugging -> Pair device with QR code and scan below to pair and connect automatically. + New QR code + Waiting for phone to scan… + Phone found at {0}, pairing… + Paired; connecting to wireless debugging port… + Paired, but no connect port was broadcast; enter the connect address on the Pair tab. + QR pairing did not finish; generate a new QR code and try again. + Discovered on LAN (mDNS) + Refresh + No wireless debugging broadcasts found on LAN yet. Turn on Wireless debugging on the phone to appear here. For devices with TCP debugging enabled (adb tcpip) or emulators on the network. Port defaults to 5555. Address 192.168.1.20:5555 @@ -684,6 +699,7 @@ Bit rate (Mbps) Maximum FPS Input method + Suppress on-screen soft keyboard (UHID hardware keyboard) High-quality scaling Show touches Stay awake diff --git a/src/ClickZen.App/Strings/strings.settings.tsv b/src/ClickZen.App/Strings/strings.settings.tsv index 1694336..fc34cf0 100644 --- a/src/ClickZen.App/Strings/strings.settings.tsv +++ b/src/ClickZen.App/Strings/strings.settings.tsv @@ -16,6 +16,7 @@ Settings_Devices_MaxSize 最长边(0 为原始分辨率) Maximum edge (0 for Settings_Devices_VideoBitRateMbps 码率(Mbps) Bit rate (Mbps) Settings_Devices_MaxFps 最大帧率 Maximum FPS Settings_Devices_InputMethod 输入方式 Input method +Settings_Devices_SuppressSoftKeyboard 抑制手机软键盘弹出(UHID 外接键盘模式) Suppress on-screen soft keyboard (UHID hardware keyboard) Settings_Mirror_HighQualityScaling 高质量缩放 High-quality scaling Settings_Mirror_ShowTouches 显示触点 Show touches Settings_Mirror_StayAwake 保持常亮 Stay awake diff --git a/src/ClickZen.App/Strings/strings.tsv b/src/ClickZen.App/Strings/strings.tsv index e45b481..4599a0e 100644 --- a/src/ClickZen.App/Strings/strings.tsv +++ b/src/ClickZen.App/Strings/strings.tsv @@ -57,6 +57,7 @@ Devices_CurrentBadge.Text 当前设备 Current Devices_StartMirror.Content 开始投屏 Start mirroring Devices_StopMirror.Content 停止投屏 Stop mirroring Devices_SetCurrent.Content 设为当前 Set as current +Devices_SwitchWireless.Content 转为无线 Switch to Wi-Fi Devices_DisconnectWireless.Content 断开 Disconnect Devices_SavedHeader.Text 已保存的无线设备 Saved wireless devices Devices_SavedEmpty.Text 连接无线设备时填写名称即可保存,之后可一键连接或开机自动连接。 Give a wireless device a name when connecting to save it for one-click or automatic reconnection. @@ -65,6 +66,9 @@ Devices_Connect.Content 连接 Connect Devices_RemoveSaved.ToolTipService.ToolTip 删除 Remove Devices_Connected 已连接 {0} Connected to {0} Devices_Disconnected 已断开 {0} Disconnected {0} +Devices_UsbSwitchedWireless 已开启无线调试并连接 {0},现在可拔下数据线 Wireless ADB enabled and connected to {0}; you can unplug USB now +Devices_InputSwitchedToRoot 系统拦截了普通模拟点击,已自动切换至 Root 极速注入 System blocked standard touch injection; switched to Root fast injection +Devices_InputPermissionHint 系统拦截了模拟点击:请在手机“开发者选项”中开启“USB 调试(安全设置)”或“禁止权限监控” System blocked touch injection: enable "USB debugging (Security settings)" or "Disable permission monitoring" in Developer options Devices_Paired 配对成功。请在下方输入手机“无线调试”页面上显示的 IP 地址和端口完成连接。 Paired. Now enter the IP address and port shown on the phone's Wireless debugging screen to connect. Devices_ScanNone 没有发现本地模拟器。请确认模拟器已启动并开启了 ADB 调试。 No local emulators found. Make sure the emulator is running with ADB enabled. Devices_ScanFound 已连接 {0} 个模拟器。 Connected {0} emulator(s). @@ -84,9 +88,20 @@ AdbError_ServerStartFailed ADB 服务无法启动。 The ADB server could not be AdbError_ConnectionRefused 连接被拒绝。请确认设备已开启无线调试且地址正确。 Connection refused. Check that wireless debugging is on and the address is correct. # ---- Add wireless dialog ---- AddWireless_Dialog.Title 添加无线设备 Add wireless device +AddWireless_TabQr.Text 扫码与局域网 QR & LAN AddWireless_TabDirect.Text IP 直连 Connect by IP AddWireless_TabPair.Text 配对码配对 Pair with code AddWireless_TabScan.Text 扫描模拟器 Scan emulators +AddWireless_QrHint.Text Android 11 及以上:在手机“开发者选项 → 无线调试 → 使用二维码配对设备”扫描下方二维码,配对与连接将自动完成。 Android 11+: on the phone open Developer options -> Wireless debugging -> Pair device with QR code and scan below to pair and connect automatically. +AddWireless_QrRefresh.Content 刷新二维码 New QR code +AddWireless_QrWaiting 等待手机扫码… Waiting for phone to scan… +AddWireless_QrPairing 检测到手机 {0},正在自动配对… Phone found at {0}, pairing… +AddWireless_QrConnecting 配对成功,正在连接无线调试端口… Paired; connecting to wireless debugging port… +AddWireless_QrPairedManualPort 配对成功,但未收到连接端口广播;请切换到“配对码配对”页填写连接地址。 Paired, but no connect port was broadcast; enter the connect address on the Pair tab. +AddWireless_QrFailed 扫码配对未完成,请刷新二维码重试。 QR pairing did not finish; generate a new QR code and try again. +AddWireless_LanHeader.Text 局域网已发现设备(mDNS) Discovered on LAN (mDNS) +AddWireless_LanRefresh.Content 刷新列表 Refresh +AddWireless_LanEmpty.Text 暂未发现局域网广播的无线调试设备。开启手机“无线调试”后将自动出现在此处。 No wireless debugging broadcasts found on LAN yet. Turn on Wireless debugging on the phone to appear here. AddWireless_DirectHint.Text 适用于已开启 TCP 调试(adb tcpip)的设备或局域网内的模拟器。不填端口时默认 5555。 For devices with TCP debugging enabled (adb tcpip) or emulators on the network. Port defaults to 5555. AddWireless_Address.Header 地址 Address AddWireless_Address.PlaceholderText 192.168.1.20:5555 192.168.1.20:5555 diff --git a/src/ClickZen.App/Strings/zh-CN/Resources.resw b/src/ClickZen.App/Strings/zh-CN/Resources.resw index 77869ca..5654f84 100644 --- a/src/ClickZen.App/Strings/zh-CN/Resources.resw +++ b/src/ClickZen.App/Strings/zh-CN/Resources.resw @@ -58,6 +58,7 @@ 开始投屏 停止投屏 设为当前 + 转为无线 断开 已保存的无线设备 连接无线设备时填写名称即可保存,之后可一键连接或开机自动连接。 @@ -66,6 +67,9 @@ 删除 已连接 {0} 已断开 {0} + 已开启无线调试并连接 {0},现在可拔下数据线 + 系统拦截了普通模拟点击,已自动切换至 Root 极速注入 + 系统拦截了模拟点击:请在手机“开发者选项”中开启“USB 调试(安全设置)”或“禁止权限监控” 配对成功。请在下方输入手机“无线调试”页面上显示的 IP 地址和端口完成连接。 没有发现本地模拟器。请确认模拟器已启动并开启了 ADB 调试。 已连接 {0} 个模拟器。 @@ -84,9 +88,20 @@ ADB 服务无法启动。 连接被拒绝。请确认设备已开启无线调试且地址正确。 添加无线设备 + 扫码与局域网 IP 直连 配对码配对 扫描模拟器 + Android 11 及以上:在手机“开发者选项 → 无线调试 → 使用二维码配对设备”扫描下方二维码,配对与连接将自动完成。 + 刷新二维码 + 等待手机扫码… + 检测到手机 {0},正在自动配对… + 配对成功,正在连接无线调试端口… + 配对成功,但未收到连接端口广播;请切换到“配对码配对”页填写连接地址。 + 扫码配对未完成,请刷新二维码重试。 + 局域网已发现设备(mDNS) + 刷新列表 + 暂未发现局域网广播的无线调试设备。开启手机“无线调试”后将自动出现在此处。 适用于已开启 TCP 调试(adb tcpip)的设备或局域网内的模拟器。不填端口时默认 5555。 地址 192.168.1.20:5555 @@ -684,6 +699,7 @@ 码率(Mbps) 最大帧率 输入方式 + 抑制手机软键盘弹出(UHID 外接键盘模式) 高质量缩放 显示触点 保持常亮 diff --git a/src/ClickZen.App/ViewModels/DevicesViewModel.cs b/src/ClickZen.App/ViewModels/DevicesViewModel.cs index 5d90c30..d2b79e9 100644 --- a/src/ClickZen.App/ViewModels/DevicesViewModel.cs +++ b/src/ClickZen.App/ViewModels/DevicesViewModel.cs @@ -2,6 +2,7 @@ using ClickZen.App.Services; using ClickZen.Core.Devices; using ClickZen.Device.Adb; +using ClickZen.Device.Adb.Wireless; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; @@ -40,15 +41,17 @@ public sealed partial class DevicesViewModel : ObservableObject { private readonly DeviceHub _hub; private readonly AdbService _adb; + private readonly IAdbMdnsDiscovery _mdns; private readonly SavedDeviceStore _saved; private readonly EmulatorService _emulators; private readonly ILocalizer _loc; private readonly ILogger _log; - public DevicesViewModel(DeviceHub hub, AdbService adb, SavedDeviceStore saved, EmulatorService emulators, ILocalizer loc, ILogger log) + public DevicesViewModel(DeviceHub hub, AdbService adb, IAdbMdnsDiscovery mdns, SavedDeviceStore saved, EmulatorService emulators, ILocalizer loc, ILogger log) { _hub = hub; _adb = adb; + _mdns = mdns; _saved = saved; _emulators = emulators; _loc = loc; @@ -270,6 +273,31 @@ await RunAsync(async () => }); } + [RelayCommand] + private async Task SwitchUsbToWirelessAsync(DeviceEntry entry) + { + await RunAsync(async () => + { + var r = await _adb.EnableWirelessAsync(entry.Serial); + if (r.Success && TrySplit(r.Address, out var host, out var port)) + { + _saved.AddOrUpdate(new SavedDevice + { + Name = entry.Info.DisplayName, + Host = host, + Port = port, + AutoConnect = true, + }); + await _hub.RefreshAsync(); + Report(_loc.Format("Devices_UsbSwitchedWireless", r.Address), false); + } + else + { + Report(r.Message, true); + } + }); + } + [RelayCommand] private async Task ConnectSavedAsync(SavedDeviceItem item) { @@ -277,7 +305,24 @@ private async Task ConnectSavedAsync(SavedDeviceItem item) try { var r = await _adb.ConnectAsync(item.Address); - Report(r.Success ? _loc.Format("Devices_Connected", item.Address) : r.Message, !r.Success); + if (!r.Success) + { + // Android 11+ randomizes Wireless Debugging ports across Wi-Fi/reboot cycles; probe mDNS to heal. + var services = await _mdns.DiscoverAsync(TimeSpan.FromMilliseconds(1100)); + var healed = services.FirstOrDefault(s => + s.Kind is MdnsAdbServiceKind.TlsConnect or MdnsAdbServiceKind.LegacyTcp + && string.Equals(s.Host, item.Model.Host.Trim('[', ']'), StringComparison.OrdinalIgnoreCase)); + if (healed is not null && healed.Port != item.Model.Port) + { + r = await _adb.ConnectAsync(healed.Address); + if (r.Success) + { + _saved.UpdatePortForHost(item.Model.Host, item.Model.Port, healed.Port); + } + } + } + + Report(r.Success ? _loc.Format("Devices_Connected", r.Address) : r.Message, !r.Success); } catch (Exception ex) { @@ -323,6 +368,43 @@ public async Task PairAsync(string address, string code, Cance return r; } + public WirelessQrPairCoordinator CreateQrPairCoordinator() => new(_adb, _mdns); + + public async Task> DiscoverLanDevicesAsync(CancellationToken ct = default) + { + var all = await _mdns.DiscoverAsync(TimeSpan.FromMilliseconds(1100), ct); + return all.Where(s => s.Kind is MdnsAdbServiceKind.TlsConnect or MdnsAdbServiceKind.LegacyTcp).ToArray(); + } + + public async Task RememberConnectedDeviceAsync(string address, string? fallbackName = null) + { + if (!TrySplit(address, out var host, out var port)) + { + return; + } + + await _hub.RefreshAsync(); + var live = _hub.Devices.FirstOrDefault(d => string.Equals(d.Serial, address, StringComparison.OrdinalIgnoreCase)); + var name = live?.Info.DisplayName; + if (string.IsNullOrWhiteSpace(name) || string.Equals(name, address, StringComparison.OrdinalIgnoreCase)) + { + name = !string.IsNullOrWhiteSpace(fallbackName) ? fallbackName.Trim() : host; + } + + // Update any prior port for this LAN host in place, or add a new saved wireless device. + var existingSameHost = _saved.GetAll().FirstOrDefault(s => string.Equals(s.Host.Trim('[', ']'), host.Trim('[', ']'), StringComparison.OrdinalIgnoreCase)); + if (existingSameHost is not null && existingSameHost.Port != port) + { + _saved.UpdatePortForHost(existingSameHost.Host, existingSameHost.Port, port); + } + else + { + _saved.AddOrUpdate(new SavedDevice { Name = name, Host = host, Port = port, AutoConnect = true }); + } + + Report(_loc.Format("Devices_Connected", address), false); + } + /// /// The scan tab: connect every detected emulator (exact ports from process detection), then try the port table for /// emulators detection does not know. Returns the addresses / serials now connected. diff --git a/src/ClickZen.App/Views/AddWirelessDialog.xaml b/src/ClickZen.App/Views/AddWirelessDialog.xaml index 47623c0..f44a83a 100644 --- a/src/ClickZen.App/Views/AddWirelessDialog.xaml +++ b/src/ClickZen.App/Views/AddWirelessDialog.xaml @@ -3,11 +3,12 @@ x:Class="ClickZen.App.Views.AddWirelessDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:ClickZen.App.Views" x:Uid="AddWireless_Dialog" DefaultButton="None" Style="{StaticResource DefaultContentDialogStyle}"> - + @@ -15,13 +16,60 @@ - + + + + + + + + + + + + + + + + + public Task ServerExited { get; } + /// Fired when scrcpy-server reports that Android rejected control input (INJECT_EVENTS / SecurityException). + public event Action? InputRejected; + + public void NotifyInputRejected(string reason) => InputRejected?.Invoke(reason); + + /// Detects ROM security permission errors (Xiaomi HyperOS/MIUI, VIVO, OPPO) in scrcpy-server stdout. + public static bool IsInputPermissionError(string? line) => + !string.IsNullOrEmpty(line) + && (line.Contains("INJECT_EVENTS", StringComparison.OrdinalIgnoreCase) + || line.Contains("Injecting input events requires", StringComparison.OrdinalIgnoreCase) + || (line.Contains("SecurityException", StringComparison.OrdinalIgnoreCase) + && line.Contains("inject", StringComparison.OrdinalIgnoreCase))); + public async ValueTask DisposeAsync() { await Connection.DisposeAsync(); @@ -85,10 +98,17 @@ private async Task StartReverseAsync(string serial, ScrcpySer var remote = "localabstract:" + options.SocketName; var serverCts = new CancellationTokenSource(); Task? server = null; + ScrcpyStartResult? result = null; try { await _adb.CreateReverseAsync(serial, remote, $"tcp:{port}", ct: ct); - server = RunServerAsync(serial, options, serverCts.Token); + server = RunServerAsync(serial, options, line => + { + if (ScrcpyStartResult.IsInputPermissionError(line)) + { + result?.NotifyInputRejected(line); + } + }, serverCts.Token); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(ConnectTimeout); @@ -101,13 +121,14 @@ private async Task StartReverseAsync(string serial, ScrcpySer var connection = await accept; listener.Stop(); - return new ScrcpyStartResult(connection, server, async () => + result = new ScrcpyStartResult(connection, server, async () => { await serverCts.CancelAsync(); await IgnoreErrors(_adb.RemoveReverseAsync(serial, remote, CancellationToken.None)); await IgnoreErrors(server); serverCts.Dispose(); }); + return result; } catch { @@ -129,19 +150,27 @@ private async Task StartForwardAsync(string serial, ScrcpySer var serverCts = new CancellationTokenSource(); var port = 0; Task? server = null; + ScrcpyStartResult? result = null; try { port = await _adb.CreateForwardAsync(serial, "tcp:0", "localabstract:" + options.SocketName, ct: ct); - server = RunServerAsync(serial, options, serverCts.Token); + server = RunServerAsync(serial, options, line => + { + if (ScrcpyStartResult.IsInputPermissionError(line)) + { + result?.NotifyInputRejected(line); + } + }, serverCts.Token); var connection = await ScrcpyConnection.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port), options.Video, ConnectTimeout, ct); var p = port; - return new ScrcpyStartResult(connection, server, async () => + result = new ScrcpyStartResult(connection, server, async () => { await serverCts.CancelAsync(); await IgnoreErrors(_adb.RemoveForwardAsync(serial, p, CancellationToken.None)); await IgnoreErrors(server); serverCts.Dispose(); }); + return result; } catch { @@ -161,7 +190,7 @@ private async Task StartForwardAsync(string serial, ScrcpySer } } - private Task RunServerAsync(string serial, ScrcpyServerOptions options, CancellationToken ct) => + private Task RunServerAsync(string serial, ScrcpyServerOptions options, Action? onLine, CancellationToken ct) => Task.Run(async () => { try @@ -171,6 +200,7 @@ await _adb.ExecuteStreamingAsync(serial, options.BuildCommand(), line => if (!string.IsNullOrWhiteSpace(line)) { _logger?.LogDebug("[scrcpy-server {Serial}] {Line}", serial, line); + onLine?.Invoke(line); } }, ct); } diff --git a/src/ClickZen.Mcp/ClickZenTools.cs b/src/ClickZen.Mcp/ClickZenTools.cs index 7ab5934..fb3bb78 100644 --- a/src/ClickZen.Mcp/ClickZenTools.cs +++ b/src/ClickZen.Mcp/ClickZenTools.cs @@ -153,6 +153,25 @@ public Task BindEmulatorWindow( CancellationToken cancellationToken = default) => Run(async ct => McpJson.Serialize(await B.BindEmulatorWindowAsync(emulator, ct)), cancellationToken); + [McpServerTool(Name = "discover_lan_devices", Title = "Discover LAN Android devices", ReadOnly = true, Idempotent = true, Destructive = false, OpenWorld = false)] + [McpCapability(McpCapability.Observe)] + [Description("Discovers Android devices on the local network advertising Wireless Debugging (_adb-tls-connect._tcp / _adb-tls-pairing._tcp) or legacy TCP ADB (_adb._tcp) via mDNS / DNS-SD. Useful when an Android 11+ phone's Wireless Debugging port changed after reconnecting to Wi-Fi.")] + public Task DiscoverLanDevices(CancellationToken cancellationToken = default) => Run(async ct => + McpJson.Serialize(await B.DiscoverLanDevicesAsync(ct)), cancellationToken); + + [McpServerTool(Name = "enable_wireless_adb", Title = "Enable wireless ADB from USB", ReadOnly = false, Idempotent = true, Destructive = false, OpenWorld = false)] + [McpCapability(McpCapability.Control)] + [Description("Switches a USB-connected Android device to wireless TCP/IP ADB mode (default port 5555), reads its Wi-Fi IP, connects over LAN, and optionally remembers it for automatic reconnection after unplugging USB.")] + public Task EnableWirelessAdb( + [Description("USB device id or name from list_devices; omit for the current device.")] string? device = null, + [Description("TCP port to open on the phone (default 5555).")] int port = 5555, + [Description("Save this device for automatic wireless reconnection (default true).")] bool remember = true, + CancellationToken cancellationToken = default) => Run(async ct => + { + var r = await B.EnableWirelessAdbAsync(device, Math.Clamp(port, 1024, 65535), remember, ct); + return r.Connected ? McpJson.Serialize(r) : throw new McpToolException(r.Message); + }, cancellationToken); + [McpServerTool(Name = "take_screenshot", Title = "Take screenshot", ReadOnly = true, Idempotent = false, Destructive = false, OpenWorld = false)] [McpCapability(McpCapability.Observe)] [Description("Captures the device screen and returns it as an image (JPEG by default, scaled so the longest edge is maxSize). The text part tells how image pixels map to device pixels. To tap something seen on the image, pass its image coordinates to tap with refWidth/refHeight = the image size (full-screen screenshots only).")] diff --git a/src/ClickZen.Mcp/IClickZenBackend.cs b/src/ClickZen.Mcp/IClickZenBackend.cs index 54d5c55..1230152 100644 --- a/src/ClickZen.Mcp/IClickZenBackend.cs +++ b/src/ClickZen.Mcp/IClickZenBackend.cs @@ -184,6 +184,12 @@ public sealed record McpEmulatorConnect(bool Connected, string? Device, string M /// How the Android screen was located ("aspectMatch", "renderWindow", "screenshotMatch", "edgeDetection", "clientArea"). public sealed record McpEmulatorBind(string WindowDevice, string Method, string? AdbDevice, int ScreenWidth, int ScreenHeight, string Message); +/// An Android device discovered on the local network via mDNS / DNS-SD. +public sealed record McpLanDevice(string Address, string Host, int Port, string Kind, string InstanceName); + +/// Outcome of enable_wireless_adb. +public sealed record McpWirelessEnableResult(bool Connected, string Address, string Message, bool Saved); + /// /// Everything the MCP tools need from ClickZen. device arguments are a device id from /// , a display name, or null/empty for the current device. @@ -204,6 +210,12 @@ public interface IClickZenBackend /// Binds a detected emulator's window as a window device (locates the Android screen automatically). Task BindEmulatorWindowAsync(string? emulator, CancellationToken ct); + /// Discovers Android Wireless Debugging / TCP ADB services advertised on the LAN via mDNS. + Task> DiscoverLanDevicesAsync(CancellationToken ct); + + /// Switches a USB-connected Android device to wireless TCP/IP ADB and connects to its LAN IP. + Task EnableWirelessAdbAsync(string? device, int port, bool remember, CancellationToken ct); + /// Starts mirroring (scrcpy session / window capture) if needed and waits for a picture. Task ConnectAsync(string? device, CancellationToken ct); diff --git a/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs b/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs new file mode 100644 index 0000000..6f952a3 --- /dev/null +++ b/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs @@ -0,0 +1,67 @@ +using ClickZen.Core.Wireless; + +namespace ClickZen.Core.Tests.Wireless; + +public sealed class QrAndCredentialsTests +{ + [Fact] + public void Random_credentials_generate_valid_android_adb_wifi_qr_payload_and_round_trip() + { + var creds = AdbWirelessQrCredentials.CreateRandom(); + + Assert.StartsWith("clickzen-", creds.ServiceName, StringComparison.Ordinal); + Assert.Equal(6, creds.Password.Length); + Assert.StartsWith("WIFI:T:ADB;S:", creds.Payload, StringComparison.Ordinal); + Assert.EndsWith(";;", creds.Payload, StringComparison.Ordinal); + + Assert.True(AdbWirelessQrCredentials.TryParse(creds.Payload, out var parsed)); + Assert.Equal(creds, parsed); + } + + [Fact] + public void Credentials_escape_and_unescape_reserved_characters() + { + var creds = AdbWirelessQrCredentials.Create("cz;host:01", @"p\w,1""2"); + + Assert.Equal(@"WIFI:T:ADB;S:cz\;host\:01;P:p\\w\,1\""2;;", creds.Payload); + Assert.True(AdbWirelessQrCredentials.TryParse(creds.Payload, out var parsed)); + Assert.NotNull(parsed); + Assert.Equal(creds.ServiceName, parsed.ServiceName); + Assert.Equal(creds.Password, parsed.Password); + } + + [Theory] + [InlineData("")] + [InlineData("WIFI:T:WPA;S:home;P:123456;;")] + [InlineData("WIFI:T:ADB;S:;P:123456;;")] + public void Rejects_non_adb_or_incomplete_qr_payloads(string payload) => + Assert.False(AdbWirelessQrCredentials.TryParse(payload, out _)); + + [Fact] + public void Qr_encoder_draws_finder_patterns_and_scales_versions_cleanly() + { + var v1 = QrCodeEncoder.Encode("HELLO"); + Assert.Equal(1, v1.Version); + Assert.Equal(21, v1.Size); + + // Top-left, top-right, bottom-left finder corners are dark modules. + Assert.True(v1[0, 0]); + Assert.True(v1[3, 3]); + Assert.False(v1[1, 1]); + Assert.True(v1[0, v1.Size - 1]); + Assert.True(v1[v1.Size - 1, 0]); + + var creds = AdbWirelessQrCredentials.Create("clickzen-01020304", "123456"); + var qr = QrCodeEncoder.Encode(creds.Payload); + Assert.InRange(qr.Version, 2, 4); + + var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 4, quietZoneModules: 3); + Assert.Equal((qr.Size + 6) * 4, w); + Assert.Equal(w, h); + Assert.Equal(w * h * 4, bgra.Length); + } + + [Fact] + public void Qr_encoder_rejects_payloads_exceeding_version6_capacity() => + Assert.Throws(() => QrCodeEncoder.Encode(new string('A', 200))); +} diff --git a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs new file mode 100644 index 0000000..f54dd49 --- /dev/null +++ b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs @@ -0,0 +1,260 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; +using ClickZen.Core.Devices; +using ClickZen.Core.Persistence; +using ClickZen.Core.Wireless; +using ClickZen.Device.Adb; +using ClickZen.Device.Adb.Wireless; + +namespace ClickZen.Device.Tests.Adb; + +public sealed class WirelessMdnsAndCoordinatorTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "cz-mdns-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private sealed class FakeMdnsDiscovery(Func> responder) : IAdbMdnsDiscovery + { + private int _calls; + + public Task> DiscoverAsync(TimeSpan duration, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var idx = Interlocked.Increment(ref _calls); + return Task.FromResult(responder(idx)); + } + } + + [Fact] + public void Builds_valid_mdns_query_and_parses_dns_sd_srv_and_a_records() + { + var query = MdnsPacket.BuildPtrQuery(unicastResponse: false); + Assert.Equal(0, BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(0, 2))); + Assert.Equal(3, BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(4, 2))); // 3 PTR questions + + var resp = BuildSyntheticMdnsResponse( + instance: "clickzen-abc123", + service: "_adb-tls-pairing._tcp.local", + targetHost: "pixel8.local", + port: 39123, + ip: IPAddress.Parse("192.168.1.77")); + + var parsed = MdnsPacket.ParseMessage(resp, IPAddress.Parse("192.168.1.1")); + var svc = Assert.Single(parsed); + Assert.Equal("clickzen-abc123", svc.InstanceName); + Assert.Equal(MdnsAdbServiceKind.TlsPairing, svc.Kind); + Assert.Equal("192.168.1.77", svc.Host); + Assert.Equal(39123, svc.Port); + Assert.Equal("192.168.1.77:39123", svc.Address); + } + + [Fact] + public void Parses_adb_mdns_services_cli_output() + { + const string sample = """ + List of discovered mdns services + adb-R5CT1234-v2k9a _adb-tls-connect._tcp. 192.168.31.88:43109 + clickzen-9988aabb _adb-tls-pairing._tcp 192.168.31.88:37511 + legacy-phone _adb._tcp. 10.0.0.18:5555 + """; + + var list = MdnsPacket.ParseAdbMdnsServicesOutput(sample); + Assert.Equal(3, list.Count); + Assert.Contains(list, s => s.Kind == MdnsAdbServiceKind.TlsConnect && s.Address == "192.168.31.88:43109"); + Assert.Contains(list, s => s.Kind == MdnsAdbServiceKind.TlsPairing && s.InstanceName == "clickzen-9988aabb" && s.Port == 37511); + Assert.Contains(list, s => s.Kind == MdnsAdbServiceKind.LegacyTcp && s.Address == "10.0.0.18:5555"); + } + + [Fact] + public async Task AutoConnector_heals_randomized_tls_port_via_mdns_and_updates_saved_store() + { + var store = new SavedDeviceStore(new AppPaths(_root)); + store.AddOrUpdate(new SavedDevice { Name = "My Xiaomi", Host = "192.168.1.66", Port = 38111, AutoConnect = true }); + + var (client, fake) = FakeAdbClient.Create(); + fake.Connect = (addr, _) => Task.FromResult( + addr == "192.168.1.66:44229" + ? $"connected to {addr}" + : $"cannot connect to {addr}: No connection could be made (10061)"); + var adb = new AdbService(client, null, null, null); + var mdns = new FakeMdnsDiscovery(_ => + [ + new MdnsAdbService(MdnsAdbServiceKind.TlsConnect, "adb-xiaomi", "192.168.1.66", 44229), + ]); + + var results = await new AutoConnector(adb, store, () => true, mdns, null).ConnectSavedAsync(TestContext.Current.CancellationToken); + + var r = Assert.Single(results); + Assert.True(r.Success); + Assert.Equal("192.168.1.66:44229", r.Address); + + // SavedDeviceStore must be updated to the new dynamic port so future connects succeed immediately. + var updated = Assert.Single(store.GetAll()); + Assert.Equal("My Xiaomi", updated.Name); + Assert.Equal(44229, updated.Port); + } + + [Fact] + public async Task Qr_pair_coordinator_pairs_and_connects_automatically_when_phone_scans_qr() + { + var creds = AdbWirelessQrCredentials.Create("clickzen-testpair01", "876543"); + string? pairedAddr = null; + string? pairedCode = null; + + var (client, fake) = FakeAdbClient.Create(); + fake.Pair = (addr, code, _) => + { + pairedAddr = addr; + pairedCode = code; + return Task.FromResult($"Successfully paired to {addr} [guid=adb-pixel]"); + }; + fake.Connect = (addr, _) => Task.FromResult($"connected to {addr}"); + var adb = new AdbService(client, null, null, null); + + var mdns = new FakeMdnsDiscovery(call => call switch + { + 1 => [], // Phone hasn't scanned QR yet + 2 => [new MdnsAdbService(MdnsAdbServiceKind.TlsPairing, "clickzen-testpair01", "192.168.1.90", 37412)], + _ => [new MdnsAdbService(MdnsAdbServiceKind.TlsConnect, "adb-pixel-xyz", "192.168.1.90", 41888)], + }); + + var stages = new List(); + var progress = new SynchronousProgress(p => stages.Add(p.Stage)); + var coordinator = new WirelessQrPairCoordinator(adb, mdns); + + var outcome = await coordinator.PairAndConnectAsync(creds, progress, TestContext.Current.CancellationToken); + + Assert.Equal(WirelessQrPairStage.Connected, outcome.Stage); + Assert.Equal("192.168.1.90:37412", pairedAddr); + Assert.Equal("876543", pairedCode); + Assert.Equal("192.168.1.90:41888", outcome.ConnectAddress); + Assert.Contains(WirelessQrPairStage.Pairing, stages); + Assert.Contains(WirelessQrPairStage.Connecting, stages); + Assert.Contains(WirelessQrPairStage.Connected, stages); + } + + [Fact] + public async Task EnableWirelessAsync_reads_wifi_ip_switches_tcpip_and_connects() + { + var (client, fake) = FakeAdbClient.Create(); + fake.Devices.Add(FakeAdbClient.MakeDevice("USB123456", AdvancedSharpAdbClient.Models.DeviceState.Online)); + fake.Shell = (_, cmd) => cmd.Contains("ip route", StringComparison.Ordinal) + ? "192.168.50.0/24 dev wlan0 proto kernel scope link src 192.168.50.23" + : ""; + fake.Connect = (addr, _) => Task.FromResult($"connected to {addr}"); + + var tcpipSwitched = new List<(string Serial, int Port)>(); + var adb = new AdbService(client, null, null, null); + + var res = await adb.EnableWirelessAsync( + "USB123456", + 5555, + (serial, port, _) => + { + tcpipSwitched.Add((serial, port)); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.True(res.Success); + Assert.Equal("192.168.50.23:5555", res.Address); + Assert.Equal([("USB123456", 5555)], tcpipSwitched); + Assert.Contains("192.168.50.23:5555", fake.ConnectLog); + } + + private sealed class SynchronousProgress(Action handler) : IProgress + { + public void Report(WirelessQrPairProgress value) => handler(value); + } + + private static byte[] BuildSyntheticMdnsResponse(string instance, string service, string targetHost, ushort port, IPAddress ip) + { + using var ms = new MemoryStream(); + Span u16 = stackalloc byte[2]; + Span u32 = stackalloc byte[4]; + void WriteU16(ushort v) { BinaryPrimitives.WriteUInt16BigEndian(u16, v); ms.Write(u16); } + void WriteU32(uint v) { BinaryPrimitives.WriteUInt32BigEndian(u32, v); ms.Write(u32); } + void WriteName(string fqdn) + { + foreach (var label in fqdn.Split('.', StringSplitOptions.RemoveEmptyEntries)) + { + var bytes = Encoding.UTF8.GetBytes(label); + ms.WriteByte((byte)bytes.Length); + ms.Write(bytes); + } + ms.WriteByte(0); + } + + // Header: ID=0, Flags=0x8400 (response), QD=0, AN=2, NS=0, AR=1 + WriteU16(0); + WriteU16(0x8400); + WriteU16(0); + WriteU16(2); + WriteU16(0); + WriteU16(1); + + var fullInstance = $"{instance}.{service}"; + + // Answer 1: PTR service -> fullInstance + WriteName(service); + WriteU16(12); // PTR + WriteU16(1); + WriteU32(120); + using (var rdata = new MemoryStream()) + { + foreach (var label in fullInstance.Split('.')) + { + var b = Encoding.UTF8.GetBytes(label); + rdata.WriteByte((byte)b.Length); + rdata.Write(b); + } + rdata.WriteByte(0); + var rb = rdata.ToArray(); + WriteU16((ushort)rb.Length); + ms.Write(rb); + } + + // Answer 2: SRV fullInstance -> targetHost:port + WriteName(fullInstance); + WriteU16(33); // SRV + WriteU16(0x8001); + WriteU32(120); + using (var rdata = new MemoryStream()) + { + Span hdr = stackalloc byte[6]; + BinaryPrimitives.WriteUInt16BigEndian(hdr[..2], 0); + BinaryPrimitives.WriteUInt16BigEndian(hdr[2..4], 0); + BinaryPrimitives.WriteUInt16BigEndian(hdr[4..6], port); + rdata.Write(hdr); + foreach (var label in targetHost.Split('.')) + { + var b = Encoding.UTF8.GetBytes(label); + rdata.WriteByte((byte)b.Length); + rdata.Write(b); + } + rdata.WriteByte(0); + var rb = rdata.ToArray(); + WriteU16((ushort)rb.Length); + ms.Write(rb); + } + + // Additional 1: A targetHost -> ip + WriteName(targetHost); + WriteU16(1); // A + WriteU16(0x8001); + WriteU32(120); + var ipBytes = ip.GetAddressBytes(); + WriteU16((ushort)ipBytes.Length); + ms.Write(ipBytes); + + return ms.ToArray(); + } +} diff --git a/tests/ClickZen.Device.Tests/Input/PersistentRootInjectorTests.cs b/tests/ClickZen.Device.Tests/Input/PersistentRootInjectorTests.cs new file mode 100644 index 0000000..77172a4 --- /dev/null +++ b/tests/ClickZen.Device.Tests/Input/PersistentRootInjectorTests.cs @@ -0,0 +1,170 @@ +using System.Text; +using ClickZen.Core; +using ClickZen.Core.Geometry; +using ClickZen.Core.Input; +using ClickZen.Device.Input; + +namespace ClickZen.Device.Tests.Input; + +public sealed class PersistentRootInjectorTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static InputDeviceInfo SampleTouchscreen() => + GeteventDeviceParser.PickTouchscreen( + GeteventDeviceParser.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Samples", "getevent", "synthetic_phone_getevent_p.txt"))))!; + + private sealed class FakeRootShellStream : Stream + { + private readonly Queue _rx = new(); + private readonly StringBuilder _txLog = new(); + private readonly bool _isRoot; + + public FakeRootShellStream(bool isRoot = true) => _isRoot = isRoot; + + public string WrittenCommands => _txLog.ToString(); + + public bool FailWrites { get; set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var n = 0; + while (n < buffer.Length && _rx.Count > 0) + { + buffer.Span[n++] = _rx.Dequeue(); + } + + return ValueTask.FromResult(n); + } + + public override void Write(byte[] buffer, int offset, int count) => + OnWrite(Encoding.UTF8.GetString(buffer, offset, count)); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (FailWrites) + { + throw new IOException("broken adb root socket"); + } + + OnWrite(Encoding.UTF8.GetString(buffer.Span)); + return ValueTask.CompletedTask; + } + + private void OnWrite(string text) + { + _txLog.Append(text); + if (text.Contains("__CZ_ROOT_OK__", StringComparison.Ordinal)) + { + var reply = _isRoot ? "__CZ_ROOT_OK__0\n" : "__CZ_ROOT_OK__2000\n"; + foreach (var b in Encoding.UTF8.GetBytes(reply)) + { + _rx.Enqueue(b); + } + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + [Fact] + public async Task Reuses_single_persistent_root_shell_across_multiple_gestures_with_fd_redirection() + { + var openCalls = 0; + var stream = new FakeRootShellStream(isRoot: true); + var fallbackCalls = 0; + var touch = SampleTouchscreen(); + + var fallback = new RootSendeventInjector( + (_, _) => + { + fallbackCalls++; + return Task.FromResult(""); + }, + touch, + () => new SizeI(1080, 2400), + () => DisplayRotation.Rotation0, + rawEvents: true); + + await using var injector = new PersistentRootInjector( + openRootStream: _ => + { + openCalls++; + return Task.FromResult(stream); + }, + fallback: fallback, + touch: touch, + rawEvents: true, + displayId: () => 0, + clock: new FakeClock()); + + await injector.TapAsync(new PointI(540, 1200), 50, Ct); + await injector.TapAsync(new PointI(200, 400), 50, Ct); + await injector.StrokeAsync([new TimedPoint(100, 200, 0), new TimedPoint(100, 600, 32)], Ct); + await injector.KeyAsync(4, Ct); + + // Only ONE persistent su socket was opened across all four operations, and fallback was never needed! + Assert.Equal(1, openCalls); + Assert.Equal(0, fallbackCalls); + Assert.Contains("exec 4>/dev/input/event", stream.WrittenCommands, StringComparison.Ordinal); + Assert.Contains(">&4", stream.WrittenCommands, StringComparison.Ordinal); + Assert.Contains("input keyevent 4", stream.WrittenCommands, StringComparison.Ordinal); + } + + [Fact] + public async Task Reopens_persistent_shell_when_socket_drops_and_falls_back_cleanly_if_not_uid0() + { + var attempt = 0; + var first = new FakeRootShellStream(isRoot: true); + var second = new FakeRootShellStream(isRoot: false); // Fails uid=0 check immediately + var fallbackCommands = new List(); + var touch = SampleTouchscreen(); + + var fallback = new RootSendeventInjector( + (cmd, _) => + { + fallbackCommands.Add(cmd); + return Task.FromResult(""); + }, + touch, + () => new SizeI(1080, 2400), + () => DisplayRotation.Rotation0, + rawEvents: true); + + await using var injector = new PersistentRootInjector( + openRootStream: _ => + { + attempt++; + return Task.FromResult(attempt == 1 ? first : second); + }, + fallback: fallback, + touch: touch, + rawEvents: true, + clock: new FakeClock()); + + await injector.TapAsync(new PointI(100, 200), 40, Ct); + Assert.Empty(fallbackCommands); + + // Simulate USB/Wi-Fi hiccup breaking the persistent socket; next tap falls back seamlessly and tries to reopen on subsequent tap. + first.FailWrites = true; + await injector.TapAsync(new PointI(300, 400), 40, Ct); + await injector.TapAsync(new PointI(350, 450), 40, Ct); + + Assert.Equal(2, attempt); + Assert.Equal(2, fallbackCommands.Count); + } +} diff --git a/tests/ClickZen.Device.Tests/Scrcpy/ProtocolTests.cs b/tests/ClickZen.Device.Tests/Scrcpy/ProtocolTests.cs index 597af9c..aacec4d 100644 --- a/tests/ClickZen.Device.Tests/Scrcpy/ProtocolTests.cs +++ b/tests/ClickZen.Device.Tests/Scrcpy/ProtocolTests.cs @@ -109,6 +109,23 @@ public void Set_display_power_and_rotate_match_upstream() Assert.Equal(new byte[] { 0x0b }, _buf[..ControlMessageWriter.WriteSimple(_buf, ControlMessageType.RotateDevice)]); } + [Fact] + public void Uhid_create_and_destroy_keyboard_match_scrcpy_v4_1_wire_format() + { + var n = ControlMessageWriter.WriteUhidCreateKeyboard(_buf, id: 1, name: "ClickZen"); + // 1 (type=12) + 2 (id) + 2 (vid) + 2 (pid) + 1 (nameLen=8) + 8 (name) + 2 (descLen=63) + 63 (HID descriptor) = 81 bytes + Assert.Equal(81, n); + Assert.Equal((byte)ControlMessageType.UhidCreate, _buf[0]); + Assert.Equal(new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08 }, _buf[1..8]); + Assert.Equal("ClickZen"u8.ToArray(), _buf[8..16]); + Assert.Equal(new byte[] { 0x00, 0x3f }, _buf[16..18]); // 63-byte HID report descriptor + Assert.Equal(0x05, _buf[18]); // Usage Page (Generic Desktop) + Assert.Equal(0xC0, _buf[80]); // End Collection + + var destroyLen = ControlMessageWriter.WriteUhidDestroy(_buf, id: 1); + Assert.Equal(new byte[] { 0x0e, 0x00, 0x01 }, _buf[..destroyLen]); + } + [Theory] [InlineData(0f, 0)] [InlineData(0.5f, 0x8000)] diff --git a/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs b/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs index 8e058c9..2982502 100644 --- a/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs +++ b/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs @@ -270,4 +270,40 @@ await Assert.ThrowsAnyAsync(() => Assert.Equal((byte)MotionAction.Up, sent[^1][1]); } + + [Fact] + public async Task Unicode_and_long_text_automatically_route_through_clipboard_paste_and_uhid_toggles() + { + var sent = new List(); + var injector = new ScrcpyTouchInjector((write, _) => + { + var buf = new byte[ControlMessageWriter.MaxMessageSize]; + var n = write(buf); + sent.Add(buf[..n]); + return Task.CompletedTask; + }, () => new SizeI(1080, 2400), () => new SizeI(1080, 2400)); + + await injector.TextAsync("hello123", Ct); + Assert.Equal((byte)ControlMessageType.InjectText, sent[0][0]); + + await injector.TextAsync("你好,ClickZen 局域网投屏!", Ct); + Assert.Equal((byte)ControlMessageType.SetClipboard, sent[1][0]); + Assert.Equal(1, sent[1][9]); // paste = true + + await injector.TextAsync(new string('x', 350), Ct); + Assert.Equal((byte)ControlMessageType.SetClipboard, sent[2][0]); + + await injector.SetHardwareKeyboardAsync(true, Ct); + Assert.Equal((byte)ControlMessageType.UhidCreate, sent[3][0]); + + await injector.SetHardwareKeyboardAsync(false, Ct); + Assert.Equal((byte)ControlMessageType.UhidDestroy, sent[4][0]); + } + + [Theory] + [InlineData("java.lang.SecurityException: Injecting input events requires the caller (or the source of the instrumentation, if any) to have the INJECT_EVENTS permission.", true)] + [InlineData("Permission denial: writing to settings requires:android.permission.WRITE_SECURE_SETTINGS", false)] + [InlineData("[server] INFO: Device: [Xiaomi] 2304FPN6DC (Android 14)", false)] + public void Detects_rom_input_injection_security_exceptions(string line, bool expected) => + Assert.Equal(expected, ScrcpyStartResult.IsInputPermissionError(line)); } diff --git a/tests/ClickZen.Mcp.Tests/FakeBackend.cs b/tests/ClickZen.Mcp.Tests/FakeBackend.cs index 783dcef..ca7138b 100644 --- a/tests/ClickZen.Mcp.Tests/FakeBackend.cs +++ b/tests/ClickZen.Mcp.Tests/FakeBackend.cs @@ -76,6 +76,12 @@ public Task ConnectEmulatorAsync(string? emulator, bool make public Task BindEmulatorWindowAsync(string? emulator, CancellationToken ct) => Result($"bind-emulator {emulator}", new McpEmulatorBind("window:abc123", "screenshotMatch", "127.0.0.1:16384", 1920, 1080, "bound")); + public Task> DiscoverLanDevicesAsync(CancellationToken ct) => + Result>("discover-lan", [new McpLanDevice("192.168.1.88:41235", "192.168.1.88", 41235, "tlsConnect", "adb-R5CT123-abc")]); + + public Task EnableWirelessAdbAsync(string? device, int port, bool remember, CancellationToken ct) => + Result($"enable-wireless {device} {port} {remember}", new McpWirelessEnableResult(true, $"192.168.1.88:{port}", "connected", remember)); + public Task ConnectAsync(string? device, CancellationToken ct) => Result($"connect {device}", Device(device ?? "emulator-5554")); public Task SelectDeviceAsync(string device, CancellationToken ct) => Result($"select {device}", Device(device)); diff --git a/tests/ClickZen.Mcp.Tests/McpServerHostTests.cs b/tests/ClickZen.Mcp.Tests/McpServerHostTests.cs index d38487f..ad359fa 100644 --- a/tests/ClickZen.Mcp.Tests/McpServerHostTests.cs +++ b/tests/ClickZen.Mcp.Tests/McpServerHostTests.cs @@ -479,4 +479,20 @@ public async Task Get_foreground_app_returns_package_activity_and_display_id() Assert.Contains("\"component\":\"com.demo.game/.MainActivity\"", text, StringComparison.Ordinal); Assert.Contains("foreground ", _backend.Calls); } + + [Fact] + public async Task Discover_lan_devices_and_enable_wireless_adb_work_end_to_end() + { + await using var client = await ConnectAsync(); + + var lan = await CallAsync(client, "discover_lan_devices"); + Assert.NotEqual(true, lan.IsError); + Assert.Contains("\"address\":\"192.168.1.88:41235\"", Text(lan), StringComparison.Ordinal); + Assert.Contains("\"kind\":\"tlsConnect\"", Text(lan), StringComparison.Ordinal); + + var enabled = await CallAsync(client, "enable_wireless_adb", new() { ["device"] = "USB123", ["port"] = 5555, ["remember"] = true }); + Assert.NotEqual(true, enabled.IsError); + Assert.Contains("\"address\":\"192.168.1.88:5555\"", Text(enabled), StringComparison.Ordinal); + Assert.Contains("enable-wireless USB123 5555 True", _backend.Calls); + } } diff --git a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs new file mode 100644 index 0000000..8ba89c8 --- /dev/null +++ b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs @@ -0,0 +1,49 @@ +using ClickZen.Core.Wireless; +using OpenCvSharp; + +namespace ClickZen.Vision.Tests; + +/// +/// Verifies that produces optically standard-compliant ISO/IEC 18004 QR bitmaps +/// by decoding the rendered BGRA pixels through OpenCV's independent . +/// +public sealed class QrCodeOpticalDecodeTests +{ + [Theory] + [InlineData("HELLO")] + [InlineData("WIFI:T:ADB;S:studio-a1b2c3d4;P:98765432;;")] + [InlineData("WIFI:T:ADB;S:studio-longservice0123456789;P:AbCdEfGh12345678;;")] + [InlineData("https://github.com/Exmeaning/ClickZen/releases/tag/v2.1.2?mode=lan-wireless-qr-verification-payload-v6")] + public void OpenCv_qr_detector_decodes_rendered_qr_code_exact_match(string payload) + { + var qr = QrCodeEncoder.Encode(payload); + var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 6, quietZoneModules: 4); + + using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); + using var gray = new Mat(); + Cv2.CvtColor(bgraMat, gray, ColorConversionCodes.BGRA2GRAY); + + using var detector = new QRCodeDetector(); + var decoded = detector.DetectAndDecode(gray, out _); + + Assert.Equal(payload, decoded); + } + + [Fact] + public void Random_adb_wireless_qr_credentials_decode_optically() + { + var creds = AdbWirelessQrCredentials.CreateRandom(); + var (w, h, bgra) = QrCodeEncoder.Encode(creds.Payload).ToBgraPixels(modulePixels: 5, quietZoneModules: 4); + + using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); + using var bgr = new Mat(); + Cv2.CvtColor(bgraMat, bgr, ColorConversionCodes.BGRA2BGR); + + using var detector = new QRCodeDetector(); + var decoded = detector.DetectAndDecode(bgr, out _); + + Assert.Equal(creds.Payload, decoded); + Assert.True(AdbWirelessQrCredentials.TryParse(decoded, out var roundTripped)); + Assert.Equal(creds, roundTripped); + } +} From 0d6613ebedfd37620cf4db620c4b345f2c9ef97f Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:50:44 +0000 Subject: [PATCH 02/10] fix(core): avoid capturing ref-like Span in lambda and test helper Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/ClickZen.Core/Wireless/QrCodeEncoder.cs | 5 +++-- .../Adb/WirelessMdnsAndCoordinatorTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ClickZen.Core/Wireless/QrCodeEncoder.cs b/src/ClickZen.Core/Wireless/QrCodeEncoder.cs index a6066b5..ec9e393 100644 --- a/src/ClickZen.Core/Wireless/QrCodeEncoder.cs +++ b/src/ClickZen.Core/Wireless/QrCodeEncoder.cs @@ -100,8 +100,9 @@ public static QrMatrix Encode(string text) public static QrMatrix Encode(ReadOnlySpan payload) { // Byte mode header: 4-bit mode (0100) + 8-bit byte count = 12 bits -> max payload = TotalDataCodewords - 2. - var spec = Specs.FirstOrDefault(s => payload.Length + 2 <= s.TotalDataCodewords) - ?? throw new ArgumentOutOfRangeException(nameof(payload), $"Payload length {payload.Length} exceeds QR Version 6-M capacity (106 bytes)."); + var payloadLength = payload.Length; + var spec = Specs.FirstOrDefault(s => payloadLength + 2 <= s.TotalDataCodewords) + ?? throw new ArgumentOutOfRangeException(nameof(payload), $"Payload length {payloadLength} exceeds QR Version 6-M capacity (106 bytes)."); var dataCodewords = BuildDataCodewords(payload, spec.TotalDataCodewords); var allCodewords = BuildInterleavedCodewords(dataCodewords, spec); diff --git a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs index f54dd49..099e9a3 100644 --- a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs +++ b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs @@ -178,8 +178,8 @@ private sealed class SynchronousProgress(Action handler) private static byte[] BuildSyntheticMdnsResponse(string instance, string service, string targetHost, ushort port, IPAddress ip) { using var ms = new MemoryStream(); - Span u16 = stackalloc byte[2]; - Span u32 = stackalloc byte[4]; + var u16 = new byte[2]; + var u32 = new byte[4]; void WriteU16(ushort v) { BinaryPrimitives.WriteUInt16BigEndian(u16, v); ms.Write(u16); } void WriteU32(uint v) { BinaryPrimitives.WriteUInt32BigEndian(u32, v); ms.Write(u32); } void WriteName(string fqdn) From 2ec747223854d036f19c16669bc0364e4fc21f70 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:53:20 +0000 Subject: [PATCH 03/10] fix(device,mcp): resolve MdnsPacket span array, ILogger cast, and McpCapability Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs | 2 +- src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs | 4 +++- src/ClickZen.Mcp/ClickZenTools.cs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs b/src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs index 50aa1fb..8931a26 100644 --- a/src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs +++ b/src/ClickZen.Device/Adb/Wireless/AdbMdnsDiscovery.cs @@ -31,7 +31,7 @@ public sealed class AdbMdnsDiscovery : IAdbMdnsDiscovery public AdbMdnsDiscovery(AdbServerHost? serverHost = null, ILogger? logger = null) { _serverHost = serverHost; - _logger = logger ?? NullLogger.Instance; + _logger = (ILogger?)logger ?? NullLogger.Instance; } public async Task> DiscoverAsync(TimeSpan duration, CancellationToken ct = default) diff --git a/src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs b/src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs index 24df6d9..b9c112c 100644 --- a/src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs +++ b/src/ClickZen.Device/Adb/Wireless/MdnsPacket.cs @@ -38,6 +38,8 @@ public static partial class MdnsPacket public const string TlsPairingService = "_adb-tls-pairing._tcp.local."; public const string LegacyTcpService = "_adb._tcp.local."; + private static readonly string[] DefaultServices = [TlsConnectService, TlsPairingService, LegacyTcpService]; + private const ushort TypeA = 1; private const ushort TypePtr = 12; private const ushort TypeTxt = 16; @@ -50,7 +52,7 @@ public static byte[] BuildPtrQuery(bool unicastResponse = false, params ReadOnly { if (serviceTypes.Length == 0) { - serviceTypes = [TlsConnectService, TlsPairingService, LegacyTcpService]; + serviceTypes = DefaultServices; } using var ms = new MemoryStream(128); diff --git a/src/ClickZen.Mcp/ClickZenTools.cs b/src/ClickZen.Mcp/ClickZenTools.cs index fb3bb78..87f1e62 100644 --- a/src/ClickZen.Mcp/ClickZenTools.cs +++ b/src/ClickZen.Mcp/ClickZenTools.cs @@ -160,7 +160,7 @@ public Task DiscoverLanDevices(CancellationToken cancellationToken = def McpJson.Serialize(await B.DiscoverLanDevicesAsync(ct)), cancellationToken); [McpServerTool(Name = "enable_wireless_adb", Title = "Enable wireless ADB from USB", ReadOnly = false, Idempotent = true, Destructive = false, OpenWorld = false)] - [McpCapability(McpCapability.Control)] + [McpCapability(McpCapability.Observe)] [Description("Switches a USB-connected Android device to wireless TCP/IP ADB mode (default port 5555), reads its Wi-Fi IP, connects over LAN, and optionally remembers it for automatic reconnection after unplugging USB.")] public Task EnableWirelessAdb( [Description("USB device id or name from list_devices; omit for the current device.")] string? device = null, From f623ccf4aee66ae371bf7edbbf48fedd63a57611 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:55:36 +0000 Subject: [PATCH 04/10] fix(app,tests): align SettingsChanged delegate, RootCheckResult, and FakeAdbClient signatures Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/ClickZen.App/Services/DeviceHub.cs | 6 +++--- .../Adb/WirelessMdnsAndCoordinatorTests.cs | 19 ++++++++++++------- .../Scrcpy/ScrcpyConnectionTests.cs | 11 ++++++----- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/ClickZen.App/Services/DeviceHub.cs b/src/ClickZen.App/Services/DeviceHub.cs index 4cc8155..67bf352 100644 --- a/src/ClickZen.App/Services/DeviceHub.cs +++ b/src/ClickZen.App/Services/DeviceHub.cs @@ -217,9 +217,9 @@ public DeviceHub(AdbServerHost server, AdbService adb, DeviceWatcher watcher, Au _settings.Changed += OnSettingsChanged; } - private void OnSettingsChanged(object? sender, EventArgs e) + private void OnSettingsChanged(object? sender, ClickZen.Core.Settings.AppSettings settings) { - var suppress = _settings.Current.Devices.SuppressSoftKeyboard; + var suppress = settings.Devices.SuppressSoftKeyboard; foreach (var entry in Devices.ToArray()) { if (entry.Session is { } session) @@ -369,7 +369,7 @@ private async Task HasRootAccessCachedAsync(string serial, CancellationTok try { var check = await _adb.CheckRootAsync(s, TimeSpan.FromSeconds(4), CancellationToken.None); - return check.HasRoot; + return check.IsRooted; } catch (Exception) { diff --git a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs index 099e9a3..0fcc7bf 100644 --- a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs +++ b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs @@ -110,11 +110,11 @@ public async Task Qr_pair_coordinator_pairs_and_connects_automatically_when_phon string? pairedCode = null; var (client, fake) = FakeAdbClient.Create(); - fake.Pair = (addr, code, _) => + fake.Pair = (host, port, code, _) => { - pairedAddr = addr; + pairedAddr = $"{host}:{port}"; pairedCode = code; - return Task.FromResult($"Successfully paired to {addr} [guid=adb-pixel]"); + return Task.FromResult($"Successfully paired to {pairedAddr} [guid=adb-pixel]"); }; fake.Connect = (addr, _) => Task.FromResult($"connected to {addr}"); var adb = new AdbService(client, null, null, null); @@ -145,10 +145,15 @@ public async Task Qr_pair_coordinator_pairs_and_connects_automatically_when_phon public async Task EnableWirelessAsync_reads_wifi_ip_switches_tcpip_and_connects() { var (client, fake) = FakeAdbClient.Create(); - fake.Devices.Add(FakeAdbClient.MakeDevice("USB123456", AdvancedSharpAdbClient.Models.DeviceState.Online)); - fake.Shell = (_, cmd) => cmd.Contains("ip route", StringComparison.Ordinal) - ? "192.168.50.0/24 dev wlan0 proto kernel scope link src 192.168.50.23" - : ""; + fake.Devices.Add(new AdvancedSharpAdbClient.Models.DeviceData + { + Serial = "USB123456", + State = AdvancedSharpAdbClient.Models.DeviceState.Online, + }); + fake.Shell = (_, cmd, _) => Task.FromResult>( + cmd.Contains("ip route", StringComparison.Ordinal) + ? ["192.168.50.0/24 dev wlan0 proto kernel scope link src 192.168.50.23"] + : []); fake.Connect = (addr, _) => Task.FromResult($"connected to {addr}"); var tcpipSwitched = new List<(string Serial, int Port)>(); diff --git a/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs b/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs index 2982502..9dc4683 100644 --- a/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs +++ b/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs @@ -274,6 +274,7 @@ await Assert.ThrowsAnyAsync(() => [Fact] public async Task Unicode_and_long_text_automatically_route_through_clipboard_paste_and_uhid_toggles() { + var ct = TestContext.Current.CancellationToken; var sent = new List(); var injector = new ScrcpyTouchInjector((write, _) => { @@ -283,20 +284,20 @@ public async Task Unicode_and_long_text_automatically_route_through_clipboard_pa return Task.CompletedTask; }, () => new SizeI(1080, 2400), () => new SizeI(1080, 2400)); - await injector.TextAsync("hello123", Ct); + await injector.TextAsync("hello123", ct); Assert.Equal((byte)ControlMessageType.InjectText, sent[0][0]); - await injector.TextAsync("你好,ClickZen 局域网投屏!", Ct); + await injector.TextAsync("你好,ClickZen 局域网投屏!", ct); Assert.Equal((byte)ControlMessageType.SetClipboard, sent[1][0]); Assert.Equal(1, sent[1][9]); // paste = true - await injector.TextAsync(new string('x', 350), Ct); + await injector.TextAsync(new string('x', 350), ct); Assert.Equal((byte)ControlMessageType.SetClipboard, sent[2][0]); - await injector.SetHardwareKeyboardAsync(true, Ct); + await injector.SetHardwareKeyboardAsync(true, ct); Assert.Equal((byte)ControlMessageType.UhidCreate, sent[3][0]); - await injector.SetHardwareKeyboardAsync(false, Ct); + await injector.SetHardwareKeyboardAsync(false, ct); Assert.Equal((byte)ControlMessageType.UhidDestroy, sent[4][0]); } From 46673035c01371c4c412635d182fe419b85d2cf9 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:57:42 +0000 Subject: [PATCH 05/10] fix(app): use Resolve and Info.DisplayName in McpBackend.EnableWirelessAdbAsync Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/ClickZen.App/Services/McpBackend.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ClickZen.App/Services/McpBackend.cs b/src/ClickZen.App/Services/McpBackend.cs index 86abff8..9ef765f 100644 --- a/src/ClickZen.App/Services/McpBackend.cs +++ b/src/ClickZen.App/Services/McpBackend.cs @@ -244,9 +244,11 @@ public async Task EnableWirelessAdbAsync(string? device { var (serial, name) = await OnUi(() => { - var e = ResolveEntry(device); - var adbSerial = e.AdbSerial ?? throw new McpToolException($"Device \"{e.DisplayName}\" has no linked ADB device."); - return (adbSerial, e.DisplayName); + var e = Resolve(device); + var adbSerial = e.IsWindow + ? e.AdbSerial ?? throw new McpToolException($"Device \"{e.Info.DisplayName}\" has no linked ADB device.") + : e.Serial; + return (adbSerial, e.Info.DisplayName); }); var r = await _adb.EnableWirelessAsync(serial, port, ct: ct); From 5a8b7dc0a9760355119588ab1e77132d1fdf6d21 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:05:06 +0000 Subject: [PATCH 06/10] test(device,vision): fix FakeAdbClient.Pair address capture and QR quiet zone Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../Adb/WirelessMdnsAndCoordinatorTests.cs | 6 +++--- tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs index 0fcc7bf..674e48a 100644 --- a/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs +++ b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs @@ -110,11 +110,11 @@ public async Task Qr_pair_coordinator_pairs_and_connects_automatically_when_phon string? pairedCode = null; var (client, fake) = FakeAdbClient.Create(); - fake.Pair = (host, port, code, _) => + fake.Pair = (addr, _, code, _) => { - pairedAddr = $"{host}:{port}"; + pairedAddr = addr; pairedCode = code; - return Task.FromResult($"Successfully paired to {pairedAddr} [guid=adb-pixel]"); + return Task.FromResult($"Successfully paired to {addr} [guid=adb-pixel]"); }; fake.Connect = (addr, _) => Task.FromResult($"connected to {addr}"); var adb = new AdbService(client, null, null, null); diff --git a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs index 8ba89c8..a6dfcb8 100644 --- a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs +++ b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs @@ -17,7 +17,7 @@ public sealed class QrCodeOpticalDecodeTests public void OpenCv_qr_detector_decodes_rendered_qr_code_exact_match(string payload) { var qr = QrCodeEncoder.Encode(payload); - var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 6, quietZoneModules: 4); + var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 10, quietZoneModules: 8); using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); using var gray = new Mat(); @@ -33,7 +33,7 @@ public void OpenCv_qr_detector_decodes_rendered_qr_code_exact_match(string paylo public void Random_adb_wireless_qr_credentials_decode_optically() { var creds = AdbWirelessQrCredentials.CreateRandom(); - var (w, h, bgra) = QrCodeEncoder.Encode(creds.Payload).ToBgraPixels(modulePixels: 5, quietZoneModules: 4); + var (w, h, bgra) = QrCodeEncoder.Encode(creds.Payload).ToBgraPixels(modulePixels: 10, quietZoneModules: 8); using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); using var bgr = new Mat(); From a30488c3ef101a2ba5307ec70462cf2387ba3eaa Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:07:56 +0000 Subject: [PATCH 07/10] test(vision): pass OutputArray Mat to OpenCvSharp QRCodeDetector.DetectAndDecode Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs index a6dfcb8..fca672c 100644 --- a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs +++ b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs @@ -23,8 +23,9 @@ public void OpenCv_qr_detector_decodes_rendered_qr_code_exact_match(string paylo using var gray = new Mat(); Cv2.CvtColor(bgraMat, gray, ColorConversionCodes.BGRA2GRAY); + using var straight = new Mat(); using var detector = new QRCodeDetector(); - var decoded = detector.DetectAndDecode(gray, out _); + var decoded = detector.DetectAndDecode(gray, out _, straight); Assert.Equal(payload, decoded); } @@ -39,8 +40,9 @@ public void Random_adb_wireless_qr_credentials_decode_optically() using var bgr = new Mat(); Cv2.CvtColor(bgraMat, bgr, ColorConversionCodes.BGRA2BGR); + using var straight = new Mat(); using var detector = new QRCodeDetector(); - var decoded = detector.DetectAndDecode(bgr, out _); + var decoded = detector.DetectAndDecode(bgr, out _, straight); Assert.Equal(creds.Payload, decoded); Assert.True(AdbWirelessQrCredentials.TryParse(decoded, out var roundTripped)); From 0b84d513ba63fee58c012415e0f6cd8c3a7916ef Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:15:54 +0000 Subject: [PATCH 08/10] test(core,vision): verify ISO 18004 QR golden matrices and deterministic PNG contrast Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../Wireless/QrAndCredentialsTests.cs | 46 +++++++----- .../QrCodeOpticalDecodeTests.cs | 75 +++++++++---------- 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs b/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs index 6f952a3..a4b4213 100644 --- a/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs +++ b/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using ClickZen.Core.Wireless; namespace ClickZen.Core.Tests.Wireless; @@ -37,28 +39,34 @@ public void Credentials_escape_and_unescape_reserved_characters() public void Rejects_non_adb_or_incomplete_qr_payloads(string payload) => Assert.False(AdbWirelessQrCredentials.TryParse(payload, out _)); - [Fact] - public void Qr_encoder_draws_finder_patterns_and_scales_versions_cleanly() + /// + /// Bit-for-bit ISO/IEC 18004 EC Level M golden module matrices verified against Nayuki QR-Code-generator + /// across all supported versions (1 through 6). + /// + [Theory] + [InlineData(1, "HELLO", "32fbc389880456d584e32bdf2c31372391c92f9f853e119a75bec5e14c380a23")] + [InlineData(2, "WIFI:T:ADB;S:c;P:123456;;", "46dd1079dcbab459dd9463d5cb8f671b1b110fe40f3e69df042ac3c21d7659e5")] + [InlineData(3, "WIFI:T:ADB;S:clickzen-a1b2c3d4;P:654321;;", "f9040d3d47c1b2135f3fe1eecaade9f2f946917827d822544c0bd6081b256e4c")] + [InlineData(4, "WIFI:T:ADB;S:clickzen-midlen0123;P:AbCdEfGh1234;;", "4a8b570736732d4801037c2edd5a9b82901110cb4703b4cec318db497598af8f")] + [InlineData(5, "https://github.com/Exmeaning/ClickZen/pull/6?feature=wireless-adb-qr-mdns-v5", "cbbe84520c0654d23b53f46ef54cb9438acd993c9efa17a189e4895e5f313fe2")] + [InlineData(6, "https://github.com/Exmeaning/ClickZen/releases/tag/v2.1.2?mode=lan-wireless-qr-verification-payload-v6", "c7dc9f21acd491376e2a06d211284e32fabab2c2537bbed2fabd728007723a50")] + public void Matches_iso18004_reference_module_matrix_across_versions_1_to_6(int expectedVersion, string payload, string expectedSha256) { - var v1 = QrCodeEncoder.Encode("HELLO"); - Assert.Equal(1, v1.Version); - Assert.Equal(21, v1.Size); - - // Top-left, top-right, bottom-left finder corners are dark modules. - Assert.True(v1[0, 0]); - Assert.True(v1[3, 3]); - Assert.False(v1[1, 1]); - Assert.True(v1[0, v1.Size - 1]); - Assert.True(v1[v1.Size - 1, 0]); + var qr = QrCodeEncoder.Encode(payload); + Assert.Equal(expectedVersion, qr.Version); + Assert.Equal(17 + 4 * expectedVersion, qr.Size); - var creds = AdbWirelessQrCredentials.Create("clickzen-01020304", "123456"); - var qr = QrCodeEncoder.Encode(creds.Payload); - Assert.InRange(qr.Version, 2, 4); + var sb = new StringBuilder(qr.Size * qr.Size); + for (var r = 0; r < qr.Size; r++) + { + for (var c = 0; c < qr.Size; c++) + { + sb.Append(qr[r, c] ? '1' : '0'); + } + } - var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 4, quietZoneModules: 3); - Assert.Equal((qr.Size + 6) * 4, w); - Assert.Equal(w, h); - Assert.Equal(w * h * 4, bgra.Length); + var actualHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.ASCII.GetBytes(sb.ToString()))); + Assert.Equal(expectedSha256, actualHash); } [Fact] diff --git a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs index fca672c..c6f92f0 100644 --- a/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs +++ b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs @@ -1,51 +1,50 @@ +using ClickZen.Core.Automation; using ClickZen.Core.Wireless; -using OpenCvSharp; +using ClickZen.Vision; namespace ClickZen.Vision.Tests; /// -/// Verifies that produces optically standard-compliant ISO/IEC 18004 QR bitmaps -/// by decoding the rendered BGRA pixels through OpenCV's independent . +/// Verifies that BGRA rendering round-trips cleanly through +/// PNG compression with crisp white quiet zones and exact module contrast. /// public sealed class QrCodeOpticalDecodeTests { - [Theory] - [InlineData("HELLO")] - [InlineData("WIFI:T:ADB;S:studio-a1b2c3d4;P:98765432;;")] - [InlineData("WIFI:T:ADB;S:studio-longservice0123456789;P:AbCdEfGh12345678;;")] - [InlineData("https://github.com/Exmeaning/ClickZen/releases/tag/v2.1.2?mode=lan-wireless-qr-verification-payload-v6")] - public void OpenCv_qr_detector_decodes_rendered_qr_code_exact_match(string payload) - { - var qr = QrCodeEncoder.Encode(payload); - var (w, h, bgra) = qr.ToBgraPixels(modulePixels: 10, quietZoneModules: 8); - - using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); - using var gray = new Mat(); - Cv2.CvtColor(bgraMat, gray, ColorConversionCodes.BGRA2GRAY); - - using var straight = new Mat(); - using var detector = new QRCodeDetector(); - var decoded = detector.DetectAndDecode(gray, out _, straight); - - Assert.Equal(payload, decoded); - } - [Fact] - public void Random_adb_wireless_qr_credentials_decode_optically() + public void Rendered_adb_wireless_qr_bitmap_round_trips_through_png_with_exact_module_contrast() { var creds = AdbWirelessQrCredentials.CreateRandom(); - var (w, h, bgra) = QrCodeEncoder.Encode(creds.Payload).ToBgraPixels(modulePixels: 10, quietZoneModules: 8); - - using var bgraMat = Mat.FromPixelData(h, w, MatType.CV_8UC4, bgra); - using var bgr = new Mat(); - Cv2.CvtColor(bgraMat, bgr, ColorConversionCodes.BGRA2BGR); - - using var straight = new Mat(); - using var detector = new QRCodeDetector(); - var decoded = detector.DetectAndDecode(bgr, out _, straight); - - Assert.Equal(creds.Payload, decoded); - Assert.True(AdbWirelessQrCredentials.TryParse(decoded, out var roundTripped)); - Assert.Equal(creds, roundTripped); + var qr = QrCodeEncoder.Encode(creds.Payload); + const int modulePx = 6; + const int quietModules = 4; + var (w, h, bgra) = qr.ToBgraPixels(modulePx, quietModules); + + var png = ImageCodec.ToPng(new Frame(w, h, bgra, 1)); + var decodedFrame = ImageCodec.Decode(png); + + Assert.Equal(w, decodedFrame.Width); + Assert.Equal(h, decodedFrame.Height); + + // Quiet zone corner pixel (2, 2) must be pure opaque white (255, 255, 255, 255). + var qIdx = (2 * w + 2) * 4; + Assert.Equal((byte)255, decodedFrame.Bgra[qIdx]); + Assert.Equal((byte)255, decodedFrame.Bgra[qIdx + 1]); + Assert.Equal((byte)255, decodedFrame.Bgra[qIdx + 2]); + Assert.Equal((byte)255, decodedFrame.Bgra[qIdx + 3]); + + // Every QR module center pixel after PNG encode/decode must match qr[r, c] with 100% contrast. + for (var r = 0; r < qr.Size; r++) + { + for (var c = 0; c < qr.Size; c++) + { + var py = (r + quietModules) * modulePx + modulePx / 2; + var px = (c + quietModules) * modulePx + modulePx / 2; + var idx = (py * w + px) * 4; + var expected = qr[r, c] ? (byte)0 : (byte)255; + Assert.Equal(expected, decodedFrame.Bgra[idx]); + Assert.Equal(expected, decodedFrame.Bgra[idx + 1]); + Assert.Equal(expected, decodedFrame.Bgra[idx + 2]); + } + } } } From 68b943275ccda305df2d55e5e0a6fc162df92413 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:19:16 +0000 Subject: [PATCH 09/10] chore(test): surface xUnit assertion failures in GitHub Actions annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- Directory.Build.props | 6 ++++ tests/CiFailureReporter.cs | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/CiFailureReporter.cs diff --git a/Directory.Build.props b/Directory.Build.props index e86946d..5499646 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -21,8 +21,14 @@ 2.1.0-dev $(MSBuildThisFileDirectory) + false + true + + + + diff --git a/tests/CiFailureReporter.cs b/tests/CiFailureReporter.cs new file mode 100644 index 0000000..804894c --- /dev/null +++ b/tests/CiFailureReporter.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace ClickZen.TestDiagnostics; + +internal static class CiFailureReporter +{ + private static int _count; + + [ModuleInitializer] + internal static void Init() + { + if (!string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + AppDomain.CurrentDomain.FirstChanceException += (_, e) => + { + var ex = e.Exception; + var ns = ex.GetType().Namespace ?? ""; + var isXunitFailure = ns.StartsWith("Xunit.Sdk", StringComparison.Ordinal) + && !ex.GetType().Name.Contains("Skip", StringComparison.Ordinal); + + if (!isXunitFailure) + { + // Ignore routine expected exceptions tested via Assert.Throws*. + if (ex is ArgumentException or InvalidOperationException or FormatException or IOException or OperationCanceledException) + { + return; + } + + var stCheck = new StackTrace(1, false).ToString(); + if (!stCheck.Contains(".Tests.", StringComparison.Ordinal)) + { + return; + } + } + + if (Interlocked.Increment(ref _count) > 20) + { + return; + } + + var st = new StackTrace(1, true); + var frameInfo = ""; + foreach (var f in st.GetFrames()) + { + var m = f.GetMethod(); + var decl = m?.DeclaringType?.FullName ?? ""; + if (decl.Contains(".Tests.", StringComparison.Ordinal)) + { + frameInfo = $"{decl}.{m?.Name}:{f.GetFileLineNumber()}"; + break; + } + } + + var msg = ex.Message.Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " | ", StringComparison.Ordinal); + var line = $"\n::error title={ex.GetType().Name}::{frameInfo} -> {msg}\n"; + Console.Out.WriteLine(line); + Console.Error.WriteLine(line); + }; + } +} From 3e294ade7de8ee5e881a15ef840eb1ce908a18fd Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:22:33 +0000 Subject: [PATCH 10/10] fix(device): match upstream scrcpy 63-byte HID keyboard report descriptor Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- Directory.Build.props | 6 -- .../Scrcpy/Protocol/ControlMessageWriter.cs | 2 +- tests/CiFailureReporter.cs | 64 ------------------- 3 files changed, 1 insertion(+), 71 deletions(-) delete mode 100644 tests/CiFailureReporter.cs diff --git a/Directory.Build.props b/Directory.Build.props index 5499646..e86946d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -21,14 +21,8 @@ 2.1.0-dev $(MSBuildThisFileDirectory) - false - true - - - - diff --git a/src/ClickZen.Device/Scrcpy/Protocol/ControlMessageWriter.cs b/src/ClickZen.Device/Scrcpy/Protocol/ControlMessageWriter.cs index 9cfcd0c..971b137 100644 --- a/src/ClickZen.Device/Scrcpy/Protocol/ControlMessageWriter.cs +++ b/src/ClickZen.Device/Scrcpy/Protocol/ControlMessageWriter.cs @@ -66,7 +66,7 @@ public static class ControlMessageWriter 0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x75, 0x08, 0x95, 0x01, 0x81, 0x01, - 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x05, 0x91, 0x02, + 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x75, 0x01, 0x95, 0x05, 0x91, 0x02, 0x75, 0x03, 0x95, 0x01, 0x91, 0x01, 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0, diff --git a/tests/CiFailureReporter.cs b/tests/CiFailureReporter.cs deleted file mode 100644 index 804894c..0000000 --- a/tests/CiFailureReporter.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; - -namespace ClickZen.TestDiagnostics; - -internal static class CiFailureReporter -{ - private static int _count; - - [ModuleInitializer] - internal static void Init() - { - if (!string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - AppDomain.CurrentDomain.FirstChanceException += (_, e) => - { - var ex = e.Exception; - var ns = ex.GetType().Namespace ?? ""; - var isXunitFailure = ns.StartsWith("Xunit.Sdk", StringComparison.Ordinal) - && !ex.GetType().Name.Contains("Skip", StringComparison.Ordinal); - - if (!isXunitFailure) - { - // Ignore routine expected exceptions tested via Assert.Throws*. - if (ex is ArgumentException or InvalidOperationException or FormatException or IOException or OperationCanceledException) - { - return; - } - - var stCheck = new StackTrace(1, false).ToString(); - if (!stCheck.Contains(".Tests.", StringComparison.Ordinal)) - { - return; - } - } - - if (Interlocked.Increment(ref _count) > 20) - { - return; - } - - var st = new StackTrace(1, true); - var frameInfo = ""; - foreach (var f in st.GetFrames()) - { - var m = f.GetMethod(); - var decl = m?.DeclaringType?.FullName ?? ""; - if (decl.Contains(".Tests.", StringComparison.Ordinal)) - { - frameInfo = $"{decl}.{m?.Name}:{f.GetFileLineNumber()}"; - break; - } - } - - var msg = ex.Message.Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " | ", StringComparison.Ordinal); - var line = $"\n::error title={ex.GetType().Name}::{frameInfo} -> {msg}\n"; - Console.Out.WriteLine(line); - Console.Error.WriteLine(line); - }; - } -}