diff --git a/Directory.Packages.props b/Directory.Packages.props index b279b97..386b744 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -108,6 +108,11 @@ --> + + diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 5d27ab4..bddd47c 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -21,6 +21,7 @@ + diff --git a/src/DodoSSH.Client.Android/App.axaml b/src/DodoSSH.Client.Android/App.axaml new file mode 100644 index 0000000..a0d22f3 --- /dev/null +++ b/src/DodoSSH.Client.Android/App.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs new file mode 100644 index 0000000..16bca11 --- /dev/null +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -0,0 +1,134 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +using DodoSSH.Client.Android.Platform; +using DodoSSH.Client.Android.Views; +using DodoSSH.Client.Session; +using DodoSSH.Client.Shell.Terminal; +using DodoSSH.Client.Shell.ViewModels; +using DodoSSH.Client.Ssh; +using DodoSSH.Client.Storage; +using DodoSSH.Client.Terminal; + +namespace DodoSSH.Client.Android; + +/// +/// The Avalonia application, phone side. +/// +/// +/// Named DodoSshApp for the same reason the desktop head's is, and then for a second reason on top +/// of it: a type called App in a namespace ending .Android is what makes every +/// Android.App in this assembly ambiguous. See the note at the top of MainActivity. +/// +public sealed partial class DodoSshApp : Avalonia.Application +{ + /// + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + /// + public override void OnFrameworkInitializationCompleted() + { + // ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one + // surface and no window to own. That difference is the whole reason the two heads cannot share a + // composition root, and very nearly the only one — everything either of them composes is the same. + if (ApplicationLifetime is ISingleViewApplicationLifetime single) + { + single.MainView = Compose(); + } + + base.OnFrameworkInitializationCompleted(); + } + + /// + /// + /// Composed by hand rather than through a container, matching the desktop head: the graph is a handful + /// of objects deep and an indirection to read through would buy nothing at this size. Read it beside + /// DodoSSH.Client.App/App.axaml.cs — the shape is deliberately identical, and the four + /// differences are the four things docs/android-port.md said would differ. + /// + /// + /// Nothing here is disposed on a lifecycle hook, and that is not an oversight either. Android + /// does not promise to call anything before killing a process, so a teardown path would be a comfort + /// rather than a guarantee. What actually protects the sessions is the foreground service; what + /// protects the vault keys is that they never leave memory this process owns. + /// + /// + private static PhoneShell Compose() + { + // Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming, + // which is what ClientPaths asks for and what no desktop platform guarantees. + var paths = PhoneEnvironment.Paths; + paths.EnsureCreated(); + + var caches = ClientCacheFactory.ForFile(paths.CacheFile); + + var knownHosts = new VaultKnownHostStore(); + var connections = new SshNetConnectionFactory(knownHosts); + + var workspace = new TerminalWorkspace( + new AvaloniaTerminalAssetProvider(), + connections, + TimeProvider.System); + + workspace.Start(); + + // Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a + // shell outlives a vault lock — true on a platform that stops backgrounded processes. + // Zero transfers for now: file transfer is out of this head's first scope by decision, and this is + // the seam it arrives through rather than a dependency taken before there is anything behind it. + // A local rather than a field, matching the desktop head: an Avalonia Application has no disposal + // hook, so a field holding a disposable would have nowhere honest to release it. It stays alive + // because it is subscribed to the workspace, which lives as long as the process. + // + // Refresh() is called once here. Calling it again when a shell opens is what the terminal screen + // will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on + // its own, which is the half that would otherwise leave a notification up over nothing. + var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0); + + // Difference 3: the Android keystore, with a fingerprint or the device credential releasing the + // key. A straight implementation of the interface the session layer has always taken. + var deviceKeys = new AndroidDeviceKeyStore(paths); + + var viewModel = new MainWindowViewModel( + paths, + caches, + workspace, + knownHosts, + deviceKeys, + SignInIsNotBuiltHere, + TimeProvider.System, + connections, + passphraseProfile: null); + + // Started rather than awaited: framework initialisation must not block on a schema migration. The + // view model shows its own progress and handles its own failures. + _ = viewModel.StartAsync(CancellationToken.None); + + keepAlive.Refresh(); + + return new PhoneShell { DataContext = viewModel }; + } + + /// + /// Difference 4, and the one that is a refusal rather than an implementation. + /// + /// + /// + /// Signing in needs a redirect this head has not got. The desktop client receives the authorization + /// response on a loopback TcpListener (RFC 8252 §7.3), and reusing that here would be a + /// security regression rather than a shortcut: on a shared device any other application can bind a + /// loopback port and race for the response, which is the attack §8.3 names and the reason app links + /// exist. Process.Start does not exist on this platform either. + /// + /// + /// So this throws rather than half-working, and the shell never reaches it: NeedsServer draws a + /// screen that says the same thing in the user's words. A phone enrolled from the desktop client + /// unlocks here perfectly well, because unlocking needs no network at all. + /// + /// + private static Task SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) => + throw new NotSupportedException( + "Signing in is not built on the Android head yet: it needs an app-link redirect rather than " + + "the desktop client's loopback listener. See docs/android-port.md §5."); +} diff --git a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj new file mode 100644 index 0000000..597c308 --- /dev/null +++ b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj @@ -0,0 +1,54 @@ + + + + net10.0-android + Exe + enable + true + + + 28 + 36 + + dev.dodotech.dodossh + 1 + 0.1.0 + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs new file mode 100644 index 0000000..456174f --- /dev/null +++ b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs @@ -0,0 +1,53 @@ +// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`. +using global::Android.App; +using global::Android.Runtime; + +using Avalonia; +using Avalonia.Android; + +using DodoSSH.Client.Android.Platform; + +namespace DodoSSH.Client.Android; + +/// +/// The Android Application object, and where Avalonia is configured. +/// +/// +/// +/// Avalonia 12 moved the hooks here from the activity, which is a better fit than +/// it first looks: an activity is destroyed and recreated, and the application object is not. The desktop +/// head's equivalent is Program.BuildAvaloniaApp. +/// +/// +/// Any Avalonia 11 sample will show this on AvaloniaMainActivity<App> instead. That type is +/// still here and is still what the launcher activity derives from — it simply no longer takes the +/// application as a type argument. +/// +/// +[Application(Label = "DodoSSH")] +public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication +{ + /// + /// The JNI constructor, and the only one Android calls. It exists to hand the managed object its Java + /// peer; there is nothing to do in it and nothing may be done in it, because the application context is + /// not usable until . + /// + public DodoSshAndroidApplication(nint javaReference, JniHandleOwnership transfer) + : base(javaReference, transfer) + { + } + + /// + public override void OnCreate() + { + // Before Avalonia, and from the application rather than the activity: this is the context whose + // lifetime matches the composition root's, and reading filesDir is the first thing startup does. + PhoneEnvironment.Attach(this); + + base.OnCreate(); + } + + /// + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) => + base.CustomizeAppBuilder(builder).WithInterFont(); +} diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs new file mode 100644 index 0000000..0f36537 --- /dev/null +++ b/src/DodoSSH.Client.Android/MainActivity.cs @@ -0,0 +1,50 @@ +// 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 global::Android.App; +using global::Android.Content.PM; + +using Avalonia.Android; + +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. +/// +/// +[Activity( + Label = "DodoSSH", + Theme = "@style/DodoTheme", + MainLauncher = true, + LaunchMode = LaunchMode.SingleTask, + ConfigurationChanges = ConfigChanges.Orientation + | ConfigChanges.ScreenSize + | ConfigChanges.ScreenLayout + | ConfigChanges.SmallestScreenSize + | ConfigChanges.KeyboardHidden + | ConfigChanges.UiMode)] +public sealed class MainActivity : AvaloniaMainActivity +{ +} diff --git a/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs new file mode 100644 index 0000000..579f3a0 --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs @@ -0,0 +1,223 @@ +using global::Android.App; +using global::Android.Security.Keystore; +using global::Java.Security; +using global::Javax.Crypto; +using global::Javax.Crypto.Spec; + +using DodoSSH.Client.Session; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// This phone's device key, wrapped by a hardware-held key that a fingerprint releases. +/// +/// +/// +/// The Android counterpart of the desktop head's WindowsDeviceKeyStore, and a closer match to what +/// the unlock screen wants than that one is: the Windows store leans on DPAPI plus a TPM-held key and a +/// Hello gesture, whereas here the gesture is a property of the key itself and the platform will not +/// release it without one. See ADR 0007 and docs/android-port.md §4. +/// +/// +/// The X25519 scalar is not stored in the keystore, and cannot be: Android's keystore holds keys it +/// generates and refuses to export them, while what the vault needs back is the raw 32 bytes. So the +/// keystore holds an AES-GCM key that never leaves the secure hardware, and that key encrypts the scalar +/// into an ordinary file beside the cache. The file is useless on its own — on this phone as much as on +/// any other — which is the same shape the Windows store already has. +/// +/// +/// StrongBox is asked for and not required. Where the phone has a separate security chip the key +/// lives there; where it does not, generation throws +/// and the key is made in the TEE instead. Refusing to fall +/// back would mean a mid-range phone reporting no device key at all, which costs a real user a real +/// feature to buy a distinction the threat model does not draw. +/// +/// +/// Every failure here is answered by returning null rather than throwing, exactly as the interface +/// asks. A user can cancel the prompt, a re-enrolled fingerprint invalidates the key permanently, and a +/// phone can have no enrolled biometric at all — and the caller's answer to all three is the same one: +/// ask for the passphrase. +/// +/// +internal sealed class AndroidDeviceKeyStore(ClientPaths paths) : IDeviceKeyStore +{ + private const string KeystoreName = "AndroidKeyStore"; + private const string KeyAlias = "dodossh.device"; + private const string Transformation = "AES/GCM/NoPadding"; + + /// + /// The nonce is stored ahead of the ciphertext rather than derived. + /// + /// + /// The cipher picks it: a GCM key that is asked to encrypt twice under a caller-chosen nonce is one + /// misuse away from losing the key, and Android's keystore refuses a caller-supplied IV for exactly + /// that reason. Twelve bytes is what it generates. + /// + private const int NonceBytes = 12; + + /// + /// + /// Three things have to be true, and the third is the one that is easy to forget: the platform has a + /// keystore, the phone has a screen lock, and something is actually enrolled to satisfy it. A phone + /// with no lock screen can still generate a key that requires authentication — and then no gesture can + /// ever release it. + /// + public ValueTask IsAvailableAsync(CancellationToken cancellationToken) + { + try + { + var keyguard = PhoneEnvironment.Require() + .GetSystemService(global::Android.Content.Context.KeyguardService) as KeyguardManager; + + return ValueTask.FromResult(keyguard?.IsDeviceSecure == true); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return ValueTask.FromResult(false); + } + } + + /// + public async ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) + { + var key = GenerateWrappingKey(); + + var cipher = Cipher.GetInstance(Transformation) + ?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher."); + + cipher.Init(CipherMode.EncryptMode, key); + + // Authenticated before the key is usable, exactly as loading is. Registering a device is itself a + // decision worth a gesture — it is the moment this phone gains the ability to open the vault + // without the passphrase. + await BiometricGate + .AuthenticateAsync(cipher, "Register this phone", cancellationToken) + .ConfigureAwait(false); + + var sealed_ = cipher.DoFinal(devicePrivateKey.ToArray()) + ?? throw new InvalidOperationException("The keystore cipher returned nothing."); + + var nonce = cipher.GetIV() ?? throw new InvalidOperationException("The keystore cipher chose no IV."); + + var blob = new byte[nonce.Length + sealed_.Length]; + nonce.CopyTo(blob, 0); + sealed_.CopyTo(blob, nonce.Length); + + paths.EnsureCreated(); + + await File.WriteAllBytesAsync(paths.DeviceKeyFile, blob, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask TryLoadAsync(CancellationToken cancellationToken) + { + try + { + if (!File.Exists(paths.DeviceKeyFile)) + { + return null; + } + + var blob = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken).ConfigureAwait(false); + + if (blob.Length <= NonceBytes) + { + return null; + } + + var store = KeyStore.GetInstance(KeystoreName) + ?? throw new InvalidOperationException("This phone has no AndroidKeyStore."); + + store.Load(null); + + // Null when the key was invalidated — a re-enrolled fingerprint or a reset screen lock does + // this, and it is permanent by design. The wrapped file is unopenable from here on, so the + // honest answer is the same as having no key: ask for the passphrase. + if (store.GetKey(KeyAlias, null) is not IKey key) + { + return null; + } + + var cipher = Cipher.GetInstance(Transformation) + ?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher."); + + cipher.Init(CipherMode.DecryptMode, key, new GCMParameterSpec(128, blob, 0, NonceBytes)); + + await BiometricGate + .AuthenticateAsync(cipher, "Unlock DodoSSH", cancellationToken) + .ConfigureAwait(false); + + return cipher.DoFinal(blob, NonceBytes, blob.Length - NonceBytes); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // Deliberately flat. KeyPermanentlyInvalidatedException, UserNotAuthenticatedException, a + // cancelled prompt and a truncated file are four different stories with one ending, and the + // interface says so: distinguishing them would describe the keystore rather than tell the user + // anything they can act on. + return null; + } + } + + /// + public ValueTask ForgetAsync(CancellationToken cancellationToken) + { + try + { + var store = KeyStore.GetInstance(KeystoreName); + store?.Load(null); + store?.DeleteEntry(KeyAlias); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // Best effort: the file going is what actually withdraws this phone's ability to unlock, and + // an orphaned keystore entry opens nothing. + } + + // After the keystore entry, not before. A file left behind with its key already deleted is merely + // unopenable; a key left behind with its file already gone is the same. Order costs nothing here, + // but the file is the one that matters, so it goes last and unconditionally. + if (File.Exists(paths.DeviceKeyFile)) + { + File.Delete(paths.DeviceKeyFile); + } + + return ValueTask.CompletedTask; + } + + /// + /// SetInvalidatedByBiometricEnrollment is on, which is the setting that makes this worth having: + /// without it, somebody who can add their own fingerprint to an unlocked phone inherits the ability to + /// unlock the vault. With it, enrolling a new fingerprint destroys the key and the phone falls back to + /// the passphrase — which is the correct outcome and the reason the load path treats invalidation as + /// ordinary rather than exceptional. + /// + private static IKey GenerateWrappingKey() + { + static KeyGenParameterSpec.Builder Spec() => + new KeyGenParameterSpec.Builder(KeyAlias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt) + .SetBlockModes(KeyProperties.BlockModeGcm)! + .SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone)! + .SetKeySize(256)! + .SetUserAuthenticationRequired(true)! + .SetInvalidatedByBiometricEnrollment(true)!; + + var generator = KeyGenerator.GetInstance(KeyProperties.KeyAlgorithmAes, KeystoreName) + ?? throw new InvalidOperationException("This phone has no AES key generator in its keystore."); + + try + { + generator.Init(Spec().SetIsStrongBoxBacked(true)!.Build()); + + return generator.GenerateKey()!; + } + catch (StrongBoxUnavailableException) + { + // No separate security chip. The TEE-backed key is still hardware-held and still gated by the + // same gesture; see the class remarks for why this is a fallback rather than a refusal. + generator.Init(Spec().Build()); + + return generator.GenerateKey()!; + } + } +} diff --git a/src/DodoSSH.Client.Android/Platform/BiometricGate.cs b/src/DodoSSH.Client.Android/Platform/BiometricGate.cs new file mode 100644 index 0000000..d366147 --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/BiometricGate.cs @@ -0,0 +1,108 @@ +using global::Android.Hardware.Biometrics; +using global::Javax.Crypto; + +namespace DodoSSH.Client.Android.Platform; + +/// Raised when the gesture did not happen — cancelled, failed, or nothing enrolled. +/// +/// One exception for every refusal, because answers all of them the +/// same way. It carries the platform's own message only so it can reach a log; nothing shows it to a user, +/// who has just watched the system's own dialogue say the same thing better. +/// +internal sealed class BiometricRefusedException(string message) : Exception(message); + +/// +/// Puts the system's biometric prompt in front of a cipher, and waits for it. +/// +/// +/// +/// The cipher is handed to the prompt rather than merely being used after it, and that is the whole point. +/// A prompt that only returned "yes" would be a boolean this process could be tricked into skipping; +/// binding the cipher to the prompt means the keystore itself will not perform the operation unless the +/// gesture actually happened. The key is unusable to a caller that did not go through here. +/// +/// +/// Separated from the store because it is the platform half and it is callback-shaped, and because it is +/// the piece most likely to need a second implementation — androidx.biometric, if the floor ever drops +/// below API 28. +/// +/// +internal static class BiometricGate +{ + public static async Task AuthenticateAsync(Cipher cipher, string title, CancellationToken cancellationToken) + { + var context = PhoneEnvironment.Require(); + + var builder = new BiometricPrompt.Builder(context) + .SetTitle(title)! + .SetDescription("Releases this phone's device key. The vault itself never leaves it.")!; + + if (OperatingSystem.IsAndroidVersionAtLeast(30)) + { + // Device credential beside biometrics deliberately: a phone whose fingerprint reader is wet, + // or whose owner has none enrolled, still has a PIN, and the key is guarded either way. The + // alternative is an unlock screen that silently stops offering the fast path. + // BiometricManagerAuthenticators, not BiometricManager.Authenticators: .NET for Android + // flattens Java's nested classes, so the Java documentation's name is not the C# one. + // Cast because the binding types the flags as an enum and the setter as the raw int Java uses. + builder.SetAllowedAuthenticators( + (int)(BiometricManagerAuthenticators.BiometricStrong + | BiometricManagerAuthenticators.DeviceCredential)); + } + else + { + // API 28 and 29 have no allowed-authenticators list, and a prompt with no negative button is + // rejected outright at build time. The button is the only way out of the dialogue on these two + // releases, which is why it says what it does. + builder.SetNegativeButton( + "Use passphrase", + context.MainExecutor!, + new RefusalListener()); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var signal = new global::Android.OS.CancellationSignal(); + + // The token has to reach the dialogue, not merely the await. Without this a cancelled unlock leaves + // the system prompt on screen over an application that has stopped waiting for it. + using var registration = cancellationToken.Register(signal.Cancel); + + builder.Build().Authenticate( + new BiometricPrompt.CryptoObject(cipher), + signal, + context.MainExecutor!, + new Callback(completion)); + + // Awaited here rather than returned, so the cancellation registration above outlives the prompt + // it is there to cancel. + await completion.Task.ConfigureAwait(false); + } + + private sealed class Callback(TaskCompletionSource completion) : BiometricPrompt.AuthenticationCallback + { + public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? result) => + completion.TrySetResult(); + + /// + /// Terminal, unlike OnAuthenticationFailed. An error is the prompt giving up — cancelled, + /// locked out, nothing enrolled — whereas a failure is one finger not being recognised, and the + /// prompt stays up and keeps trying after it. Completing the task on a failure would abandon a + /// dialogue that is still on screen. + /// + public override void OnAuthenticationError(BiometricErrorCode errorCode, global::Java.Lang.ICharSequence? errString) => + completion.TrySetException( + new BiometricRefusedException(errString?.ToString() ?? $"Biometric error {errorCode}.")); + } + + private sealed class RefusalListener : global::Java.Lang.Object, global::Android.Content.IDialogInterfaceOnClickListener + { + /// + /// Nothing to do: dismissing the prompt raises OnAuthenticationError as well, and that is + /// where the wait is completed. Answering here too would be a second completion on the same task. + /// + public void OnClick(global::Android.Content.IDialogInterface? dialog, int which) + { + } + } +} diff --git a/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs new file mode 100644 index 0000000..656d3ac --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs @@ -0,0 +1,85 @@ +using global::Android.Content; +using global::Android.OS; +using global::Android.Provider; + +using DodoSSH.Client.Session; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// The handful of facts about this phone that the platform-neutral layers need handed to them. +/// +/// +/// +/// Named Phone rather than Android because Android.Runtime.AndroidEnvironment already +/// exists and this type is not it. +/// +/// +/// Both members below are things docs/android-port.md called out as needing to come from the head rather +/// than be branched for inside the core: the profile directory, and a device name that is not +/// Environment.MachineName. +/// +/// +internal static class PhoneEnvironment +{ + private static Context? context; + + /// Captured once, from the launcher activity, before Avalonia starts. + /// + /// The application context rather than the activity's. An activity is destroyed and recreated + /// on configuration changes this head does not declare as handled, and holding one in a static field is + /// the textbook Android leak; the application context lives as long as the process, which is exactly the + /// lifetime the composition root has. + /// + public static void Attach(Context activity) => + context = activity.ApplicationContext ?? activity; + + /// Where this phone keeps its profile. + /// + /// filesDir — per-app, non-roaming, not user-visible, and removed when the app is uninstalled. + /// asks for a local, non-roaming directory because two machines sharing one + /// cache file corrupts the outbox; on Android that is not merely satisfied but enforced by the platform, + /// since no other app can reach this path at all. + /// + public static ClientPaths Paths => + new(Require().FilesDir?.AbsolutePath + ?? throw new InvalidOperationException("Android returned no filesDir for this application.")); + + /// + /// What this phone calls itself in connection and keychain log entries. + /// + /// + /// + /// Environment.MachineName returns localhost on Android, which would make every log entry + /// written from a phone indistinguishable from every other — the finding recorded in + /// docs/android-port.md §7. + /// + /// + /// Settings.Global.DeviceName is what the user themselves typed in Settings, so it is the name + /// they will recognise in a log written by a different device. It is null on phones that have never had + /// one set, and the fallback is the marketing model rather than the board name: a person reading a log + /// knows what a Pixel 8 is and does not know what shiba is. + /// + /// + public static string DeviceName + { + get + { + var chosen = Settings.Global.GetString(Require().ContentResolver, "device_name"); + + if (!string.IsNullOrWhiteSpace(chosen)) + { + return chosen; + } + + var model = Build.Model; + + return string.IsNullOrWhiteSpace(model) ? "Android phone" : model; + } + } + + /// The application context, once has run. + public static Context Require() => + context ?? throw new InvalidOperationException( + "PhoneEnvironment was read before MainActivity attached it."); +} diff --git a/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs new file mode 100644 index 0000000..61e5f05 --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs @@ -0,0 +1,134 @@ +using global::Android.App; +using global::Android.Content; +using global::Android.Content.PM; +using global::Android.OS; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// Keeps the process alive for as long as a shell or a transfer is live. +/// +/// +/// +/// The decision recorded in docs/android-port.md: a persistent notification, for as long as there is +/// something running that would be wrong to kill. It costs the user a notification and some battery, and it +/// buys the behaviour the desktop client already promises and documents — that a shell outlives a vault +/// lock, and that a transfer finishes. +/// +/// +/// Why this exists at all is worth stating plainly. TerminalWorkspace's guarantee is that +/// locking the vault does not close your shells, because the remote host never consulted the vault and the +/// credential was already spent. On a desktop that guarantee is free — the process keeps running. On +/// Android nothing keeps a backgrounded process running, so without this the guarantee would quietly become +/// desktop-only, and a phone would drop a shell the moment the user checked a message. +/// +/// +/// It holds no state and owns nothing. The sessions live in the composition root, exactly as they do +/// on the desktop; this only asks Android not to stop the process they are in. That is why starting and +/// stopping it is a count of live things rather than a lifecycle of its own — see +/// . +/// +/// +[Service( + Exported = false, + + // Android 14 (API 34) refuses to start a foreground service whose type is not declared both here and + // in the manifest's permission list. dataSync is the type that matches: an SSH session and a file + // transfer are both the user's data moving to somewhere the user chose. + ForegroundServiceType = ForegroundService.TypeDataSync)] +internal sealed class SessionForegroundService : Service +{ + private const string ChannelId = "dodossh.sessions"; + private const int NotificationId = 1; + + /// + /// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is + /// wanted here: the point is that they outlive whatever the user does with the interface. + /// + public override IBinder? OnBind(Intent? intent) => null; + + public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) + { + StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working")); + + // NotSticky: if Android does kill this process, the SSH connections died with it and there is + // nothing to resume. Restarting the service would produce a notification claiming sessions that no + // longer exist, which is exactly the kind of dishonest state the unlock screen's shell count exists + // to prevent. + return StartCommandResult.NotSticky; + } + + /// + /// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android + /// requires one, and because the user is entitled to know the app is holding connections open. Making + /// it buzz would be a notification about nothing having happened. + /// + private Notification BuildNotification(string summary) + { + var manager = (NotificationManager)GetSystemService(NotificationService)!; + + if (OperatingSystem.IsAndroidVersionAtLeast(26)) + { + var channel = new NotificationChannel(ChannelId, "Live sessions", NotificationImportance.Low) + { + Description = "Shown while a shell or a transfer is open.", + }; + + channel.SetShowBadge(false); + manager.CreateNotificationChannel(channel); + } + + var reopen = PendingIntent.GetActivity( + this, + 0, + new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop), + PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent); + + return new Notification.Builder(this, ChannelId) + .SetContentTitle("DodoSSH") + .SetContentText(summary) + .SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo) + .SetContentIntent(reopen) + .SetOngoing(true)! + .Build(); + } + + /// Starts or stops the service to match what is actually running. + /// Shells with a live channel behind them. + /// Transfers still moving bytes. + public static void Reconcile(int liveSessions, int activeTransfers) + { + var context = PhoneEnvironment.Require(); + var intent = new Intent(context, typeof(SessionForegroundService)); + + if (liveSessions == 0 && activeTransfers == 0) + { + context.StopService(intent); + + return; + } + + // The summary says what is actually held, counted rather than generic — the same principle the + // delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on. + intent.PutExtra("summary", Summarise(liveSessions, activeTransfers)); + + context.StartForegroundService(intent); + } + + private static string Summarise(int liveSessions, int activeTransfers) + { + var parts = new List(2); + + if (liveSessions > 0) + { + parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected"); + } + + if (activeTransfers > 0) + { + parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running"); + } + + return string.Join(" · ", parts); + } +} diff --git a/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs new file mode 100644 index 0000000..c20a78a --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs @@ -0,0 +1,63 @@ +using DodoSSH.Client.Terminal; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// Keeps in step with what is actually running. +/// +/// +/// +/// The service is started and stopped from one place, and that place is a count rather than a lifecycle. +/// Anything else drifts: a service started when a shell opens and stopped when a tab closes would leave +/// the notification up after the last shell died on its own, and a phone showing "1 shell connected" over +/// nothing is the same dishonesty the unlock screen's shell count exists to avoid. +/// +/// +/// is deliberately the source of truth rather than a +/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which +/// a counter incremented on open and decremented on close would not. +/// +/// +internal sealed class SessionKeepAlive : IDisposable +{ + private readonly TerminalWorkspace workspace; + private readonly Func activeTransfers; + + /// The live shells. + /// + /// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out + /// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it + /// will arrive through rather than a dependency taken before there is anything to depend on. + /// + public SessionKeepAlive(TerminalWorkspace workspace, Func activeTransfers) + { + this.workspace = workspace; + this.activeTransfers = activeTransfers; + + // Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is + // a binder call and needs no particular thread. Nothing here touches the interface. + workspace.SessionEnded += OnSessionEnded; + } + + /// Re-reads the counts and starts or stops the service to match. + /// + /// Called after anything that could change either count — opening a shell, closing a tab, a transfer + /// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is + /// either a redundant startForegroundService on a running service or a stopService on a + /// stopped one, and Android treats both as no-ops. + /// + public void Refresh() => + SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers()); + + /// + public void Dispose() + { + workspace.SessionEnded -= OnSessionEnded; + + // The notification goes with the composition root. Leaving it up over a process that is shutting + // down is how an SSH client acquires a reputation for a notification you cannot get rid of. + SessionForegroundService.Reconcile(0, 0); + } + + private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh(); +} diff --git a/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml new file mode 100644 index 0000000..b740344 --- /dev/null +++ b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Resources/values/colors.xml b/src/DodoSSH.Client.Android/Resources/values/colors.xml new file mode 100644 index 0000000..9c62f67 --- /dev/null +++ b/src/DodoSSH.Client.Android/Resources/values/colors.xml @@ -0,0 +1,13 @@ + + + + #0A0C0B + diff --git a/src/DodoSSH.Client.Android/Resources/values/styles.xml b/src/DodoSSH.Client.Android/Resources/values/styles.xml new file mode 100644 index 0000000..551e59d --- /dev/null +++ b/src/DodoSSH.Client.Android/Resources/values/styles.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/src/DodoSSH.Client.Android/Resources/xml/network_security_config.xml b/src/DodoSSH.Client.Android/Resources/xml/network_security_config.xml new file mode 100644 index 0000000..d73e69d --- /dev/null +++ b/src/DodoSSH.Client.Android/Resources/xml/network_security_config.xml @@ -0,0 +1,21 @@ + + + + + 127.0.0.1 + + diff --git a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml new file mode 100644 index 0000000..e63b333 --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +