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