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
10 changed files with 509 additions and 180 deletions

View file

@ -19,6 +19,7 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger val messenger = flutterEngine.dartExecutor.binaryMessenger
PomodoroService.appBack() PomodoroService.appBack()
ScreenGuard.attach(this, messenger)
// A question searched with the device's own web search (its default // A question searched with the device's own web search (its default
// browser or search app answers ACTION_WEB_SEARCH); false where none does, // browser or search app answers ACTION_WEB_SEARCH); false where none does,
// and the app opens a search engine's address instead. // and the app opens a search engine's address instead.

View file

@ -0,0 +1,36 @@
package app.halkyon.learn
import android.app.Activity
import android.view.WindowManager
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Keeps the window's content out of screenshots, screen recordings and the
* recents view (FLAG_SECURE) while halkyon_app shows something secret: the
* recovery phrase, recovery codes, an authenticator's key. Its SecretScreen
* holds as each shows and releases as it goes; counted, as several may show
* at once.
*/
object ScreenGuard {
fun attach(activity: Activity, messenger: BinaryMessenger) {
var holds = 0
MethodChannel(messenger, "app.halkyon/screen_guard").setMethodCallHandler { call, result ->
when (call.method) {
"hold" -> {
holds++
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
result.success(null)
}
"release" -> {
holds = maxOf(0, holds - 1)
if (holds == 0) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -1,5 +1,12 @@
package app.halkyon.todo package app.halkyon.todo
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
}
}

View file

@ -0,0 +1,36 @@
package app.halkyon.todo
import android.app.Activity
import android.view.WindowManager
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Keeps the window's content out of screenshots, screen recordings and the
* recents view (FLAG_SECURE) while halkyon_app shows something secret: the
* recovery phrase, recovery codes, an authenticator's key. Its SecretScreen
* holds as each shows and releases as it goes; counted, as several may show
* at once.
*/
object ScreenGuard {
fun attach(activity: Activity, messenger: BinaryMessenger) {
var holds = 0
MethodChannel(messenger, "app.halkyon/screen_guard").setMethodCallHandler { call, result ->
when (call.method) {
"hold" -> {
holds++
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
result.success(null)
}
"release" -> {
holds = maxOf(0, holds - 1)
if (holds == 0) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -90,6 +90,7 @@ export 'src/password/widget/password_sign_in_sheet.dart';
export 'src/password/widget/password_texts.dart'; export 'src/password/widget/password_texts.dart';
export 'src/password/widget/password_widgets.dart'; export 'src/password/widget/password_widgets.dart';
export 'src/password/widget/secret_clipboard.dart'; export 'src/password/widget/secret_clipboard.dart';
export 'src/password/widget/secret_screen.dart';
export 'src/person/person_folders.dart'; export 'src/person/person_folders.dart';
export 'src/sync/app_compatibility.dart'; export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart'; export 'src/sync/bloc/sync_status_bloc.dart';

View file

@ -7,6 +7,7 @@ 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';
import 'package:halkyon_app/src/password/widget/secret_clipboard.dart'; import 'package:halkyon_app/src/password/widget/secret_clipboard.dart';
import 'package:halkyon_app/src/password/widget/secret_screen.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength; import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -292,71 +293,74 @@ class const RecoveryPhraseView({required final String phrase, super.key})
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final words = wordsOf(phrase); final words = wordsOf(phrase);
return Column( // Kept out of screenshots and recent apps while it shows.
crossAxisAlignment: CrossAxisAlignment.stretch, return SecretScreen(
children: [ child: Column(
DecoratedBox( crossAxisAlignment: CrossAxisAlignment.stretch,
decoration: BoxDecoration( children: [
color: theme.colorScheme.surfaceContainerHighest, DecoratedBox(
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), color: theme.colorScheme.surfaceContainerHighest,
child: Padding( borderRadius: BorderRadius.circular(12),
padding: const EdgeInsets.all(12), ),
child: LayoutBuilder( child: Padding(
builder: (context, constraints) { padding: const EdgeInsets.all(12),
final columns = constraints.maxWidth >= 480 ? 4 : 2; child: LayoutBuilder(
final rows = (words.length / columns).ceil(); builder: (context, constraints) {
return Row( final columns = constraints.maxWidth >= 480 ? 4 : 2;
crossAxisAlignment: CrossAxisAlignment.start, final rows = (words.length / columns).ceil();
children: [ return Row(
for (var column = 0; column < columns; column++) crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( for (var column = 0; column < columns; column++)
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: Column(
for ( crossAxisAlignment: CrossAxisAlignment.start,
var i = column * rows; children: [
i < (column + 1) * rows && i < words.length; for (
i++ var i = column * rows;
) i < (column + 1) * rows && i < words.length;
Padding( i++
padding: const EdgeInsets.symmetric( )
vertical: 2, Padding(
), padding: const EdgeInsets.symmetric(
child: Text.rich( vertical: 2,
TextSpan( ),
children: [ child: Text.rich(
TextSpan( TextSpan(
text: '${i + 1}. ', children: [
style: TextStyle( TextSpan(
color: theme text: '${i + 1}. ',
.colorScheme style: TextStyle(
.onSurfaceVariant, color: theme
), .colorScheme
), .onSurfaceVariant,
TextSpan(text: words[i]), ),
], ),
TextSpan(text: words[i]),
],
),
style: theme.textTheme.bodyLarge,
), ),
style: theme.textTheme.bodyLarge,
), ),
), ],
], ),
), ),
), ],
], );
); },
}, ),
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), // Saved or written down, never copied ([copySecret]).
// Saved or written down, never copied ([copySecret]). KeepTextButtons(
KeepTextButtons( text: phrase,
text: phrase, fileName: 'halkyon-recovery-phrase.txt',
fileName: 'halkyon-recovery-phrase.txt', copyable: false,
copyable: false, ),
), ],
], ),
); );
} }
} }
@ -446,39 +450,42 @@ class const RecoveryCodesView({required final List<String> codes, super.key})
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Column( // Kept out of screenshots and recent apps while it shows.
crossAxisAlignment: CrossAxisAlignment.stretch, return SecretScreen(
children: [ child: Column(
DecoratedBox( crossAxisAlignment: CrossAxisAlignment.stretch,
decoration: BoxDecoration( children: [
color: theme.colorScheme.surfaceContainerHighest, DecoratedBox(
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), color: theme.colorScheme.surfaceContainerHighest,
child: Padding( borderRadius: BorderRadius.circular(12),
padding: const EdgeInsets.all(12), ),
child: Wrap( child: Padding(
spacing: 24, padding: const EdgeInsets.all(12),
runSpacing: 8, child: Wrap(
children: [ spacing: 24,
// Not selectable: Copy empties the clipboard again, the runSpacing: 8,
// system's own copy wouldn't. children: [
for (final code in codes) // Not selectable: Copy empties the clipboard again, the
Text( // system's own copy wouldn't.
code, for (final code in codes)
style: theme.textTheme.bodyLarge?.copyWith( Text(
fontFeatures: const [FontFeature.tabularFigures()], code,
style: theme.textTheme.bodyLarge?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
), ),
), ],
], ),
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), KeepTextButtons(
KeepTextButtons( text: codes.join('\n'),
text: codes.join('\n'), fileName: 'halkyon-recovery-codes.txt',
fileName: 'halkyon-recovery-codes.txt', ),
), ],
], ),
); );
} }
} }
@ -553,108 +560,114 @@ class const AuthenticatorSetupView({
// Only an otpauth URI is shown as a QR code or opened ([AuthenticatorSetup.isAuthenticatorUri]). // Only an otpauth URI is shown as a QR code or opened ([AuthenticatorSetup.isAuthenticatorUri]).
final opens = setup.isAuthenticatorUri; final opens = setup.isAuthenticatorUri;
final phone = opens && (Platform.isAndroid || Platform.isIOS); final phone = opens && (Platform.isAndroid || Platform.isIOS);
return Column( // Kept out of screenshots and recent apps while it shows.
crossAxisAlignment: CrossAxisAlignment.stretch, return SecretScreen(
children: [ child: Column(
SheetIntro(l10n.passwordAuthenticatorIntro), crossAxisAlignment: CrossAxisAlignment.stretch,
if (opens) ...[ children: [
Center(child: QrCodeView(data: setup.uri)), SheetIntro(l10n.passwordAuthenticatorIntro),
const SizedBox(height: 12), if (opens) ...[
], Center(child: QrCodeView(data: setup.uri)),
Text(l10n.passwordAuthenticatorKey, style: theme.textTheme.labelLarge), const SizedBox(height: 12),
const SizedBox(height: 4), ],
Row( Text(
children: [ l10n.passwordAuthenticatorKey,
Expanded( style: theme.textTheme.labelLarge,
// Not selectable: Copy empties the clipboard again. ),
child: Text( const SizedBox(height: 4),
setup.secret, Row(
style: theme.textTheme.bodyLarge?.copyWith( children: [
fontFeatures: const [FontFeature.tabularFigures()], Expanded(
// Not selectable: Copy empties the clipboard again.
child: Text(
setup.secret,
style: theme.textTheme.bodyLarge?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
), ),
), ),
), IconButton(
IconButton( tooltip: l10n.passwordCopy,
tooltip: l10n.passwordCopy, icon: const Icon(LucideIcons.copy),
icon: const Icon(LucideIcons.copy), onPressed: () async {
onPressed: () async { final messenger = ScaffoldMessenger.maybeOf(context);
final messenger = ScaffoldMessenger.maybeOf(context); await copySecret(setup.secret.replaceAll(' ', ''));
await copySecret(setup.secret.replaceAll(' ', '')); messenger?.showSnackBar(
messenger?.showSnackBar( SnackBar(content: Text(l10n.passwordCopied)),
SnackBar(content: Text(l10n.passwordCopied)), );
); },
}, ),
],
),
if (phone) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.externalLink),
label: Text(l10n.passwordAuthenticatorOpen),
onPressed: () async {
final messenger = ScaffoldMessenger.maybeOf(context);
final opened = await launchUrl(
Uri.parse(setup.uri),
mode: LaunchMode.externalApplication,
);
if (!opened) {
messenger?.showSnackBar(
SnackBar(content: Text(l10n.passwordAuthenticatorNoApp)),
);
}
},
),
), ),
], ],
),
if (phone) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.externalLink),
label: Text(l10n.passwordAuthenticatorOpen),
onPressed: () async {
final messenger = ScaffoldMessenger.maybeOf(context);
final opened = await launchUrl(
Uri.parse(setup.uri),
mode: LaunchMode.externalApplication,
);
if (!opened) {
messenger?.showSnackBar(
SnackBar(content: Text(l10n.passwordAuthenticatorNoApp)),
);
}
},
),
),
],
const SizedBox(height: 16),
Text(l10n.passwordAuthenticatorCodeIntro),
const SizedBox(height: 8),
CodeField(
controller: code,
kind: CodeKind.totp,
enabled: enabled,
errorText: errorText,
textInputAction: password == null && existingCode == null
? TextInputAction.done
: TextInputAction.next,
onSubmitted: password == null && existingCode == null
? (_) => onDone?.call()
: null,
),
if (password case final password?) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(l10n.passwordAuthenticatorCodeIntro),
existingCode == null
? l10n.passwordAuthenticatorPasswordIntro
: l10n.passwordAuthenticatorOwnerIntro,
),
const SizedBox(height: 8),
PasswordField(
controller: password,
label: l10n.passwordCurrent,
enabled: enabled,
errorText: existingCode == null ? existingErrorText : null,
textInputAction: existingCode == null
? TextInputAction.done
: TextInputAction.next,
onSubmitted: existingCode == null ? (_) => onDone?.call() : null,
),
],
if (existingCode case final existing?) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
CodeField( CodeField(
controller: existing, controller: code,
kind: CodeKind.totp, kind: CodeKind.totp,
label: l10n.passwordExistingCode,
enabled: enabled, enabled: enabled,
errorText: existingErrorText, errorText: errorText,
onSubmitted: (_) => onDone?.call(), textInputAction: password == null && existingCode == null
? TextInputAction.done
: TextInputAction.next,
onSubmitted: password == null && existingCode == null
? (_) => onDone?.call()
: null,
), ),
if (password case final password?) ...[
const SizedBox(height: 16),
Text(
existingCode == null
? l10n.passwordAuthenticatorPasswordIntro
: l10n.passwordAuthenticatorOwnerIntro,
),
const SizedBox(height: 8),
PasswordField(
controller: password,
label: l10n.passwordCurrent,
enabled: enabled,
errorText: existingCode == null ? existingErrorText : null,
textInputAction: existingCode == null
? TextInputAction.done
: TextInputAction.next,
onSubmitted: existingCode == null ? (_) => onDone?.call() : null,
),
],
if (existingCode case final existing?) ...[
const SizedBox(height: 8),
CodeField(
controller: existing,
kind: CodeKind.totp,
label: l10n.passwordExistingCode,
enabled: enabled,
errorText: existingErrorText,
onSubmitted: (_) => onDone?.call(),
),
],
], ],
], ),
); );
} }
} }

View file

@ -0,0 +1,111 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
/// Keeps the window's content out of screenshots, screen recordings and
/// the system's view of recent apps while something secret shows (the
/// recovery phrase, recovery codes, an authenticator's key), held as each
/// shows and released as it goes.
abstract interface class ScreenGuard() {
/// One more secret shows.
Future<void> hold();
/// One secret less shows.
Future<void> release();
}
/// [ScreenGuard] through Android's `FLAG_SECURE`, which each app's
/// `MainActivity` sets while any is held (`ScreenGuard.kt`, counting them,
/// as several may show at once); nothing to do elsewhere: iOS has no such
/// flag (its app switcher's picture is covered by [SecretScreen] instead),
/// and desktops none either.
class const PlatformScreenGuard({final bool? android}) implements ScreenGuard {
/// The channel each app's activity answers.
static const channel = MethodChannel('app.halkyon/screen_guard');
bool get _android => android ?? Platform.isAndroid;
@override
Future<void> hold() => _ask('hold');
@override
Future<void> release() => _ask('release');
Future<void> _ask(String method) async {
if (!_android) {
return;
}
try {
await channel.invokeMethod<void>(method);
} on MissingPluginException {
// An app without the channel: nothing kept out.
} on PlatformException {
// A window that won't take the flag: nothing kept out.
}
}
}
/// [child], something secret, kept out of screenshots and recent apps while
/// it shows ([guard]); and where [coversWhenAway] (iOS, whose app switcher
/// shows a picture of the app taken as it leaves), covered while the app
/// isn't in front.
class const SecretScreen({
required final Widget child,
final ScreenGuard guard = const PlatformScreenGuard(),
/// Whether it covers [child] while the app isn't in front; by default on
/// iOS alone. Not on desktops, where a window loses focus whenever the
/// person turns to another (to type a code into a password manager), nor
/// on Android, whose flag keeps it out already.
final bool? coversWhenAway,
super.key,
}) extends StatefulWidget {
/// The cover, found by tests.
static const coverKey = Key('secret-screen-cover');
@override
State<SecretScreen> createState() => _SecretScreenState();
}
class _SecretScreenState() extends State<SecretScreen> {
late final AppLifecycleListener _lifecycle;
var _away = false;
@override
void initState() {
super.initState();
widget.guard.hold();
_lifecycle = AppLifecycleListener(
onStateChange: (state) {
final away = state != AppLifecycleState.resumed;
if (away != _away && mounted) {
setState(() => _away = away);
}
},
);
}
@override
void dispose() {
_lifecycle.dispose();
widget.guard.release();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!(_away && (widget.coversWhenAway ?? Platform.isIOS))) {
return widget.child;
}
return Stack(
children: [
widget.child,
Positioned.fill(
key: SecretScreen.coverKey,
child: ColoredBox(color: Theme.of(context).colorScheme.surface),
),
],
);
}
}

View file

@ -0,0 +1,13 @@
import 'package:halkyon_app/src/password/widget/secret_screen.dart';
/// [ScreenGuard] counting what is held, as a test reads it.
class FakeScreenGuard() implements ScreenGuard {
/// How many secrets show.
int held = 0;
@override
Future<void> hold() async => held++;
@override
Future<void> release() async => held--;
}

View file

@ -7,6 +7,7 @@ export 'src/testing/fake_account_crypto.dart';
export 'src/testing/fake_auth_gateway.dart'; export 'src/testing/fake_auth_gateway.dart';
export 'src/testing/fake_instance_checker.dart'; export 'src/testing/fake_instance_checker.dart';
export 'src/testing/fake_password_gateway.dart'; export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_screen_guard.dart';
export 'src/testing/memory_account_key_store.dart'; export 'src/testing/memory_account_key_store.dart';
export 'src/testing/memory_file_saver.dart'; export 'src/testing/memory_file_saver.dart';
export 'src/testing/memory_instance_store.dart'; export 'src/testing/memory_instance_store.dart';

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