fix(app): secrets are kept out of screenshots and the recent apps

The recovery phrase, recovery codes and an authenticator's QR code and
key now show inside a SecretScreen: on Android it holds FLAG_SECURE while
it shows (through a channel each app's MainActivity answers, counting
holds, as several may show at once; ScreenGuard.kt), so screenshots,
screen recordings and the recents view don't get them; on iOS, which has
no such flag, it covers them while the app isn't in front, so the app
switcher's picture doesn't hold them (Dart-side, not checked on a device).
Desktops do neither: a window loses focus whenever its person turns to a
password manager. Todo's debug APK builds with 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 15:09:02 +02:00
commit f3c3f7fb51
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,110 @@
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() {
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(
MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: child)),
),
);
testWidgets('keeps the screen out of screenshots and recent apps while a '
'secret shows, and lets it go after', (tester) async {
final guard = FakeScreenGuard();
await pump(
tester,
SecretScreen(guard: guard, child: const Text('the phrase')),
);
expect(guard.held, 1);
await pump(tester, const Text('something else'));
expect(guard.held, 0);
});
testWidgets('covers the secret while the app is not in front, where asked '
'(iOS, whose app switcher shows a picture of it)', (tester) async {
await pump(
tester,
SecretScreen(
guard: FakeScreenGuard(),
coversWhenAway: true,
child: const Text('the phrase'),
),
);
expect(find.byKey(SecretScreen.coverKey), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.pump();
expect(find.byKey(SecretScreen.coverKey), findsOneWidget);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(find.byKey(SecretScreen.coverKey), findsNothing);
});
testWidgets('the phrase, the recovery codes and an authenticator are each '
'shown as a secret', (tester) async {
final code = TextEditingController();
addTearDown(code.dispose);
for (final view in [
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',
),
const RecoveryCodesView(codes: ['AAAA-BBBB']),
AuthenticatorSetupView(
setup: const AuthenticatorSetup(
id: 'a',
uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
),
code: code,
),
]) {
await tester.pumpWidget(
AppScope(
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
child: MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: view)),
),
),
);
expect(find.byType(SecretScreen), findsOneWidget, reason: '$view');
}
});
testWidgets("the platform's guard asks Android's activity, counted", (
tester,
) async {
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
PlatformScreenGuard.channel,
(call) async {
calls.add(call.method);
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
PlatformScreenGuard.channel,
null,
),
);
const guard = PlatformScreenGuard(android: true);
await guard.hold();
await guard.release();
expect(calls, ['hold', 'release']);
// Elsewhere, nothing to ask.
calls.clear();
await const PlatformScreenGuard(android: false).hold();
expect(calls, isEmpty);
});
}