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:
parent
3ca01915e2
commit
f3c3f7fb51
1 changed files with 509 additions and 180 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue