Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
185 changes: 185 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,158 @@ 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() {
guard !isLayerAttached else { return }
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 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
72 changes: 69 additions & 3 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,24 @@ 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
/// environment controller auto-releases this on the next foreground return.
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 @@ -234,6 +264,7 @@ class SensitivePrivacyEnvironmentController
bool _macOSNativeSafe = true;
bool _disposed = false;
bool _deferAuthClear = false;
Timer? _screenshotSuppressionTimer;

@override
void beginAuthPrompt() {
Expand All @@ -258,6 +289,31 @@ class SensitivePrivacyEnvironmentController
}
}

@override
void beginScreenshotSuppression() {
final wasActive = _screenshotSuppressionActive;
_screenshotSuppressionActive = true;
// Backstop: if the app never goes inactive (the user ignores the preview),
// release after a short window so a genuine later backgrounding still
// blurs. It only clears while already foreground; if the editor is still
// up (inactive), the foreground transition clears it instead, so the
// shield never flashes during the editor dismiss animation.
_screenshotSuppressionTimer?.cancel();
_screenshotSuppressionTimer = Timer(const Duration(seconds: 8), () {
if (!_lifecycleInactive) endScreenshotSuppression();
});
if (!wasActive) _syncSafety();
}

@override
void endScreenshotSuppression() {
_screenshotSuppressionTimer?.cancel();
_screenshotSuppressionTimer = null;
if (!_screenshotSuppressionActive) return;
_screenshotSuppressionActive = false;
_syncSafety();
}

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

Expand Down Expand Up @@ -289,6 +345,13 @@ class SensitivePrivacyEnvironmentController
_deferAuthClear = false;
_authPromptActive = false;
}
if (_screenshotSuppressionActive) {
// The iOS screenshot preview/editor handed foreground back; drop the
// suppression so a genuine later backgrounding blurs normally.
_screenshotSuppressionTimer?.cancel();
_screenshotSuppressionTimer = null;
_screenshotSuppressionActive = false;
}
_syncSafety();
}

Expand All @@ -315,14 +378,17 @@ 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);
}

@override
void dispose() {
_disposed = true;
_screenshotSuppressionTimer?.cancel();
_macOSExposureSub?.cancel();
_lifecycleListener?.dispose();
if (_supportsPlatformPrivacySignals) {
Expand Down
Loading