fix(app): a recovery phrase's and codes' files name the instance
Saved from several apps or instances, they differed by " (1)" alone. They name the instance now, without the app's word, as an operator's apps share one account: halkyon.eu-recovery-phrase.txt, localhost-8280-recovery-codes.txt. Whats-New: A saved recovery phrase or recovery codes file now names its instance. Whats-New-De: Gespeicherte Wiederherstellungsphrasen und -codes nennen jetzt ihre Instanz im Dateinamen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6ee7aeabc0
commit
2535da15de
3 changed files with 72 additions and 19 deletions
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||||
|
import 'package:halkyon_app/src/app/app_frame.dart';
|
||||||
import 'package:halkyon_app/src/app/app_scope.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/account_authenticator.dart';
|
||||||
import 'package:halkyon_app/src/password/model/phrase_check.dart';
|
import 'package:halkyon_app/src/password/model/phrase_check.dart';
|
||||||
|
|
@ -328,26 +329,46 @@ class const SheetProblem(final String text, {super.key})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save for [text], kept as [fileName]. Nothing secret is copied (the
|
/// How the file of a secret names the instance called [name] (its host, with
|
||||||
/// reading of the fixes): no app can mark a copy as sensitive through
|
/// the port if it has one), for the app called [product]: without the app's
|
||||||
|
/// word, as an operator's apps share one account (`learn.halkyon.eu`,
|
||||||
|
/// `todo.halkyon.eu`: `halkyon.eu`), and safe in a file's name
|
||||||
|
/// (`localhost:8280`: `localhost-8280`).
|
||||||
|
String instanceFileLabel(String name, {required String product}) {
|
||||||
|
final host = name.toLowerCase();
|
||||||
|
final prefix = '${product.toLowerCase()}.';
|
||||||
|
return (host.startsWith(prefix) ? host.substring(prefix.length) : host)
|
||||||
|
.replaceAll(RegExp('[^a-z0-9.-]+'), '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save for [text], in a file named after the instance and [fileKind].
|
||||||
|
/// Nothing secret is copied (the reading of the fixes): no app can mark a copy as sensitive through
|
||||||
/// Flutter, so clipboard histories (Windows, KDE, Gboard, Samsung's) and
|
/// Flutter, so clipboard histories (Windows, KDE, Gboard, Samsung's) and
|
||||||
/// cloud clipboards would keep it, and on Android an app in the background
|
/// cloud clipboards would keep it, and on Android an app in the background
|
||||||
/// can't empty the clipboard again.
|
/// can't empty the clipboard again.
|
||||||
class const KeepTextButtons({
|
class const KeepTextButtons({
|
||||||
required final String text,
|
required final String text,
|
||||||
required final String fileName,
|
|
||||||
|
/// What the file holds; its name adds the instance's, whose account it
|
||||||
|
/// belongs to whichever app showed it (`halkyon.eu-recovery-phrase.txt`).
|
||||||
|
required final String fileKind,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = HalkyonAppLocalizations.of(context);
|
final l10n = HalkyonAppLocalizations.of(context);
|
||||||
|
final dependencies = AppScope.of(context);
|
||||||
|
final instance = instanceFileLabel(
|
||||||
|
dependencies.serverName,
|
||||||
|
product: AppFrameScope.of(context).product(context),
|
||||||
|
);
|
||||||
return Align(
|
return Align(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(LucideIcons.download),
|
icon: const Icon(LucideIcons.download),
|
||||||
label: Text(l10n.passwordSave),
|
label: Text(l10n.passwordSave),
|
||||||
onPressed: () => AppScope.of(context).fileSaver.save(
|
onPressed: () => dependencies.fileSaver.save(
|
||||||
name: fileName,
|
name: '$instance-$fileKind.txt',
|
||||||
bytes: utf8.encode('$text\n'),
|
bytes: utf8.encode('$text\n'),
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
),
|
),
|
||||||
|
|
@ -426,7 +447,7 @@ class const RecoveryPhraseView({required final String phrase, super.key})
|
||||||
// Saved or written down, never copied ([KeepTextButtons]).
|
// Saved or written down, never copied ([KeepTextButtons]).
|
||||||
KeepTextButtons(
|
KeepTextButtons(
|
||||||
text: phrase,
|
text: phrase,
|
||||||
fileName: 'halkyon-recovery-phrase.txt',
|
fileKind: 'recovery-phrase',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -683,7 +704,7 @@ class const RecoveryCodesView({required final List<String> codes, super.key})
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
KeepTextButtons(
|
KeepTextButtons(
|
||||||
text: codes.join('\n'),
|
text: codes.join('\n'),
|
||||||
fileName: 'halkyon-recovery-codes.txt',
|
fileKind: 'recovery-codes',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,23 @@ void main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late TestAppDependencies dependencies;
|
||||||
|
|
||||||
Future<void> pumpView(WidgetTester tester, Widget view) async {
|
Future<void> pumpView(WidgetTester tester, Widget view) async {
|
||||||
tester.view.physicalSize = const Size(412, 1400);
|
tester.view.physicalSize = const Size(412, 1400);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
dependencies = TestAppDependencies(gateway: FakeAuthGateway());
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
AppScope(
|
AppFrameScope(
|
||||||
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
|
frame: testFrame(),
|
||||||
child: MaterialApp(
|
child: AppScope(
|
||||||
localizationsDelegates: testLocalizationsDelegates,
|
dependencies: dependencies,
|
||||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
child: MaterialApp(
|
||||||
home: Scaffold(body: SingleChildScrollView(child: view)),
|
localizationsDelegates: testLocalizationsDelegates,
|
||||||
|
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: view)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -83,6 +89,36 @@ void main() {
|
||||||
expect(clipboard, isNull);
|
expect(clipboard, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a secret's file names the instance, without the app's word, whose "
|
||||||
|
'account it belongs to whichever app showed it (5 Nov 2026)', () {
|
||||||
|
const label = instanceFileLabel;
|
||||||
|
expect(label('learn.halkyon.eu', product: 'Learn'), 'halkyon.eu');
|
||||||
|
expect(label('todo.halkyon.eu', product: 'Todo'), 'halkyon.eu');
|
||||||
|
expect(label('notes.example.org', product: 'Learn'), 'notes.example.org');
|
||||||
|
expect(label('learn.example.org', product: 'Todo'), 'learn.example.org');
|
||||||
|
expect(label('localhost:8280', product: 'Meditate'), 'localhost-8280');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("a saved phrase's file names the instance", (tester) async {
|
||||||
|
await pumpView(tester, const RecoveryPhraseView(phrase: 'one two'));
|
||||||
|
await tester.tap(find.text('Save as a file'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(dependencies.fileSaver.saved.map((file) => file.name), [
|
||||||
|
'example.org-recovery-phrase.txt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("saved recovery codes' file names the instance too", (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpView(tester, const RecoveryCodesView(codes: ['AAAA-BBBB']));
|
||||||
|
await tester.tap(find.text('Save as a file'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(dependencies.fileSaver.saved.map((file) => file.name), [
|
||||||
|
'example.org-recovery-codes.txt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
group("an authenticator's key, for a device without a camera, is copied "
|
group("an authenticator's key, for a device without a camera, is copied "
|
||||||
'and emptied again a minute later; never selectable (decided 3 Nov '
|
'and emptied again a minute later; never selectable (decided 3 Nov '
|
||||||
'2026)', () {
|
'2026)', () {
|
||||||
|
|
|
||||||
|
|
@ -111,13 +111,9 @@ void main() {
|
||||||
),
|
),
|
||||||
]) {
|
]) {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
AppScope(
|
testApp(
|
||||||
|
Scaffold(body: SingleChildScrollView(child: view)),
|
||||||
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
|
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
|
||||||
child: MaterialApp(
|
|
||||||
localizationsDelegates: testLocalizationsDelegates,
|
|
||||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
|
||||||
home: Scaffold(body: SingleChildScrollView(child: view)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(find.byType(SecretScreen), findsOneWidget, reason: '$view');
|
expect(find.byType(SecretScreen), findsOneWidget, reason: '$view');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue