fix(app): the phrase is never copied, codes leave the clipboard a minute later
The recovery phrase opens the account's keys; clipboard histories and cloud clipboard sync would keep it beyond the app's reach. It is saved as a file or written down. Recovery codes and an authenticator's key may be copied, and the clipboard is emptied 60 s later if it still holds them; they are no longer selectable, as the system's own copy wouldn't empty it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7bef70948d
commit
a0b7e2bf18
4 changed files with 163 additions and 17 deletions
|
|
@ -89,6 +89,7 @@ export 'src/password/widget/account_setup_sheet.dart';
|
|||
export 'src/password/widget/password_sign_in_sheet.dart';
|
||||
export 'src/password/widget/password_texts.dart';
|
||||
export 'src/password/widget/password_widgets.dart';
|
||||
export 'src/password/widget/secret_clipboard.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
|||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
|
||||
import 'package:halkyon_app/src/password/model/phrase_check.dart';
|
||||
import 'package:halkyon_app/src/password/widget/secret_clipboard.dart';
|
||||
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -239,10 +240,12 @@ class const SheetProblem(final String text, {super.key})
|
|||
}
|
||||
}
|
||||
|
||||
/// Copy and Save for [text], kept as [fileName] when saved.
|
||||
/// Save for [text], kept as [fileName] when saved, and where [copyable]
|
||||
/// Copy, which empties the clipboard again a minute later ([copySecret]).
|
||||
class const KeepTextButtons({
|
||||
required final String text,
|
||||
required final String fileName,
|
||||
final bool copyable = true,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -252,17 +255,18 @@ class const KeepTextButtons({
|
|||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.copy),
|
||||
label: Text(l10n.passwordCopy),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(l10n.passwordCopied)),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (copyable)
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.copy),
|
||||
label: Text(l10n.passwordCopy),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await copySecret(text);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(l10n.passwordCopied)),
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.download),
|
||||
label: Text(l10n.passwordSave),
|
||||
|
|
@ -342,9 +346,11 @@ class const RecoveryPhraseView({required final String phrase, super.key})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Saved or written down, never copied ([copySecret]).
|
||||
KeepTextButtons(
|
||||
text: phrase,
|
||||
fileName: 'halkyon-recovery-phrase.txt',
|
||||
copyable: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -413,8 +419,10 @@ class const RecoveryCodesView({required final List<String> codes, super.key})
|
|||
spacing: 24,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Not selectable: Copy empties the clipboard again, the
|
||||
// system's own copy wouldn't.
|
||||
for (final code in codes)
|
||||
SelectableText(
|
||||
Text(
|
||||
code,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
|
|
@ -513,7 +521,8 @@ class const AuthenticatorSetupView({
|
|||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
// Not selectable: Copy empties the clipboard again.
|
||||
child: Text(
|
||||
setup.secret,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
|
|
@ -525,9 +534,7 @@ class const AuthenticatorSetupView({
|
|||
icon: const Icon(LucideIcons.copy),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: setup.secret.replaceAll(' ', '')),
|
||||
);
|
||||
await copySecret(setup.secret.replaceAll(' ', ''));
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(l10n.passwordCopied)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// How long something secret the app copied (recovery codes, an
|
||||
/// authenticator's key) stays on the system's clipboard: long enough to
|
||||
/// paste it into a password manager, short of what clipboard histories and
|
||||
/// other apps reading the clipboard would otherwise get to keep.
|
||||
const secretClipboardLifetime = Duration(seconds: 60);
|
||||
|
||||
/// Puts [text] on the system's clipboard, and [clearAfter] later empties
|
||||
/// the clipboard if it still holds [text] (what was copied since stays).
|
||||
///
|
||||
/// The recovery phrase is never copied (ADR 0017): it opens the account's
|
||||
/// keys, and clipboard histories (Windows, Gboard, Samsung's) and cloud
|
||||
/// clipboard sync would keep it beyond this app's reach; it is saved as a
|
||||
/// file or written down instead.
|
||||
Future<void> copySecret(
|
||||
String text, {
|
||||
Duration clearAfter = secretClipboardLifetime,
|
||||
}) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
Timer(clearAfter, () => unawaited(_clearIfStill(text)));
|
||||
}
|
||||
|
||||
Future<void> _clearIfStill(String text) async {
|
||||
try {
|
||||
final held = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (held?.text == text) {
|
||||
await Clipboard.setData(const ClipboardData(text: ''));
|
||||
}
|
||||
} on PlatformException {
|
||||
// A clipboard the system won't read or write now: nothing to clear.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
/// What the system's clipboard holds.
|
||||
String? clipboard;
|
||||
|
||||
/// Stands in for the system's clipboard for [tester].
|
||||
void mockClipboard(WidgetTester tester) {
|
||||
clipboard = null;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async => switch (call.method) {
|
||||
'Clipboard.setData' => () {
|
||||
clipboard = (call.arguments as Map)['text'] as String?;
|
||||
return null;
|
||||
}(),
|
||||
'Clipboard.getData' => {'text': clipboard},
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpView(WidgetTester tester, Widget view) async {
|
||||
tester.view.physicalSize = const Size(412, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
|
||||
child: MaterialApp(
|
||||
localizationsDelegates:
|
||||
HalkyonAppLocalizations.localizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: SingleChildScrollView(child: view)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('copySecret', () {
|
||||
testWidgets('clears the clipboard a minute later', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await copySecret('ABCD-EFGH');
|
||||
expect(clipboard, 'ABCD-EFGH');
|
||||
await tester.pump(const Duration(seconds: 59));
|
||||
expect(clipboard, 'ABCD-EFGH');
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(clipboard, '');
|
||||
});
|
||||
|
||||
testWidgets('leaves what was copied since', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await copySecret('ABCD-EFGH');
|
||||
clipboard = 'something else';
|
||||
await tester.pump(secretClipboardLifetime);
|
||||
expect(clipboard, 'something else');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the recovery phrase is saved, never copied', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await pumpView(
|
||||
tester,
|
||||
const RecoveryPhraseView(
|
||||
phrase:
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon '
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon '
|
||||
'abandon abandon abandon abandon abandon abandon abandon art',
|
||||
),
|
||||
);
|
||||
expect(find.text('Copy'), findsNothing);
|
||||
expect(find.text('Save as a file'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('recovery codes are copied, and cleared a minute later', (
|
||||
tester,
|
||||
) async {
|
||||
mockClipboard(tester);
|
||||
await pumpView(
|
||||
tester,
|
||||
const RecoveryCodesView(codes: ['AAAA-BBBB', 'CCCC-DDDD']),
|
||||
);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
await tester.tap(find.text('Copy'));
|
||||
await tester.pump();
|
||||
expect(clipboard, 'AAAA-BBBB\nCCCC-DDDD');
|
||||
await tester.pump(secretClipboardLifetime);
|
||||
expect(clipboard, '');
|
||||
// The snack bar's own time.
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue