Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -244,3 +274,179 @@ 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 { self?.reassertWindowGeometry() }
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
}
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 }
}
}
9 changes: 6 additions & 3 deletions lib/src/core/platform/screenshot_observer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import 'package:flutter/services.dart';
const _channel = EventChannel('com.zcash.wallet/screenshots');

/// 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<void> screenshotEvents() {
if (kIsWeb || !Platform.isIOS) return const Stream.empty();
return _channel
Expand Down
45 changes: 45 additions & 0 deletions lib/src/core/privacy/route_coverage_aware.dart
Original file line number Diff line number Diff line change
@@ -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<T extends StatefulWidget> on State<T> {
Animation<double>? _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;
Comment thread
piatoss3612 marked this conversation as resolved.
if (covered == _coveredByNextRoute || !mounted) return;
setState(() => _coveredByNextRoute = covered);
Comment thread
piatoss3612 marked this conversation as resolved.
}

@override
void dispose() {
_secondaryAnimation?.removeStatusListener(_onSecondaryStatus);
super.dispose();
}
}
59 changes: 54 additions & 5 deletions lib/src/core/privacy/sensitive_privacy_overlay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ bool get _supportsNativePrivacyShield => supportsNativePrivacyShield(
isWeb: kIsWeb,
isMacOS: !kIsWeb && Platform.isMacOS,
isAndroid: !kIsWeb && Platform.isAndroid,
isIOS: !kIsWeb && Platform.isIOS,
);

@visibleForTesting
Expand All @@ -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 {
Expand Down Expand Up @@ -138,6 +149,7 @@ class SensitivePrivacyOverlayController extends ChangeNotifier {

bool _isSafe;
bool _authPromptActive = false;
bool _screenshotSuppressionActive = false;

/// Whether sensitive content may be shown unobscured.
bool get isSafe => _isSafe;
Expand All @@ -163,6 +175,27 @@ class SensitivePrivacyOverlayController extends ChangeNotifier {
notifyListeners();
}

/// Suppresses the shield through the brief `inactive` transition the iOS
/// screenshot preview/editor causes. The native secure-field blanking already
/// blacks out the actual capture and the warning sheet already explains it, so
/// the extra blur flash during the screenshot flow is pure noise. The
/// The caller releases this via [endScreenshotSuppression] when the warning
/// sheet closes, so the suppression is scoped to the active screenshot flow
/// rather than a fixed timeout — a genuine backgrounding once the sheet is
/// gone still blurs.
void beginScreenshotSuppression() {
if (_screenshotSuppressionActive) return;
_screenshotSuppressionActive = true;
notifyListeners();
}

/// Releases the [beginScreenshotSuppression] marker.
void endScreenshotSuppression() {
if (!_screenshotSuppressionActive) return;
_screenshotSuppressionActive = false;
notifyListeners();
}

@protected
void _setSafe(bool value) {
if (_isSafe == value) return;
Expand Down Expand Up @@ -237,14 +270,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
Expand All @@ -258,6 +291,20 @@ class SensitivePrivacyEnvironmentController
}
}

@override
void beginScreenshotSuppression() {
if (_disposed || _screenshotSuppressionActive) return;
super.beginScreenshotSuppression();
_syncSafety();
}

@override
void endScreenshotSuppression() {
if (_disposed || !_screenshotSuppressionActive) return;
super.endScreenshotSuppression();
_syncSafety();
}

@override
void onWindowFocus() => _setWindowSafe(true);

Expand Down Expand Up @@ -315,8 +362,10 @@ class SensitivePrivacyEnvironmentController
}

void _syncSafety() {
final lifecycleSafe =
_lifecycleSafe || (_lifecycleInactive && _authPromptActive);
final suppressedInactive =
_lifecycleInactive &&
(_authPromptActive || _screenshotSuppressionActive);
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
final lifecycleSafe = _lifecycleSafe || suppressedInactive;
_setSafe(lifecycleSafe && _windowSafe && _macOSNativeSafe);
}

Expand Down
Loading