diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 3e986e105..2fe988830 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -178,6 +178,36 @@ import UIKit } } + // MethodChannel for the native screenshot/recording privacy shield — + // mirrors the macOS `PrivacyExposureChannel` contract on the shared + // `privacy_shield` channel. iOS only needs the window-blanking half. + let privacyShieldChannel = FlutterMethodChannel( + name: "com.zcash.wallet/privacy_shield", + binaryMessenger: messenger + ) + privacyShieldChannel.setMethodCallHandler { (call, result) in + switch call.method { + case "setSensitiveContentVisible": + guard + let args = call.arguments as? [String: Any], + let visible = args["visible"] as? Bool + else { + result( + FlutterError( + code: "bad_args", + message: "Expected visible argument.", + details: nil + ) + ) + return + } + SecureScreenshotShield.shared.setSensitiveContentVisible(visible) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + // EventChannel for sync progress (Swift → Dart) let eventChannel = FlutterEventChannel( name: "com.zcash.wallet/sync_progress", @@ -244,3 +274,188 @@ class ScreenshotStreamHandler: NSObject, FlutterStreamHandler { return nil } } + +/// Blanks the whole app window in OS screenshots and screen recordings while a +/// sensitive screen (secret passphrase / import) is showing. +/// +/// Uses the canonical `isSecureTextEntry` layer trick: the key window's layer +/// is re-parented into a hidden secure `UITextField`'s canvas layer, which +/// iOS excludes from any capture. Toggling `isSecureTextEntry` then blanks or +/// reveals the window without touching the layer tree again. +/// +/// Every step is a defensive no-op on failure (no key window yet, missing +/// superlayer, missing canvas layer). If the private UIKit layout this relies +/// on changes in a future iOS release, the app degrades to its prior behavior +/// (the post-capture screenshot warning sheet) instead of crashing. +/// +/// Lives in this file so it needs no `project.pbxproj` entry, matching +/// `ScreenshotStreamHandler`. +final class SecureScreenshotShield { + static let shared = SecureScreenshotShield() + + // Ported from no_screenshot's open-source iOS-26 technique. The secure-canvas + // capture exclusion already worked (stills came out black); only geometry was + // broken on iOS 26.5. Two fixes vs the old code: (1) find the canvas by the + // secure field's private CANVAS SUBVIEW class name instead of sublayer index + // (index `.last` grabbed a small offset aux layer on iOS 26.5), and (2) re-pin + // the reparented window layer to full window bounds so it no longer collapses + // into a corner. The flag stays as the single kill switch, and the screenshot + // warning sheet remains the permanent fallback if a future iOS breaks the + // private-layer layout this relies on. + private static let isNativeBlankingEnabled = true + + private let secureField = UITextField() + private var isLayerAttached = false + private weak var shieldedWindow: UIWindow? + private weak var canvasLayer: CALayer? + private var geometryObservers: [NSObjectProtocol] = [] + + private init() {} + + /// Idempotent: repeated calls with the same value only toggle the flag, and + /// the one-time layer setup runs at most once even across Dart hot restarts. + func setSensitiveContentVisible(_ visible: Bool) { + guard Self.isNativeBlankingEnabled else { return } + // MethodChannel callbacks land on the main thread, but never assume it for + // UIKit access — hop explicitly. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.attachLayerIfNeeded() + // If the layer could not be attached (no window yet), a later call + // retries; nothing is toggled until the trick is wired up. + guard self.isLayerAttached else { return } + self.reassertWindowGeometry() + self.secureField.isSecureTextEntry = visible + } + } + + private func attachLayerIfNeeded() { + // Re-graft if not attached, or if the window we grafted into is gone or is + // no longer the key window — a UIScene can reconnect and hand us a fresh + // UIWindow. Without this, `isLayerAttached` would latch to a dead window and + // silently stop blanking: a screenshot would then capture the secret in + // plaintext with no error and no fallback. + if isLayerAttached { + if let attached = shieldedWindow, attached === Self.keyWindow() { + return + } + removeGeometryObservers() + isLayerAttached = false + shieldedWindow = nil + canvasLayer = nil + } + guard let window = Self.keyWindow() else { return } + + secureField.isUserInteractionEnabled = false + secureField.translatesAutoresizingMaskIntoConstraints = false + // Stop a rightward shift under RTL device languages. + secureField.semanticContentAttribute = .forceLeftToRight + secureField.textAlignment = .left + + // Build the field's internal (canvas) layer tree, then detach the field as a + // SUBVIEW so we never create a circular view hierarchy (an iOS 26 crash + // trap); only the LAYERS are grafted below. + window.addSubview(secureField) + secureField.layoutIfNeeded() + secureField.removeFromSuperview() + + // Only re-parent once every dependency is present, so a partial failure + // leaves the window untouched. + guard let superlayer = window.layer.superlayer else { return } + guard let canvas = Self.secureCanvasLayer(of: secureField) else { return } + + // Zero the container so the reparented window layer inherits no offset. + secureField.layer.frame = .zero + secureField.layer.masksToBounds = false + canvas.masksToBounds = false + + superlayer.addSublayer(secureField.layer) + canvas.addSublayer(window.layer) + + shieldedWindow = window + canvasLayer = canvas + isLayerAttached = true + + reassertWindowGeometry() + installGeometryObservers() + } + + /// Robust canvas identification: prefer the private secure-text canvas subview + /// by class name (stable across iOS 15..26, unlike the sublayer index), then + /// the largest-frame sublayer, then the historical index heuristic. + private static func secureCanvasLayer(of field: UITextField) -> CALayer? { + if let byName = field.subviews.first(where: { + String(describing: type(of: $0)).contains("CanvasView") + }) { + return byName.layer + } + if let biggest = field.layer.sublayers?.max(by: { + ($0.bounds.width * $0.bounds.height) < ($1.bounds.width * $1.bounds.height) + }) { + return biggest + } + if #available(iOS 17.0, *) { return field.layer.sublayers?.last } + return field.layer.sublayers?.first + } + + /// Force the reparented window layer (and the canvas above it) back to full + /// window bounds at origin zero. UIKit re-lays the window layer on + /// rotation/scene changes, so this is re-run from the observers and before + /// each visibility toggle. + private func reassertWindowGeometry() { + guard let window = shieldedWindow, let canvas = canvasLayer else { return } + let full = CGRect(origin: .zero, size: window.bounds.size) + CATransaction.begin() + CATransaction.setDisableActions(true) + canvas.frame = full + canvas.masksToBounds = false + window.layer.frame = full + CATransaction.commit() + } + + private func installGeometryObservers() { + guard geometryObservers.isEmpty else { return } + let nc = NotificationCenter.default + let reassert: (Notification) -> Void = { [weak self] _ in + DispatchQueue.main.async { + guard let self else { return } + // A scene reconnect can swap in a fresh key window while a secret is + // still on screen, and Dart will not re-send setSensitiveContentVisible + // (the token set is unchanged). Re-graft to the live window here — a + // no-op when the window is unchanged — before re-pinning geometry, so + // the new window is inside the secure canvas and stays blanked. + if self.isLayerAttached { self.attachLayerIfNeeded() } + self.reassertWindowGeometry() + } + } + geometryObservers = [ + nc.addObserver( + forName: UIDevice.orientationDidChangeNotification, + object: nil, queue: .main, using: reassert + ), + nc.addObserver( + forName: UIScene.didActivateNotification, + object: nil, queue: .main, using: reassert + ), + nc.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, queue: .main, using: reassert + ), + ] + } + + private func removeGeometryObservers() { + let nc = NotificationCenter.default + for observer in geometryObservers { + nc.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private static func keyWindow() -> UIWindow? { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} diff --git a/lib/src/core/platform/screenshot_observer.dart b/lib/src/core/platform/screenshot_observer.dart index 265e4664d..ed745b3cd 100644 --- a/lib/src/core/platform/screenshot_observer.dart +++ b/lib/src/core/platform/screenshot_observer.dart @@ -5,14 +5,22 @@ import 'package:flutter/services.dart'; const _channel = EventChannel('com.zcash.wallet/screenshots'); +// Cache the broadcast stream so stacked screens (e.g. paste import → manual +// import, both mounted) share ONE native subscription. Calling +// receiveBroadcastStream() per listener creates competing listen/cancel pairs +// on the single-listener native handler, so popping the top route would tear +// down the handler and silently stop the still-mounted route's events. +Stream? _broadcast; + /// Emits whenever the OS reports a user screenshot (iOS only — Android -/// would use FLAG_SECURE instead and never emits here). Errors from a -/// missing host handler (tests, other platforms) are swallowed so -/// listeners only ever see real events. +/// uses FLAG_SECURE and never emits here). iOS now also blanks the window +/// via the secure-field privacy shield (see `SecureScreenshotShield`), but +/// keeps this stream as a secondary UX: the OS only reports the capture +/// after it happens, so the warning sheet explains why the shot is blank. +/// Errors from a missing host handler (tests, other platforms) are +/// swallowed so listeners only ever see real events. Stream screenshotEvents() { if (kIsWeb || !Platform.isIOS) return const Stream.empty(); - return _channel - .receiveBroadcastStream() - .map((_) {}) - .handleError((Object _) {}); + final broadcast = _broadcast ??= _channel.receiveBroadcastStream(); + return broadcast.map((_) {}).handleError((Object _) {}); } diff --git a/lib/src/core/privacy/route_coverage_aware.dart b/lib/src/core/privacy/route_coverage_aware.dart new file mode 100644 index 000000000..19fb50d9a --- /dev/null +++ b/lib/src/core/privacy/route_coverage_aware.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; + +/// Tracks whether this route has been fully covered by a route pushed on top. +/// +/// A screen that shows a secret drives a global native screenshot shield +/// (`SensitivePrivacyOverlay` → Android `FLAG_SECURE` / iOS secure-field +/// blanking). When it pushes the next step, it must drop that token so the +/// pushed, non-secret screens are not blanked — but only *after* the secret +/// screen is off-screen. Keying the drop off the route's `secondaryAnimation` +/// (rather than the push/pop `Future`) keeps the shield engaged through the +/// entire push slide-out and pop slide-in, so the secret is never visible +/// unblanked during a transition. +mixin RouteCoverageAware on State { + Animation? _secondaryAnimation; + bool _coveredByNextRoute = false; + + /// True only once the next route has fully slid over this one. False while + /// this route is on top and throughout both the push and pop transitions. + bool get isCoveredByNextRoute => _coveredByNextRoute; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final secondary = ModalRoute.of(context)?.secondaryAnimation; + if (identical(secondary, _secondaryAnimation)) return; + _secondaryAnimation?.removeStatusListener(_onSecondaryStatus); + _secondaryAnimation = secondary; + _secondaryAnimation?.addStatusListener(_onSecondaryStatus); + // Set directly — build runs right after didChangeDependencies; the listener + // uses setState for later status changes. + _coveredByNextRoute = secondary?.status == AnimationStatus.completed; + } + + void _onSecondaryStatus(AnimationStatus status) { + final covered = status == AnimationStatus.completed; + if (covered == _coveredByNextRoute || !mounted) return; + setState(() => _coveredByNextRoute = covered); + } + + @override + void dispose() { + _secondaryAnimation?.removeStatusListener(_onSecondaryStatus); + super.dispose(); + } +} diff --git a/lib/src/core/privacy/sensitive_privacy_overlay.dart b/lib/src/core/privacy/sensitive_privacy_overlay.dart index a765aa7e4..076af36d6 100644 --- a/lib/src/core/privacy/sensitive_privacy_overlay.dart +++ b/lib/src/core/privacy/sensitive_privacy_overlay.dart @@ -19,6 +19,7 @@ bool get _supportsNativePrivacyShield => supportsNativePrivacyShield( isWeb: kIsWeb, isMacOS: !kIsWeb && Platform.isMacOS, isAndroid: !kIsWeb && Platform.isAndroid, + isIOS: !kIsWeb && Platform.isIOS, ); @visibleForTesting @@ -31,13 +32,23 @@ bool supportsPlatformPrivacySignals({ return !isWeb && isMacOS; } +/// Whether the platform can natively blank the app in OS screenshots and +/// screen recordings via the `com.zcash.wallet/privacy_shield` channel. +/// +/// - macOS suppresses Mission Control capture of the window. +/// - Android sets `FLAG_SECURE`. +/// - iOS re-parents the window layer into a `secureTextEntry` field's canvas +/// (see `SecureScreenshotShield` in `ios/Runner/AppDelegate.swift`); the +/// screenshot warning sheet stays as a secondary post-capture UX because +/// iOS only notifies after the capture completes. @visibleForTesting bool supportsNativePrivacyShield({ required bool isWeb, required bool isMacOS, required bool isAndroid, + required bool isIOS, }) { - return !isWeb && (isMacOS || isAndroid); + return !isWeb && (isMacOS || isAndroid || isIOS); } class MacOSPrivacyExposureEvent { @@ -237,14 +248,14 @@ class SensitivePrivacyEnvironmentController @override void beginAuthPrompt() { - if (_authPromptActive) return; + if (_disposed || _authPromptActive) return; super.beginAuthPrompt(); _syncSafety(); } @override void endAuthPrompt() { - if (!_authPromptActive) return; + if (_disposed || !_authPromptActive) return; // The biometric sheet pushes the app to `inactive`; dropping suppression // now would flash the shield for the frames before `onResume`/`onShow` // arrives. Defer the release to the next foreground transition so diff --git a/lib/src/features/onboarding/mobile/mobile_import_manual_screen.dart b/lib/src/features/onboarding/mobile/mobile_import_manual_screen.dart index 6a1457f61..efd64b57d 100644 --- a/lib/src/features/onboarding/mobile/mobile_import_manual_screen.dart +++ b/lib/src/features/onboarding/mobile/mobile_import_manual_screen.dart @@ -1,12 +1,21 @@ +import 'dart:async'; + import 'package:flutter/material.dart' show TextField; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import '../../../../main.dart' show log; +import '../../../core/feedback/app_haptics.dart'; +import '../../../core/layout/mobile/app_mobile_sheet.dart'; +import '../../../core/platform/screenshot_observer.dart'; +import '../../../core/privacy/route_coverage_aware.dart'; +import '../../../core/privacy/sensitive_privacy_overlay.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_button.dart'; import '../../../core/widgets/app_icon.dart'; import '../../../rust/api/wallet.dart' as rust_wallet; +import '../../settings/screens/mobile/mobile_seed_phrase_screen.dart' + show MobileSeedScreenshotWarningSheet; import '../shared/onboarding_flow_args.dart'; import 'mobile_import_screens.dart'; import 'mobile_onboarding_progress.dart'; @@ -18,27 +27,52 @@ import 'mobile_onboarding_scaffold.dart'; /// tapping a suggestion, or by space/return when it's a valid BIP39 /// word; backspace on an empty field steps back to the previous word. class MobileImportManualScreen extends StatefulWidget { - const MobileImportManualScreen({this.wordListOverride, super.key}); + const MobileImportManualScreen({ + this.wordListOverride, + this.screenshotStream, + this.privacyOverlayController, + super.key, + }); /// Test seam — production loads the Rust BIP39 list. @visibleForTesting final List? wordListOverride; + /// Test seam — production listens to the platform screenshot events. + @visibleForTesting + final Stream? screenshotStream; + + @visibleForTesting + final SensitivePrivacyOverlayController? privacyOverlayController; + @override State createState() => _MobileImportManualScreenState(); } -class _MobileImportManualScreenState extends State { +class _MobileImportManualScreenState extends State + with RouteCoverageAware { late final List _wordList; final List _accepted = []; final _controller = TextEditingController(); final _focusNode = FocusNode(); String? _error; + StreamSubscription? _screenshotSub; + bool _screenshotSheetShowing = false; + late final bool _ownsPrivacyController; + late final SensitivePrivacyOverlayController _privacyController; + @override void initState() { super.initState(); + _ownsPrivacyController = widget.privacyOverlayController == null; + _privacyController = + widget.privacyOverlayController ?? + SensitivePrivacyEnvironmentController(); + _screenshotSub = (widget.screenshotStream ?? screenshotEvents()).listen( + (_) => _onScreenshot(), + ); var words = widget.wordListOverride; if (words == null) { try { @@ -54,11 +88,36 @@ class _MobileImportManualScreenState extends State { @override void dispose() { + _screenshotSub?.cancel(); + if (_ownsPrivacyController) _privacyController.dispose(); _controller.dispose(); _focusNode.dispose(); super.dispose(); } + Future _onScreenshot() async { + // Only warn once a word is on screen — a typed word or an accepted word. + // An empty field has nothing to protect. Mirrors the reveal screens. + if ((_accepted.isEmpty && _typed.isEmpty) || + _screenshotSheetShowing || + !_isCurrentRoute || + !mounted) { + return; + } + _screenshotSheetShowing = true; + unawaited(AppHaptics.privacyToggle()); + try { + await showAppMobileSheet( + context: context, + builder: (_) => const MobileSeedScreenshotWarningSheet(), + ); + } finally { + _screenshotSheetShowing = false; + } + } + + bool get _isCurrentRoute => ModalRoute.of(context)?.isCurrent ?? true; + List get _suggestions { final prefix = _controller.text.trim().toLowerCase(); if (prefix.isEmpty) return const []; @@ -229,6 +288,9 @@ class _MobileImportManualScreenState extends State { setState(() => _error = error); return; } + // The shield stays engaged through the push transition and drops only once + // this screen is fully covered (RouteCoverageAware), so birthday and the + // screens after it are not blanked while the seed is no longer visible. context .push( '/import/birthday', @@ -277,90 +339,98 @@ class _MobileImportManualScreenState extends State { final colors = context.colors; final position = (_accepted.length + 1).clamp(1, kMnemonicMaxWords); - return MobileOnboardingStepScaffold( - progress: mobileImportProgress(1), - onBack: () => Navigator.of(context).maybePop(), - title: 'Enter your Secret Passphrase', - subtitle: 'Accept 12, 15, 18, 21 or 24 words', - // Only the CTA is pinned — it rides up above the keyboard (Figma - // 4746:83516). The autocomplete chips stay attached under the word - // field and scroll with the content. The stretch Column gives the - // expand:true button a tight width to fill. - bottomArea: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [_buildButtonRow()], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _WordField( - index: position, - controller: _controller, - focusNode: _focusNode, - hasError: _error != null, - onChanged: _onChanged, - onSubmitted: _onSubmitted, - ), - if (_suggestions.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.s), - SizedBox( - height: 40, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - for (final word in _suggestions) - Padding( - padding: const EdgeInsets.only(right: AppSpacing.xs), - child: _SuggestionChip( - word: word, - onTap: () => _acceptWord(word), + return SensitivePrivacyOverlay( + // Protect only once a word is on screen — an empty field has nothing to + // blank. Matches the `_onScreenshot` guard. Drops once a next step has + // fully covered this screen so it does not blank those screens. + sensitiveContentVisible: + (_accepted.isNotEmpty || _typed.isNotEmpty) && !isCoveredByNextRoute, + controller: _privacyController, + child: MobileOnboardingStepScaffold( + progress: mobileImportProgress(1), + onBack: () => Navigator.of(context).maybePop(), + title: 'Enter your Secret Passphrase', + subtitle: 'Accept 12, 15, 18, 21 or 24 words', + // Only the CTA is pinned — it rides up above the keyboard (Figma + // 4746:83516). The autocomplete chips stay attached under the word + // field and scroll with the content. The stretch Column gives the + // expand:true button a tight width to fill. + bottomArea: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [_buildButtonRow()], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _WordField( + index: position, + controller: _controller, + focusNode: _focusNode, + hasError: _error != null, + onChanged: _onChanged, + onSubmitted: _onSubmitted, + ), + if (_suggestions.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.s), + SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (final word in _suggestions) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: _SuggestionChip( + word: word, + onTap: () => _acceptWord(word), + ), ), - ), - ], + ], + ), ), - ), - ], - if (_error != null) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - _error!, - textAlign: TextAlign.center, - style: AppTypography.bodySmall.copyWith( - color: colors.text.destructive, + ], + if (_error != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + _error!, + textAlign: TextAlign.center, + style: AppTypography.bodySmall.copyWith( + color: colors.text.destructive, + ), ), - ), - ], - if (_accepted.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.md), - Text( - _accepted.join(' · '), - textAlign: TextAlign.center, - style: AppTypography.bodySmall.copyWith( - color: colors.text.secondary, + ], + if (_accepted.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + Text( + _accepted.join(' · '), + textAlign: TextAlign.center, + style: AppTypography.bodySmall.copyWith( + color: colors.text.secondary, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - Semantics( - button: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _stepBack, - child: SizedBox( - height: 36, - child: Center( - child: Text( - 'Undo last word', - style: AppTypography.labelMedium.copyWith( - color: colors.text.secondary, + const SizedBox(height: AppSpacing.xs), + Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _stepBack, + child: SizedBox( + height: 36, + child: Center( + child: Text( + 'Undo last word', + style: AppTypography.labelMedium.copyWith( + color: colors.text.secondary, + ), ), ), ), ), ), - ), + ], ], - ], + ), ), ); } diff --git a/lib/src/features/onboarding/mobile/mobile_import_screens.dart b/lib/src/features/onboarding/mobile/mobile_import_screens.dart index 99b85f785..ec63a29b6 100644 --- a/lib/src/features/onboarding/mobile/mobile_import_screens.dart +++ b/lib/src/features/onboarding/mobile/mobile_import_screens.dart @@ -1,13 +1,22 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import '../../../../main.dart' show log; +import '../../../core/feedback/app_haptics.dart'; +import '../../../core/layout/mobile/app_mobile_sheet.dart'; +import '../../../core/platform/screenshot_observer.dart'; +import '../../../core/privacy/route_coverage_aware.dart'; +import '../../../core/privacy/sensitive_privacy_overlay.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_button.dart'; import '../../../core/widgets/app_icon.dart'; import '../../../core/widgets/app_toast.dart'; import '../../../rust/api/wallet.dart' as rust_wallet; +import '../../settings/screens/mobile/mobile_seed_phrase_screen.dart' + show MobileSeedScreenshotWarningSheet; import '../shared/onboarding_flow_args.dart'; import 'mobile_onboarding_progress.dart'; import 'mobile_onboarding_scaffold.dart'; @@ -50,16 +59,75 @@ String? validateImportedMnemonic(List words) { /// slots with a paste action, clipboard problems surfaced as toasts, /// and an Enter Manually link into the word-by-word wizard. class MobileImportScreen extends StatefulWidget { - const MobileImportScreen({super.key}); + const MobileImportScreen({ + this.screenshotStream, + this.privacyOverlayController, + super.key, + }); + + /// Test seam — production listens to the platform screenshot events. + @visibleForTesting + final Stream? screenshotStream; + + @visibleForTesting + final SensitivePrivacyOverlayController? privacyOverlayController; @override State createState() => _MobileImportScreenState(); } -class _MobileImportScreenState extends State { +class _MobileImportScreenState extends State + with RouteCoverageAware { List _words = const []; String? _error; + StreamSubscription? _screenshotSub; + bool _screenshotSheetShowing = false; + late final bool _ownsPrivacyController; + late final SensitivePrivacyOverlayController _privacyController; + + @override + void initState() { + super.initState(); + _ownsPrivacyController = widget.privacyOverlayController == null; + _privacyController = + widget.privacyOverlayController ?? + SensitivePrivacyEnvironmentController(); + _screenshotSub = (widget.screenshotStream ?? screenshotEvents()).listen( + (_) => _onScreenshot(), + ); + } + + @override + void dispose() { + _screenshotSub?.cancel(); + if (_ownsPrivacyController) _privacyController.dispose(); + super.dispose(); + } + + Future _onScreenshot() async { + // Only warn once pasted words are on screen — the empty paste form has + // nothing to protect. Mirrors the reveal screens' _onScreenshot guard. + if (_words.isEmpty || + _screenshotSheetShowing || + !_isCurrentRoute || + !mounted) { + return; + } + _screenshotSheetShowing = true; + unawaited(AppHaptics.privacyToggle()); + try { + await showAppMobileSheet( + context: context, + builder: (_) => const MobileSeedScreenshotWarningSheet(), + ); + } finally { + _screenshotSheetShowing = false; + } + } + + bool get _isCurrentRoute => ModalRoute.of(context)?.isCurrent ?? true; + Future _paste() async { String? text; try { @@ -100,6 +168,9 @@ class _MobileImportScreenState extends State { } void _confirm() { + // The shield stays engaged through the push transition and drops only once + // this screen is fully covered (RouteCoverageAware), so birthday and the + // screens after it are not blanked while the seed is no longer visible. context.push( '/import/birthday', extra: ImportBirthdayArgs(mnemonic: _words.join(' ')), @@ -115,117 +186,124 @@ class _MobileImportScreenState extends State { @override Widget build(BuildContext context) { final colors = context.colors; - return MobileOnboardingStepScaffold( - progress: mobileImportProgress(1), - onBack: () => Navigator.of(context).maybePop(), - title: 'Import Wallet', - // Line break matches the Figma subtitle wrap. - subtitle: - 'Paste your Secret Passphrase or\nenter it manually word by word.', - bottomArea: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_error != null) ...[ - Text( - _error!, - textAlign: TextAlign.center, - style: AppTypography.bodySmall.copyWith( - color: colors.text.destructive, + return SensitivePrivacyOverlay( + // Protect only once words are on screen — the empty paste form has + // nothing to blank. Matches the `_onScreenshot` guard. Drops once a next + // step has fully covered this screen so it does not blank those screens. + sensitiveContentVisible: _words.isNotEmpty && !isCoveredByNextRoute, + controller: _privacyController, + child: MobileOnboardingStepScaffold( + progress: mobileImportProgress(1), + onBack: () => Navigator.of(context).maybePop(), + title: 'Import Wallet', + // Line break matches the Figma subtitle wrap. + subtitle: + 'Paste your Secret Passphrase or\nenter it manually word by word.', + bottomArea: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_error != null) ...[ + Text( + _error!, + textAlign: TextAlign.center, + style: AppTypography.bodySmall.copyWith( + color: colors.text.destructive, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - ], - if (_filled) ...[ - // Pasted state — Figma fills the slots in place and swaps - // the actions for confirm / clear. - AppButton( - key: const ValueKey('mobile_import_confirm'), - expand: true, - onPressed: _confirm, - trailing: const AppIcon(AppIcons.chevronForward), - child: const Text('Confirm & import'), - ), - const SizedBox(height: AppSpacing.xs), - Semantics( - button: true, - child: GestureDetector( - key: const ValueKey('mobile_import_clear'), - behavior: HitTestBehavior.opaque, - onTap: _clear, - child: SizedBox( - height: 44, - child: Center( - child: Text( - 'Clear secret phrase', - style: AppTypography.labelLarge.copyWith( - color: colors.text.primary, + const SizedBox(height: AppSpacing.xs), + ], + if (_filled) ...[ + // Pasted state — Figma fills the slots in place and swaps + // the actions for confirm / clear. + AppButton( + key: const ValueKey('mobile_import_confirm'), + expand: true, + onPressed: _confirm, + trailing: const AppIcon(AppIcons.chevronForward), + child: const Text('Confirm & import'), + ), + const SizedBox(height: AppSpacing.xs), + Semantics( + button: true, + child: GestureDetector( + key: const ValueKey('mobile_import_clear'), + behavior: HitTestBehavior.opaque, + onTap: _clear, + child: SizedBox( + height: 44, + child: Center( + child: Text( + 'Clear secret phrase', + style: AppTypography.labelLarge.copyWith( + color: colors.text.primary, + ), ), ), ), ), ), - ), - ] else ...[ - AppButton( - key: const ValueKey('mobile_import_paste'), - expand: true, - onPressed: _paste, - // No explicit icon color: AppButton's IconTheme tints it - // with the label color (white on the primary fill). - leading: const AppIcon(AppIcons.copy), - child: const Text('Paste secret phrase'), - ), - const SizedBox(height: AppSpacing.xs), - Semantics( - button: true, - child: GestureDetector( - key: const ValueKey('mobile_import_enter_manually'), - behavior: HitTestBehavior.opaque, - onTap: _openManual, - child: SizedBox( - height: 44, - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon( - AppIcons.edit, - size: AppIconSize.medium, - color: colors.text.primary, - ), - const SizedBox(width: AppSpacing.xs), - Text( - 'Enter manually', - style: AppTypography.labelLarge.copyWith( + ] else ...[ + AppButton( + key: const ValueKey('mobile_import_paste'), + expand: true, + onPressed: _paste, + // No explicit icon color: AppButton's IconTheme tints it + // with the label color (white on the primary fill). + leading: const AppIcon(AppIcons.copy), + child: const Text('Paste secret phrase'), + ), + const SizedBox(height: AppSpacing.xs), + Semantics( + button: true, + child: GestureDetector( + key: const ValueKey('mobile_import_enter_manually'), + behavior: HitTestBehavior.opaque, + onTap: _openManual, + child: SizedBox( + height: 44, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon( + AppIcons.edit, + size: AppIconSize.medium, color: colors.text.primary, ), - ), - ], + const SizedBox(width: AppSpacing.xs), + Text( + 'Enter manually', + style: AppTypography.labelLarge.copyWith( + color: colors.text.primary, + ), + ), + ], + ), ), ), ), ), - ), + ], ], - ], - ), - // VZR-71: users instinctively tap the slot grid expecting to - // type — route the tap into the manual wizard, same as the Enter - // manually link. Once a valid phrase fills the card it is a - // confirmed phrase surface and the tap is disabled. - child: _filled - ? ImportSlotsCard(words: _words) - : Semantics( - button: true, - label: 'Enter secret phrase manually', - child: GestureDetector( - key: const ValueKey('mobile_import_slots'), - behavior: HitTestBehavior.opaque, - onTap: _openManual, - child: ImportSlotsCard(words: _words), + ), + // VZR-71: users instinctively tap the slot grid expecting to + // type — route the tap into the manual wizard, same as the Enter + // manually link. Once a valid phrase fills the card it is a + // confirmed phrase surface and the tap is disabled. + child: _filled + ? ImportSlotsCard(words: _words) + : Semantics( + button: true, + label: 'Enter secret phrase manually', + child: GestureDetector( + key: const ValueKey('mobile_import_slots'), + behavior: HitTestBehavior.opaque, + onTap: _openManual, + child: ImportSlotsCard(words: _words), + ), ), - ), + ), ); } } diff --git a/lib/src/features/onboarding/mobile/mobile_secret_passphrase_screen.dart b/lib/src/features/onboarding/mobile/mobile_secret_passphrase_screen.dart index 04e3ae503..c6c97d07e 100644 --- a/lib/src/features/onboarding/mobile/mobile_secret_passphrase_screen.dart +++ b/lib/src/features/onboarding/mobile/mobile_secret_passphrase_screen.dart @@ -9,6 +9,7 @@ import '../../../../main.dart' show log; import '../../../core/feedback/app_haptics.dart'; import '../../../core/layout/mobile/app_mobile_sheet.dart'; import '../../../core/platform/screenshot_observer.dart'; +import '../../../core/privacy/route_coverage_aware.dart'; import '../../../core/privacy/sensitive_privacy_overlay.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/app_button.dart'; @@ -59,7 +60,8 @@ class MobileSecretPassphraseScreen extends ConsumerStatefulWidget { } class _MobileSecretPassphraseScreenState - extends ConsumerState { + extends ConsumerState + with RouteCoverageAware { String? _mnemonic; bool _revealed = false; bool _copied = false; @@ -144,6 +146,9 @@ class _MobileSecretPassphraseScreenState final security = ref.read(appSecurityProvider); if (!security.isPasswordConfigured) { + // The shield stays engaged through the push transition and drops only + // once this screen is fully covered (RouteCoverageAware), so the passcode + // step is not blanked while the seed is no longer visible. context.push( '/onboarding/set-passcode', extra: SetPasswordScreenArgs.create(mnemonic: mnemonic), @@ -207,7 +212,12 @@ class _MobileSecretPassphraseScreenState final words = _mnemonic?.split(' ') ?? const []; return SensitivePrivacyOverlay( - sensitiveContentVisible: _revealed && _mnemonic != null, + // Protect only while the phrase is actually on screen. Before reveal the + // card shows no words, so blanking its screenshot and blurring the app + // switcher there is needless friction. Matches the `_onScreenshot` guard. + // Drops once the passcode step has fully covered this screen. + sensitiveContentVisible: + _revealed && _mnemonic != null && !isCoveredByNextRoute, controller: _privacyController, child: MobileOnboardingStepScaffold( progress: mobileCreateProgress(6), diff --git a/lib/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart b/lib/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart index cf4921254..55916a1f9 100644 --- a/lib/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart +++ b/lib/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart @@ -370,6 +370,9 @@ class _MobileSeedPhraseScreenState backgroundColor: colors.background.window, body: AppToastHost( child: SensitivePrivacyOverlay( + // Protect only in the reveal stage. The passcode gate shows no words, + // so blanking its screenshot and app-switcher snapshot is needless + // friction. Matches the `_onScreenshot` guard. sensitiveContentVisible: _stage == _SeedStage.reveal && _mnemonic != null, controller: _privacyController, diff --git a/test/core/privacy/sensitive_privacy_overlay_test.dart b/test/core/privacy/sensitive_privacy_overlay_test.dart index 943a1f6d1..539a93b7c 100644 --- a/test/core/privacy/sensitive_privacy_overlay_test.dart +++ b/test/core/privacy/sensitive_privacy_overlay_test.dart @@ -17,12 +17,13 @@ void main() { expect(supportsPlatformPrivacySignals(isWeb: true, isMacOS: true), isFalse); }); - test('native privacy shield supports macOS and Android', () { + test('native privacy shield supports macOS, Android, and iOS', () { expect( supportsNativePrivacyShield( isWeb: false, isMacOS: true, isAndroid: false, + isIOS: false, ), isTrue, ); @@ -31,6 +32,7 @@ void main() { isWeb: false, isMacOS: false, isAndroid: true, + isIOS: false, ), isTrue, ); @@ -39,11 +41,26 @@ void main() { isWeb: false, isMacOS: false, isAndroid: false, + isIOS: true, + ), + isTrue, + ); + expect( + supportsNativePrivacyShield( + isWeb: false, + isMacOS: false, + isAndroid: false, + isIOS: false, ), isFalse, ); expect( - supportsNativePrivacyShield(isWeb: true, isMacOS: true, isAndroid: true), + supportsNativePrivacyShield( + isWeb: true, + isMacOS: true, + isAndroid: true, + isIOS: true, + ), isFalse, ); }); diff --git a/test/features/onboarding/mobile_import_manual_screen_test.dart b/test/features/onboarding/mobile_import_manual_screen_test.dart index 5323bc723..797382781 100644 --- a/test/features/onboarding/mobile_import_manual_screen_test.dart +++ b/test/features/onboarding/mobile_import_manual_screen_test.dart @@ -1,14 +1,19 @@ @Tags(['mobile']) library; +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:zcash_wallet/src/core/privacy/sensitive_privacy_overlay.dart'; import 'package:zcash_wallet/src/core/theme/app_theme.dart'; import 'package:zcash_wallet/src/features/onboarding/mobile/mobile_import_manual_screen.dart'; import 'package:zcash_wallet/src/features/onboarding/mobile/mobile_import_screens.dart'; import 'package:zcash_wallet/src/features/onboarding/shared/onboarding_flow_args.dart'; +import 'package:zcash_wallet/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart'; import 'package:zcash_wallet/src/rust/frb_generated.dart'; const _wordList = ['abandon', 'ability', 'able', 'about', 'zebra']; @@ -49,6 +54,35 @@ Widget _routedApp() { ); } +Widget _screenshotApp({ + Stream? screenshotStream, + SensitivePrivacyOverlayController? privacyOverlayController, +}) { + return ProviderScope( + child: MaterialApp( + builder: (_, c) => AppTheme(data: AppThemeData.light, child: c!), + home: MobileImportManualScreen( + wordListOverride: _wordList, + screenshotStream: screenshotStream, + privacyOverlayController: privacyOverlayController, + ), + ), + ); +} + +void _muteSystemChannel(WidgetTester tester) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); +} + void main() { setUpAll(() { RustLib.initMock(api: _RustApiFake()); @@ -252,6 +286,63 @@ void main() { expect(find.textContaining("Stopped at 'notaword'"), findsOneWidget); expect(find.text('Undo last word'), findsNothing); }); + + testWidgets('warns on a screenshot once a word is on screen', (tester) async { + final screenshots = StreamController(); + addTearDown(screenshots.close); + _muteSystemChannel(tester); + + await tester.pumpWidget( + _screenshotApp(screenshotStream: screenshots.stream), + ); + await tester.pump(); + + // Empty field — nothing to protect yet. + screenshots.add(null); + await tester.pumpAndSettle(); + expect(find.byType(MobileSeedScreenshotWarningSheet), findsNothing); + + await tester.enterText( + find.byKey(const ValueKey('mobile_import_manual_field')), + 'abandon', + ); + await tester.pump(); + + screenshots.add(null); + await tester.pumpAndSettle(); + expect(find.byType(MobileSeedScreenshotWarningSheet), findsOneWidget); + expect(find.textContaining('Don’t take screenshots'), findsOneWidget); + }); + + testWidgets( + 'covers the field once a word is on screen when the controller is unsafe', + (tester) async { + final privacyController = SensitivePrivacyOverlayController( + initiallySafe: false, + ); + addTearDown(privacyController.dispose); + + await tester.pumpWidget( + _screenshotApp(privacyOverlayController: privacyController), + ); + await tester.pump(); + + // An empty field has nothing to blank, even when unsafe. + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + + await tester.enterText( + find.byKey(const ValueKey('mobile_import_manual_field')), + 'ab', + ); + await tester.pump(); + // A typed word turns on protection; the shield covers the field. + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + privacyController.markSafe(); + await tester.pump(); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + }, + ); } class _RustApiFake implements RustLibApi { diff --git a/test/features/onboarding/mobile_import_screens_test.dart b/test/features/onboarding/mobile_import_screens_test.dart index 09f3c445f..9734370f2 100644 --- a/test/features/onboarding/mobile_import_screens_test.dart +++ b/test/features/onboarding/mobile_import_screens_test.dart @@ -1,15 +1,19 @@ @Tags(['mobile']) library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:zcash_wallet/src/core/navigation/mobile_onboarding_routes.dart'; +import 'package:zcash_wallet/src/core/privacy/sensitive_privacy_overlay.dart'; import 'package:zcash_wallet/src/core/theme/app_theme.dart'; import 'package:zcash_wallet/src/features/onboarding/mobile/mobile_import_screens.dart'; import 'package:zcash_wallet/src/features/onboarding/shared/onboarding_flow_args.dart'; +import 'package:zcash_wallet/src/features/settings/screens/mobile/mobile_seed_phrase_screen.dart'; import 'package:zcash_wallet/src/rust/frb_generated.dart'; const _validMnemonic = @@ -28,16 +32,35 @@ Widget _app(String initialLocation) { ); } -Widget _entryAppWithBirthdayProbe() { +Widget _entryAppWithBirthdayProbe({ + SensitivePrivacyOverlayController? privacyOverlayController, + Stream? screenshotStream, +}) { final router = GoRouter( initialLocation: '/import', routes: [ - GoRoute(path: '/import', builder: (_, _) => const MobileImportScreen()), + GoRoute( + path: '/import', + builder: (_, _) => MobileImportScreen( + privacyOverlayController: privacyOverlayController, + screenshotStream: screenshotStream, + ), + ), GoRoute( path: '/import/birthday', - builder: (_, state) { + builder: (context, state) { final args = state.extra as ImportBirthdayArgs; - return Scaffold(body: Text('Birthday: ${args.mnemonic}')); + return Scaffold( + body: Column( + children: [ + Text('Birthday: ${args.mnemonic}'), + TextButton( + onPressed: () => context.pop(), + child: const Text('back-probe'), + ), + ], + ), + ); }, ), ], @@ -66,6 +89,21 @@ void _mockClipboard(WidgetTester tester, String? text) { ); } +Widget _screenshotApp({ + Stream? screenshotStream, + SensitivePrivacyOverlayController? privacyOverlayController, +}) { + return ProviderScope( + child: MaterialApp( + builder: (_, child) => AppTheme(data: AppThemeData.light, child: child!), + home: MobileImportScreen( + screenshotStream: screenshotStream, + privacyOverlayController: privacyOverlayController, + ), + ), + ); +} + void main() { setUpAll(() { RustLib.initMock(api: _RustApiFake()); @@ -181,6 +219,147 @@ void main() { await tester.pump(const Duration(seconds: 3)); expect(find.text('Clipboard is empty'), findsNothing); }); + + testWidgets('warns on a screenshot once pasted words are on screen', ( + tester, + ) async { + final screenshots = StreamController(); + addTearDown(screenshots.close); + // A short paste is invalid but still renders the words into the slot card. + _mockClipboard(tester, 'one two three'); + + await tester.pumpWidget( + _screenshotApp(screenshotStream: screenshots.stream), + ); + await tester.pumpAndSettle(); + + // Nothing to protect before a paste — no warning. + screenshots.add(null); + await tester.pumpAndSettle(); + expect(find.byType(MobileSeedScreenshotWarningSheet), findsNothing); + + await tester.tap(find.byKey(const ValueKey('mobile_import_paste'))); + await tester.pumpAndSettle(); + expect(find.text('one'), findsOneWidget); + + screenshots.add(null); + await tester.pumpAndSettle(); + expect(find.byType(MobileSeedScreenshotWarningSheet), findsOneWidget); + expect(find.textContaining('Don’t take screenshots'), findsOneWidget); + }); + + testWidgets( + 'covers the entry once words are pasted when the controller is unsafe', + (tester) async { + final privacyController = SensitivePrivacyOverlayController( + initiallySafe: false, + ); + addTearDown(privacyController.dispose); + _mockClipboard(tester, 'one two three'); + + await tester.pumpWidget( + _screenshotApp(privacyOverlayController: privacyController), + ); + await tester.pumpAndSettle(); + + // Nothing on screen to blank before a paste, even when unsafe. + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + + await tester.tap(find.byKey(const ValueKey('mobile_import_paste'))); + await tester.pumpAndSettle(); + expect(find.text('one'), findsOneWidget); + // Pasted words turn on protection; the shield covers the filled card. + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + privacyController.markSafe(); + await tester.pump(); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + }, + ); + + testWidgets( + 'keeps the shield through the push slide, drops it once covered, and ' + 'restores it on back', + (tester) async { + final privacyController = SensitivePrivacyOverlayController( + initiallySafe: false, + ); + addTearDown(privacyController.dispose); + _mockClipboard(tester, _validMnemonic); + + await tester.pumpWidget( + _entryAppWithBirthdayProbe(privacyOverlayController: privacyController), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('mobile_import_paste'))); + await tester.pumpAndSettle(); + // Pasted words + unsafe controller: the shield covers the import entry. + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + // Start the push: the seed screen is still sliding out — the shield must + // stay up so a screenshot mid-transition does not capture the mnemonic. + await tester.tap(find.byKey(const ValueKey('mobile_import_confirm'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 120)); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + // Transition finished: the import screen is fully covered, so the shield + // drops and the global native token no longer blanks the birthday screen. + await tester.pumpAndSettle(); + expect(find.text('Birthday: $_validMnemonic'), findsOneWidget); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + + // Back: the secret screen slides in and is protected again. + await tester.tap(find.text('back-probe')); + await tester.pumpAndSettle(); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + }, + ); + + testWidgets( + 'keeps the shield up while the screenshot warning sheet is open over the ' + 'seed (real go_router routing)', + (tester) async { + final screenshots = StreamController(); + addTearDown(screenshots.close); + final privacyController = SensitivePrivacyOverlayController( + initiallySafe: false, + ); + addTearDown(privacyController.dispose); + _mockClipboard(tester, 'one two three'); + + await tester.pumpWidget( + _entryAppWithBirthdayProbe( + privacyOverlayController: privacyController, + screenshotStream: screenshots.stream, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('mobile_import_paste'))); + await tester.pumpAndSettle(); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + // A screenshot opens the warning bottom sheet — a non-opaque popup route + // (showModalBottomSheet). A page route does not drive its + // secondaryAnimation for a popup pushed above it, so RouteCoverageAware + // must NOT treat the screen as covered: the mnemonic is still behind the + // sheet, and the shield must stay up so a second screenshot or an + // app-switcher snapshot is still blanked. + screenshots.add(null); + await tester.pumpAndSettle(); + expect(find.byType(MobileSeedScreenshotWarningSheet), findsOneWidget); + expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); + + // The route's secondaryAnimation must have stayed dismissed (the popup + // never counted as coverage). + final route = ModalRoute.of( + tester.element(find.byType(MobileImportScreen)), + ); + expect(route?.secondaryAnimation?.status, AnimationStatus.dismissed); + }, + ); } class _RustApiFake implements RustLibApi { diff --git a/test/features/onboarding/mobile_secret_passphrase_screen_test.dart b/test/features/onboarding/mobile_secret_passphrase_screen_test.dart index ebf1e5c1c..199eaf5cb 100644 --- a/test/features/onboarding/mobile_secret_passphrase_screen_test.dart +++ b/test/features/onboarding/mobile_secret_passphrase_screen_test.dart @@ -293,7 +293,7 @@ void main() { }); testWidgets( - 'covers the revealed phrase when the privacy controller is unsafe', + 'covers the phrase once revealed when the privacy controller is unsafe', (tester) async { final privacyController = SensitivePrivacyOverlayController( initiallySafe: false, @@ -309,11 +309,13 @@ void main() { ), ); await tester.pump(); + // Before reveal the card shows no words, so nothing is blanked even when + // the controller is unsafe. expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsNothing); + // Revealing the phrase turns on protection; the shield covers the words. await tester.tap(find.text('Reveal phrase')); await tester.pump(); - expect(find.byKey(SensitivePrivacyOverlay.shieldKey), findsOneWidget); privacyController.markSafe();