Public Access
Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6. Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android despite shipping no Android build, and the local cache opens. Two findings the audit could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which settles the open "which Android versions" question at targetSdk 36; and Android has blocked cleartext HTTP since API 28, so the terminal renderer needs a network security config scoped to 127.0.0.1 or the WebView loads nothing. DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal renderer files and the palette moved there so both heads drive one state machine and draw from one set of tokens. The desktop head is otherwise untouched and its 144 tests still pass. The platform pieces behind interfaces that already existed: the profile directory from filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and a foreground service so a shell outliving a vault lock stays true on a platform that stops backgrounded processes. Sign-in is deliberately absent rather than approximated. It needs an app link, because reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// This phone's device key, wrapped by a hardware-held key that a fingerprint releases.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Android counterpart of the desktop head's <c>WindowsDeviceKeyStore</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The X25519 scalar is not stored in the keystore</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>StrongBox is asked for and not required.</b> Where the phone has a separate security chip the key
|
||||
/// lives there; where it does not, generation throws
|
||||
/// <see cref="StrongBoxUnavailableException"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every failure here is answered by returning null rather than throwing</b>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// The nonce is stored ahead of the ciphertext rather than derived.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private const int NonceBytes = 12;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public ValueTask<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(ReadOnlyMemory<byte> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <c>SetInvalidatedByBiometricEnrollment</c> 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.
|
||||
/// </remarks>
|
||||
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()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using global::Android.Hardware.Biometrics;
|
||||
using global::Javax.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>Raised when the gesture did not happen — cancelled, failed, or nothing enrolled.</summary>
|
||||
/// <remarks>
|
||||
/// One exception for every refusal, because <see cref="AndroidDeviceKeyStore"/> 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.
|
||||
/// </remarks>
|
||||
internal sealed class BiometricRefusedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Puts the system's biometric prompt in front of a cipher, and waits for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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();
|
||||
|
||||
/// <remarks>
|
||||
/// Terminal, unlike <c>OnAuthenticationFailed</c>. 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.
|
||||
/// </remarks>
|
||||
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
|
||||
{
|
||||
/// <remarks>
|
||||
/// Nothing to do: dismissing the prompt raises <c>OnAuthenticationError</c> as well, and that is
|
||||
/// where the wait is completed. Answering here too would be a second completion on the same task.
|
||||
/// </remarks>
|
||||
public void OnClick(global::Android.Content.IDialogInterface? dialog, int which)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The handful of facts about this phone that the platform-neutral layers need handed to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named <c>Phone</c> rather than <c>Android</c> because <c>Android.Runtime.AndroidEnvironment</c> already
|
||||
/// exists and this type is not it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>Environment.MachineName</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PhoneEnvironment
|
||||
{
|
||||
private static Context? context;
|
||||
|
||||
/// <summary>Captured once, from the launcher activity, before Avalonia starts.</summary>
|
||||
/// <remarks>
|
||||
/// The <em>application</em> 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.
|
||||
/// </remarks>
|
||||
public static void Attach(Context activity) =>
|
||||
context = activity.ApplicationContext ?? activity;
|
||||
|
||||
/// <summary>Where this phone keeps its profile.</summary>
|
||||
/// <remarks>
|
||||
/// <c>filesDir</c> — per-app, non-roaming, not user-visible, and removed when the app is uninstalled.
|
||||
/// <see cref="ClientPaths"/> 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.
|
||||
/// </remarks>
|
||||
public static ClientPaths Paths =>
|
||||
new(Require().FilesDir?.AbsolutePath
|
||||
?? throw new InvalidOperationException("Android returned no filesDir for this application."));
|
||||
|
||||
/// <summary>
|
||||
/// What this phone calls itself in connection and keychain log entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Environment.MachineName</c> returns <c>localhost</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>Settings.Global.DeviceName</c> 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 <c>shiba</c> is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||
public static Context Require() =>
|
||||
context ?? throw new InvalidOperationException(
|
||||
"PhoneEnvironment was read before MainActivity attached it.");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the process alive for as long as a shell or a transfer is live.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this exists at all is worth stating plainly.</b> <c>TerminalWorkspace</c>'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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It holds no state and owns nothing.</b> 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
|
||||
/// <see cref="SessionKeepAlive"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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;
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||
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<string>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps <see cref="SessionForegroundService"/> in step with what is actually running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="TerminalWorkspace.LiveSessionCount"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SessionKeepAlive : IDisposable
|
||||
{
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly Func<int> activeTransfers;
|
||||
|
||||
/// <param name="workspace">The live shells.</param>
|
||||
/// <param name="activeTransfers">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
||||
/// stopped one, and Android treats both as no-ops.
|
||||
/// </remarks>
|
||||
public void Refresh() =>
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user