Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions src/OpenIPC.Viewer.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
40 changes: 24 additions & 16 deletions src/OpenIPC.Viewer.Android/Platform/TvSoftKeyboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextBox>(OnGotFocus, handledEventsToo: true);
InputElement.LostFocusEvent.AddClassHandler<TextBox>((box, _) =>
{
if (ReferenceEquals(_waitingForOk, box)) _waitingForOk = null;
}, handledEventsToo: true);
InputElement.KeyDownEvent.AddClassHandler<TextBox>(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;
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Services/OverlayDialogPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading