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..67bf352 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, ClickZen.Core.Settings.AppSettings settings) + { + var suppress = settings.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.IsRooted; + } + 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..9ef765f 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,59 @@ 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 = 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); + 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..87f1e62 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.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, + [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..a4b4213 --- /dev/null +++ b/tests/ClickZen.Core.Tests/Wireless/QrAndCredentialsTests.cs @@ -0,0 +1,75 @@ +using System.Security.Cryptography; +using System.Text; +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 _)); + + /// + /// 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 qr = QrCodeEncoder.Encode(payload); + Assert.Equal(expectedVersion, qr.Version); + Assert.Equal(17 + 4 * expectedVersion, qr.Size); + + 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 actualHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.ASCII.GetBytes(sb.ToString()))); + Assert.Equal(expectedSha256, actualHash); + } + + [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..674e48a --- /dev/null +++ b/tests/ClickZen.Device.Tests/Adb/WirelessMdnsAndCoordinatorTests.cs @@ -0,0 +1,265 @@ +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(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)>(); + 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(); + 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) + { + 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..9dc4683 100644 --- a/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs +++ b/tests/ClickZen.Device.Tests/Scrcpy/ScrcpyConnectionTests.cs @@ -270,4 +270,41 @@ 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 ct = TestContext.Current.CancellationToken; + 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..c6f92f0 --- /dev/null +++ b/tests/ClickZen.Vision.Tests/QrCodeOpticalDecodeTests.cs @@ -0,0 +1,50 @@ +using ClickZen.Core.Automation; +using ClickZen.Core.Wireless; +using ClickZen.Vision; + +namespace ClickZen.Vision.Tests; + +/// +/// Verifies that BGRA rendering round-trips cleanly through +/// PNG compression with crisp white quiet zones and exact module contrast. +/// +public sealed class QrCodeOpticalDecodeTests +{ + [Fact] + public void Rendered_adb_wireless_qr_bitmap_round_trips_through_png_with_exact_module_contrast() + { + var creds = AdbWirelessQrCredentials.CreateRandom(); + 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]); + } + } + } +}