From a2637e5d2765aafdc990539e95480d7b7ce0f90e Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 18:29:14 +0000 Subject: [PATCH 1/3] CI: run the test projects one at a time With all six test projects in OpenIPC.Viewer.Desktop.slnx, dotnet test ran them side by side, and on windows-latest five ONVIF tests in Devices.Tests timed out (TaskCanceledException after 17 s to 1 min 51 s); Linux and macOS passed, and so did Windows on every earlier run with three projects. Devices.Tests' stub cameras listen on HttpListener (http.sys on Windows) and Infrastructure.Tests opens loopback TcpListeners, both on ports picked by the OS; most likely an ONVIF request reached the other project's listener and got no answer. -m:1 runs one test project at a time; locally all six take ~9 s in a row. Refs #100 --- .github/workflows/build.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e8f0d7..262e606 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -90,7 +90,11 @@ jobs: run: dotnet build OpenIPC.Viewer.Desktop.slnx --configuration Release --no-restore /warnaserror -p:IncludeMobileTargets=false - name: Test - run: dotnet test OpenIPC.Viewer.Desktop.slnx --configuration Release --no-build --verbosity normal -p:IncludeMobileTargets=false + # -m:1: one test project at a time. Run side by side on windows-latest, Devices.Tests' + # ONVIF stub cameras (HttpListener, i.e. http.sys) and Infrastructure.Tests' loopback + # TcpListeners — both on ports picked by the OS — made five ONVIF tests time out, most + # likely a request reaching the other project's listener. The tests take seconds anyway. + run: dotnet test OpenIPC.Viewer.Desktop.slnx --configuration Release --no-build -m:1 --verbosity normal -p:IncludeMobileTargets=false - name: Publish run: dotnet publish src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj -c Release -r ${{ matrix.rid }} --self-contained true /p:PublishSingleFile=true /p:IncludeNativeLibrariesForSelfExtract=true -p:IncludeMobileTargets=false -o publish/${{ matrix.rid }} From 15997791da8bfe5204a19f085846c5e4d9a6bb96 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 18:37:04 +0000 Subject: [PATCH 2/3] Dialogs: player hotkeys yield to an open dialog; keep the content's focus Qodo on #106: - The recording player listens for its hotkeys on the TopLevel (tunnel), so with its delete confirmation open Left/Right still seeked the recording and Space toggled playback before the dialog saw them. The handler now returns while a mobile dialog is open (OverlayDialogPresenter.IsAnyOpen, never true on the desktop, whose dialogs are windows). - The presenter's initial focus no longer replaces a focus the content has already set inside the dialog: the SSH terminal focuses its input surface itself, and the presenter moved it to the Close button. --- src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs | 4 ++++ .../Views/Pages/RecordingPlayerPage.axaml.cs | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs index 9b821cf..6128a3c 100644 --- a/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs +++ b/src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs @@ -329,6 +329,10 @@ void ApplySize(Size s) dim.Opacity = 1; if (!fullScreen) card.RenderTransform = TransformOperations.Parse("translateY(0)"); + // The content already put the focus somewhere inside (the SSH terminal focuses its + // input surface itself): leave it there. + if (top?.FocusManager?.GetFocusedElement() is Visual inside && card.IsVisualAncestorOf(inside)) + return; // Opened from a key: start the remote on the dialog's first control. Opened by a tap: // only take the focus off the page underneath, without a focus ring or a keyboard. var first = _lastInputWasKey ? FirstFocusable(card) : null; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml.cs b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml.cs index ff0667e..4c913df 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml.cs +++ b/src/OpenIPC.Viewer.App/Views/Pages/RecordingPlayerPage.axaml.cs @@ -63,6 +63,9 @@ private void OnWindowKeyDown(object? sender, KeyEventArgs e) { if (Vm is not { } vm || e.KeyModifiers != KeyModifiers.None) return; if (e.Source is TextBox) return; + // A mobile dialog over the player (the delete confirmation) owns the keys: the arrows + // move its focus and OK presses its buttons, they must not seek or toggle playback. + if (Services.OverlayDialogPresenter.IsAnyOpen) return; switch (e.Key) { From b1f3aba678fcd29aeacec4f85e858a555b36d7b6 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 2 Nov 2026 18:37:04 +0000 Subject: [PATCH 3/3] TV keyboard: reopen after Back, leave code focus alone, follow the activity Qodo on #107: - OK on a field whose keyboard was closed with Back (the field keeps the focus) went to the field instead of reopening the keyboard. Whether OK opens the keyboard now depends on the input pane's state (TopLevel.InputPane), not on a one-shot marker set on focus. - The keyboard is hidden only when the focus reached the field by D-pad or Tab. A focus set from code is left alone, so the SSH terminal's keyboard button, which refocuses its input proxy to bring the keyboard back, works. - The activity is refreshed on every MainActivity.OnCreate and dropped in OnDestroy; the class handlers are still registered once. Checked on an Android TV 16 emulator: into the search box by D-pad, no keyboard; OK opens it; Back closes it; OK opens it again; typed text lands without a stray space; Down moves on. Same after a font-scale change. --- src/OpenIPC.Viewer.Android/MainActivity.cs | 6 +++ .../Platform/TvSoftKeyboard.cs | 40 +++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/OpenIPC.Viewer.Android/MainActivity.cs b/src/OpenIPC.Viewer.Android/MainActivity.cs index 0a3871f..f9d6447 100644 --- a/src/OpenIPC.Viewer.Android/MainActivity.cs +++ b/src/OpenIPC.Viewer.Android/MainActivity.cs @@ -52,4 +52,10 @@ protected override void OnCreate(Bundle? savedInstanceState) if (wanted.Count > 0) RequestPermissions(wanted.ToArray(), 1906); } + + protected override void OnDestroy() + { + Platform.TvSoftKeyboard.Uninstall(this); + base.OnDestroy(); + } } diff --git a/src/OpenIPC.Viewer.Android/Platform/TvSoftKeyboard.cs b/src/OpenIPC.Viewer.Android/Platform/TvSoftKeyboard.cs index ef2123a..62d81d0 100644 --- a/src/OpenIPC.Viewer.Android/Platform/TvSoftKeyboard.cs +++ b/src/OpenIPC.Viewer.Android/Platform/TvSoftKeyboard.cs @@ -3,58 +3,66 @@ using Android.Content.Res; using Android.Views.InputMethods; using Avalonia.Controls; +using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; namespace OpenIPC.Viewer.Android.Platform; -// Android TV: a text field opens the soft keyboard on OK, not on focus. +// Android TV: a text field opens the soft keyboard on OK, not when the D-pad reaches it. // // Avalonia.Android shows the keyboard as soon as a TextBox gets the focus (AndroidInputMethod // SetClient → ShowSoftInput). With a touch screen that is a tap on the field; with a TV remote // it fires whenever the D-pad merely passes through one (the Library search box sits between the // toolbar and the filters), and the keyboard then covers half the screen and takes the D-pad. -// On a TV this hides the keyboard again right after the focus lands, and shows it when OK -// (D-pad centre, which Avalonia maps to Key.Space, or Enter) is pressed on the field. Back closes -// it as usual. Phones and tablets are left alone. +// On a TV this hides the keyboard again when the focus reached the field by D-pad or Tab, and +// shows it when OK (D-pad centre, which Avalonia maps to Key.Space, or Enter) is pressed on a +// field whose keyboard is closed. Back closes it as usual. Focus set from code (the SSH +// terminal's keyboard button) is left alone, and so are phones and tablets. internal static class TvSoftKeyboard { + private static bool _registered; + // The activity whose window the keyboard belongs to. Replaced when Android recreates the + // activity, cleared when it is destroyed. private static Activity? _activity; - // The focused field had its keyboard hidden by us and is waiting for OK. - private static TextBox? _waitingForOk; public static void Install(Activity activity) { - if (_activity is not null) return; var uiMode = activity.GetSystemService(Context.UiModeService) as UiModeManager; if (uiMode?.CurrentModeType != UiMode.TypeTelevision) return; _activity = activity; + if (_registered) return; + _registered = true; InputElement.GotFocusEvent.AddClassHandler(OnGotFocus, handledEventsToo: true); - InputElement.LostFocusEvent.AddClassHandler((box, _) => - { - if (ReferenceEquals(_waitingForOk, box)) _waitingForOk = null; - }, handledEventsToo: true); InputElement.KeyDownEvent.AddClassHandler(OnKeyDown, RoutingStrategies.Tunnel); } + public static void Uninstall(Activity activity) + { + if (ReferenceEquals(_activity, activity)) _activity = null; + } + private static void OnGotFocus(TextBox box, FocusChangedEventArgs e) { - _waitingForOk = box; + // Only the D-pad passing through: a focus set from code may be an explicit request for + // the keyboard. + if (e.NavigationMethod is not (NavigationMethod.Directional or NavigationMethod.Tab)) return; // Posted: Avalonia shows the keyboard while it hands the field to the input method, // around this focus change; hide it once that is done. Dispatcher.UIThread.Post(() => { - if (ReferenceEquals(_waitingForOk, box)) SetKeyboard(show: false); + if (box.IsFocused) SetKeyboard(show: false); }, DispatcherPriority.Background); } private static void OnKeyDown(TextBox box, KeyEventArgs e) { - if (e.Handled || !ReferenceEquals(_waitingForOk, box)) return; - if (e.Key is not (Key.Space or Key.Enter)) return; - _waitingForOk = null; + if (e.Handled || e.Key is not (Key.Space or Key.Enter)) return; + // With the keyboard up, the key is the keyboard's (or typing); only a closed keyboard — + // never opened on this field, or closed with Back while it kept the focus — opens on OK. + if (TopLevel.GetTopLevel(box)?.InputPane?.State == InputPaneState.Open) return; SetKeyboard(show: true); // OK only opens the keyboard: no space typed, no Enter action. e.Handled = true;