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:
Tizian 「ティツィアーン」 2026-10-21 14:31:19 +02:00
commit a0b7e2bf18
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 163 additions and 17 deletions

View file

@ -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';

View file

@ -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)),
);

View file

@ -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.
}
}

View file

@ -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();
});
}