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