Public Access
The Android head had no updater and no release path, and the two are one problem: Android refuses an update signed by a different key, and CI generates a fresh debug key in every container. An APK released from a workflow could be installed once and never updated again — each new one an uninstall, which on this product means losing the cache, the outbox and the device key. So there are two channels, and they are two applications because the platform gives no third option. dev.dodotech.dodossh is cut from a v* tag by a person running scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners. dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore committed here in the open — a key everybody has cannot be stolen and grants nothing by being held, which is why putting it in CI does not touch the rule. Neither can update the other, by construction. See ADR 0014. The android job assumed an image with a JDK and an Android SDK on it, which is what a GitHub runner is and what this project's is not. It now installs a JDK, fetches Google's command-line tools, accepts the licences and installs API 36 — each a no-op where it is already satisfied, and each cached by the persistent runner's own disk rather than by an action that would move a quarter of a gigabyte to rebuild a directory that never left. The client reads a small JSON manifest beside the APK, the counterpart of releases.win.json, and compares Android's versionCode rather than a version name: that integer is what the platform itself uses to accept or refuse an install, so comparing anything else would offer updates the phone then rejects. It fetches, and then asks Android to ask — the system draws its own confirmation, and from API 26 will not draw even that until unknown sources is on for this application. IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files and restarts, so the shell disposes the vault first and that is what zeroes the keys. On the phone the install is a request and the answer may be no, so disposing first would answer "not now" with a locked keychain and every shell closed — a punishment for declining an update. Two measured bugs found on the way, both older than this work and both invisible to a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a top-level PropertyGroup, so the target setting it from MinVer ran after the only thing that reads it: every APK ever built here said versionName 1.0.0. And nothing found so far varies the launcher name per channel — four mechanisms tried, all of them recorded in platform-flags, none of them reaching the label the launcher shows. The two channels share an icon name for now and are told apart by package name, version, and what the preferences screen says.
130 lines
6.6 KiB
C#
130 lines
6.6 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// The launcher activity.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Deliberately empty. Avalonia is configured on the application object — see
|
|
/// <see cref="DodoSshAndroidApplication"/> — and the desktop head's <c>Program.Main</c> has no counterpart
|
|
/// at all here: Android constructs the activity, and its <c>[STAThread]</c> is a WebView2 requirement that
|
|
/// means nothing on this platform.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The ConfigurationChanges list is load-bearing.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>SingleTask</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b><c>AdjustResize</c> is declared rather than left unspecified</b>, and it is half of how this head
|
|
/// keeps the software keyboard off the box being typed into; <c>PhoneShell</c> 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 <c>ScrollViewer</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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
|
|
{
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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 <see cref="AndroidRedirectCallback.Abandon"/>, which is a
|
|
/// no-op unless a browser was opened and has not answered.
|
|
/// </remarks>
|
|
protected override void OnResume()
|
|
{
|
|
base.OnResume();
|
|
|
|
PhoneEnvironment.CurrentActivity = this;
|
|
|
|
AndroidRedirectCallback.Abandon();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
protected override void OnPause()
|
|
{
|
|
base.OnPause();
|
|
|
|
if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this))
|
|
{
|
|
PhoneEnvironment.CurrentActivity = null;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// <c>OnNewIntent</c> rather than <c>OnCreate</c>, and that is what <c>SingleTask</c> 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
}
|