Give the phone a way to enrol the fingerprint it already unlocks with
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 35s
ci / build and test (push) Successful in 1m36s

The Android device key store, the biometric gate and the lock screen's UNLOCK
WITH FINGERPRINT button have all shipped since this head was written, and none
of them could ever run: that button appears only when a device key exists, and
nothing on the phone could create one. `CanUnlockWithDevice` was false on every
launch of every phone. This is the missing half.

**The offer is on PREFERENCES**, which held a PendingScreen until it had a
setting on it. It is there rather than beside the button it turns on because
registering needs an unlocked keychain and a reachable server — the vault has to
be open to seal the bundle, and the wrap has to reach the account or a phone
somebody has lost could never be revoked. Neither is true on the lock screen.

One card, and exactly one of its three blocks is ever drawn: the offer, the
withdrawal, or the sentence saying this phone has nowhere to keep a key. That is
`CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two
flags and not one and its negation for the reason written where they are set —
a phone with no screen lock and a phone already registered are both "cannot
register", and only the second has anything to take back. The withdrawal has no
confirmation, deliberately, and the sentence above it carries what the desktop
puts in a tooltip this head has no room for. `StatusMessage` is on the screen
because it is the only feedback this head has once the system's own dialogue has
gone.

**Two things would have been wrong in the feature the moment it worked.**

`Environment.MachineName` answers `localhost` on Android, and registering names
the device — so every phone would have arrived in the account's device list as
another identical row, on the very screen a lost handset is revoked from.
`PhoneEnvironment.DeviceName` was already written and never called; the shell
now takes it as an optional constructor argument that the desktop does not pass,
and it reaches enrollment, registration and every connection log entry. That was
gap §7 of docs/android-port.md, and it is now closed.

And the status line said "Waiting for Windows…" over an Android biometric
prompt. `GestureWait` picks the sentence from the platform rather than from a
head, unlike the device name beside it: a device name is a fact about one
handset only the head can read, and which dialogue appears is a fact about the
operating system this assembly is running on.

Two tests cover the seam — the injected name reaching the account, and the
default still being this machine's own name — and `FakeVaultServer` records what
each device called itself, because the name is the only part of a registration a
person ever reads. The gesture itself is unreachable from any test process, so
Phase 13 of docs/manual-checks.md carries five checks, including that enrolling
a new fingerprint in Android's own Settings destroys the key. That one is the
property that makes this a fast path rather than a weakening of the passphrase.
This commit is contained in:
2026-08-03 16:12:50 +02:00
parent fd8497bb76
commit 38d8706784
10 changed files with 327 additions and 20 deletions
@@ -157,7 +157,8 @@
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What this phone can and cannot do, and why." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Unlocking with a fingerprint, and what this phone cannot do." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
@@ -174,10 +174,12 @@
</Panel>
<!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent
rather than one with settings on it — and gains the header its siblings under MORE have, here
rather than inside PendingScreen: that control is shared with three states of getting in, none of
which has anywhere to go back to.
Preferences carries the header its siblings under MORE have, written here rather than inside the
screen: the screen is one of two that hang off this hub with a back arrow, and the arrow belongs
to the shell that knows where back is.
It held a PendingScreen until it had a setting on it. The one it gained is this phone's device
key — the fingerprint the lock screen offers and that nothing here could previously enrol.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
@@ -185,9 +187,7 @@
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PendingScreen Grid.Row="1"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
<views:PreferencesScreen Grid.Row="1" />
</Grid>
</Panel>
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.PreferencesScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
Design v2 — PREFERENCES, and the phone's counterpart of the desktop head's screen of the same name.
It exists for one setting: whether this phone may open the keychain with a fingerprint. That is the
setting the whole Android device key store was built for — AndroidDeviceKeyStore and BiometricGate have
been in this head since it was written — and until this screen there was nowhere to turn it on. The
lock screen offers UNLOCK WITH FINGERPRINT only when a device key exists, and nothing on the phone could
make one, so on a phone that button could never appear. This is the missing half.
Registering has to happen from an unlocked keychain and cannot happen from the lock screen, which is why
it is here rather than beside the button it enables: sealing this phone's device key needs the vault
open, and the wrap has to reach the server or it could never be revoked from a phone that was lost. See
MainWindowViewModel.RegisterDeviceAsync.
Everything the desktop's screen carries and this one does not is stated at the foot rather than left
out, exactly as the desktop states its own gaps. A preferences screen that is silent about what it has
not got reads as a product with one preference.
-->
<ScrollViewer>
<StackPanel Margin="16,0,16,24" Spacing="0">
<TextBlock Classes="section" Text="THIS PHONE" Margin="6,4,0,0" />
<!--
The offer and the withdrawal share this card and never show together: one is "there is no device key
here", the other is "there is". Neither appears on a phone that cannot hold one at all — a handset
with no screen lock — and that third case is the paragraph under them, because a card with nothing
in it would read as a setting that failed to load.
-->
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="9">
<!-- The lock screen draws the same ring beside its own fingerprint button. -->
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Unlock with a fingerprint" />
</StackPanel>
<TextBlock Classes="body"
Text="Registers this phone so a later launch can open the keychain with your fingerprint — or this phone's PIN — instead of your passphrase. The key is held by the phone's secure hardware and never leaves it, and your passphrase keeps working." />
<Button Classes="primary" Content="REGISTER THIS PHONE"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<!--
No confirmation, deliberately, and the desktop's button says the same in a tooltip this head has
no room for — so the sentence above it carries it instead. Somebody pressing this has realised a
phone is in the wrong hands, and the cost of a wrong press is one passphrase.
-->
<StackPanel Spacing="8" IsVisible="{Binding CanForgetDevice}">
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="registered · this phone can open the keychain without your passphrase" />
<Button Classes="danger" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<!--
Neither flag is set on a phone with no screen lock, and that is worth saying: Android will
generate a key that requires authentication on such a phone and then no gesture can ever release
it, which is why AndroidDeviceKeyStore reports itself unavailable rather than offering one.
-->
<TextBlock Classes="body" IsVisible="{Binding HasNoDeviceKeyOption}"
Text="This phone has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a screen lock with a fingerprint or a PIN enrolled on the phone itself." />
</StackPanel>
</Border>
<!-- Where registering, withdrawing and a declined prompt all answer. -->
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
Text="{Binding StatusMessage}" />
<TextBlock Classes="section" Text="NOT HERE" Margin="6,26,0,0" />
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="9">
<TextBlock Classes="body"
Text="Text size is on the terminal itself, where the text is — the phone puts A and A+ in the shell's own bar rather than a step away from what they resize." />
<TextBlock Classes="body"
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
<TextBlock Classes="body"
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// The one setting this phone has: whether a fingerprint may open the keychain.
/// </summary>
/// <remarks>
/// Takes the shell as its data context, like the hub it is reached from, because both commands on it are
/// the shell's — registering a device key needs the session, the server connection and the keystore, and
/// all three are the state machine's.
/// </remarks>
internal sealed partial class PreferencesScreen : UserControl
{
public PreferencesScreen() => AvaloniaXamlLoader.Load(this);
}