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
10 changed files with 509 additions and 180 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
packages/halkyon_app/lib/src/password/widget/secret_screen.dart
Normal file
111
packages/halkyon_app/lib/src/password/widget/secret_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/halkyon_app/lib/src/testing/fake_screen_guard.dart
Normal file
13
packages/halkyon_app/lib/src/testing/fake_screen_guard.dart
Normal 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--;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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