Public Access
Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that would drive it, so the two met in the same three files. Every other conflict was textual and resolved by taking both; these are the ones where a decision had to be made. **The view model.** Main taught TeamsViewModel three things and this branch had renamed and rewritten it into VaultsViewModel. All three are ported rather than dropped, because each is a behaviour rather than wording: adding somebody now wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed, removing somebody rotates the vault and hands the new key to whoever is left, and a share reports how many generations were wrapped. The session calls they reach — ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list rather than to one vault, and they are called that way here rather than narrowed: adding somebody is a change to the list, so every vault the list carries is one they can now fetch. This screen makes lists that carry one vault, so the sentences name one; where a list carries several, naming them all is the honest report, and the members section already says the list is shared. AddMemberAsync ran two lines over the length limit once the sharing was in it, so the calls behind it moved to AddOrInviteAsync and the three-way refusal to WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was before the sharing arrived. **The tests.** Main's four new cases are ported to the vault-first API, including the one that matters most: the tampered key log is corrupted *before* the add, because the add is now a route to a wrap and a test that corrupted it afterwards would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey now expects two holders rather than one — main's fake records the creator's own self-grant, and a key-holder list that omitted it would show the one person who can certainly open a new vault as somebody who cannot. **The README.** The limits list is six rather than four or five: main's rotation entries and this branch's "a vault cannot be deleted" describe different things and both are true. "The rekey is flagged, never performed" is gone, since it is now performed, and M3 reads *Done* rather than *Done, except rekey*. One thing worth writing down that neither side had. An invitation claimed at sign-in still leaves the key owed, where an add does not: at the moment an invitation is issued there is no account and no published key to wrap to, and the claim happens on the invitee's machine, which holds nothing. Manual check 12.1 says so, because a reader who knows adding shares would otherwise read that step as stale. 1561 tests pass.
This commit is contained in:
@@ -19,17 +19,28 @@
|
||||
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
||||
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
||||
|
||||
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
|
||||
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
|
||||
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
|
||||
application's own private directory. A download would report success and put the file somewhere the
|
||||
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
|
||||
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
|
||||
docs/android-port.md decided on and is the next piece of work.
|
||||
◆ **ADD FILES is the way in, and it is the system document picker rather than an UPLOAD button.** There
|
||||
is nothing local to select from, so the gesture cannot be "choose on the left, press the arrow": it is
|
||||
"point at a document wherever it lives, and it goes to the directory showing". What Android hands back
|
||||
is a `content://` URI, so `DocumentStaging` copies it into this application's cache and queues the copy —
|
||||
the queue needs a path, a length and a seek, and a document URI promises none of the three. See that
|
||||
class for why the copy is a requirement rather than a shortcut, and `QueueStagedUploads` for when it is
|
||||
deleted again.
|
||||
|
||||
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
|
||||
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
|
||||
screen is opened; it is simply not something this head can start yet.
|
||||
◆ **SAVE FILE is the way out, and it is the save picker rather than a DOWNLOAD button.** `QueueDownloads`
|
||||
writes to `Path.Combine(LocalPath, name)`, and `LocalPath` on Android is the application's own private
|
||||
directory — a download that way would report success and leave the file where the person who asked for
|
||||
it cannot open it. So this head does not use it: `QueueDeliveredDownload` runs the transfer into the
|
||||
cache and hands the finished bytes to the document `ACTION_CREATE_DOCUMENT` made.
|
||||
|
||||
The destination is chosen *before* the transfer, which is a decision with a visible cost — the picker
|
||||
creates the document when it is dismissed, so a download that then fails leaves an empty file where it
|
||||
was pointed. The alternative is a picker raised minutes later, over whatever the person moved on to and
|
||||
frequently while this application is backgrounded, where Android will not show one at all.
|
||||
|
||||
So what ships is browsing a remote and moving files both ways, plus the two remote-side operations that
|
||||
need nothing local — opening a directory and deleting. The queue is drawn under the actions, because
|
||||
this head can now fill it.
|
||||
|
||||
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
||||
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
||||
@@ -255,6 +266,70 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||
phone is how a screen becomes unusable rather than merely tall.
|
||||
-->
|
||||
<ScrollViewer MaxHeight="164" IsVisible="{Binding HasTransfers}"
|
||||
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="14,*,Auto" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12" Text="{Binding Arrow}"
|
||||
VerticalAlignment="Center" Foreground="{StaticResource AccentText}" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="8,0" Spacing="3">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<ProgressBar Height="3" Minimum="0" Maximum="100" Value="{Binding Percent}"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
One button per row, never two: whichever of the three applies to the state it is in.
|
||||
A phone row has space for a name, a bar and one 44-pixel target, and the three are
|
||||
mutually exclusive by construction — IsRunning and CanRetry cannot both hold.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
||||
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="STOP"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="{Binding RetryLabel}"
|
||||
IsVisible="{Binding CanRetry}"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Classes="secondary" Height="40" Content="CLEAR FINISHED" HorizontalAlignment="Stretch"
|
||||
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
||||
|
||||
<!--
|
||||
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
||||
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
||||
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
||||
screen exists for on a phone.
|
||||
|
||||
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
@@ -263,18 +338,11 @@
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
Text="{Binding Status}" />
|
||||
|
||||
<!--
|
||||
There is no queue on this screen, and that follows from the note at the top rather than being a
|
||||
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
|
||||
empty for every possible state of the application. It comes back with the document picker, along
|
||||
with the two buttons that would fill it.
|
||||
-->
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
@@ -29,8 +30,9 @@ internal sealed partial class FilesScreen : UserControl
|
||||
/// fires after the list has moved its selection, which is what lets this read it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
|
||||
/// note about the document picker at the top of the screen — and the actions below act on the selection.
|
||||
/// A file is left selected rather than downloaded, and that is deliberate now rather than forced: the
|
||||
/// actions below act on the selection, and a tap that started a transfer would make selecting a row to
|
||||
/// read its size the same gesture as fetching it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||
@@ -40,4 +42,93 @@ internal sealed partial class FilesScreen : UserControl
|
||||
transfers.OpenRemoteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks documents in the system picker and queues them for upload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// In the head rather than in the shared view model, for the reason every other platform difference is:
|
||||
/// the picker is Android's, the staging directory is this application's cache, and the desktop reaches
|
||||
/// its local files by browsing a pane that does not exist here. What crosses back into shared code is
|
||||
/// what the queue understands — paths — through <see cref="TransfersViewModel.QueueStagedUploads"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failures land in the screen's own status line</b>, which is where every other refusal on this
|
||||
/// screen already is. The picker itself is a trip out to another application, and it can come back with
|
||||
/// a document that has since been deleted or a grant that was revoked; the exception's message is more
|
||||
/// use than "the upload failed", and a phone has nowhere else to put it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async void OnAddFiles(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var staged = await DocumentStaging.PickAsync(top, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
if (staged.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transfers.QueueStagedUploads(staged);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
transfers.Status = $"Those files could not be read: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks where the chosen remote file should be saved, then queues it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other direction, and the asymmetry with <see cref="OnAddFiles"/> is the platform's rather than
|
||||
/// this screen's: coming in, several documents can be pointed at in one trip; going out, the save picker
|
||||
/// names one destination, so this acts on the selected row. Asking for five destinations in a row to
|
||||
/// download five files would be a worse screen than pressing the button five times.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is queued when the picker is dismissed. The document it makes when it is *not* dismissed
|
||||
/// exists from that moment, which is why the queueing follows immediately — see
|
||||
/// <c>DocumentStaging.PickDestinationAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async void OnSaveFile(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
|
||||
{
|
||||
transfers.Status = "Choose a file on the host to save.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await DocumentStaging.PickDestinationAsync(top, row.Name).ConfigureAwait(true)
|
||||
is not { } destination)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transfers.QueueDeliveredDownload(
|
||||
row,
|
||||
DocumentStaging.NewStagingPath(row.Name),
|
||||
path => DocumentStaging.DeliverAsync(destination, path));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
transfers.Status = $"That file could not be saved: {exception.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user