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 }} 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; 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) {