// The Android SDK's own namespaces are reached through `global::` throughout this project, and it is not // a style choice. This assembly's root namespace ends in `Android`, so inside it a bare `Android.App` // binds to `DodoSSH.Client.Android.App` — this head's own Avalonia application type — rather than to the // platform. The desktop head hit the same class of collision and answered it by renaming its type; here // the collision is in the namespace itself, so the qualification is the honest fix. using Avalonia.Android; using DodoSSH.Client.Android.Platform; using global::Android.App; using global::Android.Content; using global::Android.Content.PM; using global::Android.Views; namespace DodoSSH.Client.Android; /// /// The launcher activity. /// /// /// /// Deliberately empty. Avalonia is configured on the application object — see /// — and the desktop head's Program.Main has no counterpart /// at all here: Android constructs the activity, and its [STAThread] is a WebView2 requirement that /// means nothing on this platform. /// /// /// The ConfigurationChanges list is load-bearing. Without it Android destroys and recreates the /// activity on every rotation and every time the software keyboard appears — and this head holds live SSH /// sessions and a terminal data plane behind a process-wide composition root. Letting the activity restart /// would tear the Avalonia application down under them. Declaring the changes handled is what keeps a /// shell alive across turning the phone sideways, which is the same promise the foreground service makes /// about backgrounding, arrived at from a different direction. /// /// /// SingleTask for the sign-in redirect: the authorization response comes back as an intent, and any /// other launch mode answers it with a second copy of this activity on top of the first — which on this /// head would mean a second Avalonia application over a live one. /// /// /// AdjustResize is declared rather than left unspecified, and it is half of how this head /// keeps the software keyboard off the box being typed into; PhoneShell is the other half. Left /// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view /// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks /// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on /// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can /// answer, and a screen built around a ScrollViewer then scrolls the focused box into view by /// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the /// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the /// reported inset. The two are complementary and never both in effect: where the window resizes, the /// keyboard inset arrives already consumed and measures zero. /// /// [Activity( // The launcher reads this rather than the application's, so a channel that renamed only the // application element would still put two identical entries on the home screen. A resource because // this is a compile-time string and the two channels need different answers from one binary's // sources. See Resources/values/strings.xml. Label = "@string/app_name", Theme = "@style/DodoTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTask, WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.KeyboardHidden | ConfigChanges.UiMode)] // The intent filter is what makes the sign-in redirect reach this application rather than a loopback // socket. The scheme is the reversed package name — RFC 8252 §7.1 — and it must match the constant on // AndroidRedirectCallback exactly; they are two declarations of one fact, so the constant is referenced // here rather than retyped and a rename cannot break only one of them. [IntentFilter( [Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataScheme = AndroidRedirectCallback.Scheme)] public sealed class MainActivity : AvaloniaMainActivity { /// /// /// Resuming is also how a cancelled sign-in is noticed. There is no event for a user pressing back out /// of the login page — the browser simply goes away and this application is foreground again with /// nothing delivered — so being resumed while a sign-in is still waiting for its redirect is the /// signal, and the only one there is. See , which is a /// no-op unless a browser was opened and has not answered. /// protected override void OnResume() { base.OnResume(); PhoneEnvironment.CurrentActivity = this; AndroidRedirectCallback.Abandon(); } /// /// /// Cleared only if it is still this activity. Android may resume the next one before pausing this one, /// and clearing unconditionally would drop a reference the newcomer had just set. /// protected override void OnPause() { base.OnPause(); if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this)) { PhoneEnvironment.CurrentActivity = null; } } /// /// /// OnNewIntent rather than OnCreate, and that is what SingleTask above buys: the /// activity is already running with a sign-in waiting inside it, so the redirect has to be delivered /// into that instance. Any other launch mode would start a second copy of the activity — and with it a /// second Avalonia application over a live one — leaving the original waiting for a response that had /// already been consumed. /// protected override void OnNewIntent(Intent? intent) { base.OnNewIntent(intent); if (intent?.Data is { } data && string.Equals(data.Scheme, AndroidRedirectCallback.Scheme, StringComparison.Ordinal) && Uri.TryCreate(data.ToString(), UriKind.Absolute, out var redirect)) { AndroidRedirectCallback.Complete(redirect); } } }