twonly-app-dependencies/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift
2026-02-12 22:01:59 +01:00

383 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Flutter
import UIKit
public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private var screenPrevent = UITextField()
private var screenImage: UIImageView? = nil
private weak var attachedWindow: UIWindow? = nil
private static var methodChannel: FlutterMethodChannel? = nil
private static var eventChannel: FlutterEventChannel? = nil
private static var preventScreenShot: Bool = false
private var eventSink: FlutterEventSink? = nil
private var lastSharedPreferencesState: String = ""
private var hasSharedPreferencesChanged: Bool = false
private var isImageOverlayModeEnabled: Bool = false
private var isScreenRecording: Bool = false
private var isRecordingListening: Bool = false
private static let ENABLESCREENSHOT = false
private static let DISABLESCREENSHOT = true
private static let preventScreenShotKey = "preventScreenShot"
private static let imageOverlayModeKey = "imageOverlayMode"
private static let methodChannelName = "com.flutterplaza.no_screenshot_methods"
private static let eventChannelName = "com.flutterplaza.no_screenshot_streams"
private static let screenshotPathPlaceholder = "screenshot_path_placeholder"
override init() {
super.init()
// Restore the saved state from UserDefaults
let fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey)
isImageOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.imageOverlayModeKey)
updateScreenshotState(isScreenshotBlocked: fetchVal)
}
public static func register(with registrar: FlutterPluginRegistrar) {
methodChannel = FlutterMethodChannel(name: methodChannelName, binaryMessenger: registrar.messenger())
eventChannel = FlutterEventChannel(name: eventChannelName, binaryMessenger: registrar.messenger())
let instance = IOSNoScreenshotPlugin()
registrar.addMethodCallDelegate(instance, channel: methodChannel!)
eventChannel?.setStreamHandler(instance)
registrar.addApplicationDelegate(instance)
}
// MARK: - Inline Screenshot Prevention (replaces ScreenProtectorKit)
private func configurePreventionScreenshot(window: UIWindow) {
guard let rootLayer = window.layer.superlayer else { return }
guard screenPrevent.layer.superlayer == nil else { return }
screenPrevent.semanticContentAttribute = .forceLeftToRight // RTL fix
screenPrevent.textAlignment = .left // RTL fix
// Briefly add to the window so UIKit creates the text field's
// internal sublayer hierarchy, then force a layout pass and
// immediately remove so screenPrevent is NOT a subview of window.
// This avoids a circular view-hierarchy that causes EXC_BAD_ACCESS
// (stack overflow in _collectExistingTraitCollectionsForTraitTracking)
// on iOS 26+.
window.addSubview(screenPrevent)
screenPrevent.layoutIfNeeded()
screenPrevent.removeFromSuperview()
// Keep the layer at the origin so reparenting window.layer
// does not shift the app content.
screenPrevent.layer.frame = .zero
rootLayer.addSublayer(screenPrevent.layer)
if #available(iOS 17.0, *) {
screenPrevent.layer.sublayers?.last?.addSublayer(window.layer)
} else {
screenPrevent.layer.sublayers?.first?.addSublayer(window.layer)
}
}
private func enablePreventScreenshot() {
screenPrevent.isSecureTextEntry = true
}
private func disablePreventScreenshot() {
screenPrevent.isSecureTextEntry = false
}
private func enableImageScreen(named: String) {
guard let window = attachedWindow else { return }
let imageView = UIImageView(frame: UIScreen.main.bounds)
imageView.image = UIImage(named: named)
imageView.isUserInteractionEnabled = false
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
window.addSubview(imageView)
screenImage = imageView
}
private func disableImageScreen() {
screenImage?.removeFromSuperview()
screenImage = nil
}
// MARK: - App Lifecycle
//
// Image overlay lifecycle is intentionally handled in exactly two places:
// SHOW: applicationWillResignActive (app is about to lose focus)
// HIDE: applicationDidBecomeActive (app is fully interactive again)
//
// willResignActive always fires before didEnterBackground, and
// didBecomeActive always fires after willEnterForeground, so a single
// show/hide pair covers both the app-switcher peek and the full
// background foreground round-trip without double-showing the image.
public func applicationWillResignActive(_ application: UIApplication) {
persistState()
if isImageOverlayModeEnabled {
// Temporarily lift screenshot prevention so the overlay image is
// visible in the app switcher (otherwise the secure text field
// would show a blank screen).
disablePreventScreenshot()
enableImageScreen(named: "image")
}
}
public func applicationDidBecomeActive(_ application: UIApplication) {
// Remove the image overlay FIRST.
if isImageOverlayModeEnabled {
disableImageScreen()
}
// Now restore screenshot protection (and re-attach the window if it
// changed while the app was in the background).
fetchPersistedState()
}
public func applicationWillEnterForeground(_ application: UIApplication) {
// Image overlay removal is handled in applicationDidBecomeActive
// which always fires after this callback.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
persistState()
// Image overlay was already shown in applicationWillResignActive
// which always fires before this callback.
}
public func applicationWillTerminate(_ application: UIApplication) {
persistState()
}
func persistState() {
// Persist the state when changed
UserDefaults.standard.set(IOSNoScreenshotPlugin.preventScreenShot, forKey: IOSNoScreenshotPlugin.preventScreenShotKey)
UserDefaults.standard.set(isImageOverlayModeEnabled, forKey: IOSNoScreenshotPlugin.imageOverlayModeKey)
print("Persisted state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled)")
updateSharedPreferencesState("")
}
func fetchPersistedState() {
// Restore the saved state from UserDefaults
let fetchVal = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.preventScreenShotKey) ? IOSNoScreenshotPlugin.DISABLESCREENSHOT : IOSNoScreenshotPlugin.ENABLESCREENSHOT
isImageOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.imageOverlayModeKey)
updateScreenshotState(isScreenshotBlocked: fetchVal)
print("Fetched state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled)")
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "screenshotOff":
shotOff()
result(true)
case "screenshotOn":
shotOn()
result(true)
case "toggleScreenshotWithImage":
let isActive = toggleScreenshotWithImage()
result(isActive)
case "toggleScreenshot":
IOSNoScreenshotPlugin.preventScreenShot ? shotOn() : shotOff()
result(true)
case "startScreenshotListening":
startListening()
result("Listening started")
case "stopScreenshotListening":
stopListening()
result("Listening stopped")
case "startScreenRecordingListening":
startRecordingListening()
result("Recording listening started")
case "stopScreenRecordingListening":
stopRecordingListening()
result("Recording listening stopped")
default:
result(FlutterMethodNotImplemented)
}
}
private func shotOff() {
IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT
enablePreventScreenshot()
persistState()
}
private func shotOn() {
IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT
disablePreventScreenshot()
persistState()
}
private func toggleScreenshotWithImage() -> Bool {
// Toggle the image overlay mode state
isImageOverlayModeEnabled.toggle()
if isImageOverlayModeEnabled {
// Mode is now active (true) - screenshot prevention should be ON (screenshots blocked)
IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT
enablePreventScreenshot()
} else {
// Mode is now inactive (false) - screenshot prevention should be OFF (screenshots allowed)
IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT
disablePreventScreenshot()
disableImageScreen()
}
persistState()
return isImageOverlayModeEnabled
}
private func startListening() {
NotificationCenter.default.addObserver(self, selector: #selector(screenshotDetected), name: UIApplication.userDidTakeScreenshotNotification, object: nil)
persistState()
}
private func stopListening() {
NotificationCenter.default.removeObserver(self, name: UIApplication.userDidTakeScreenshotNotification, object: nil)
persistState()
}
// MARK: - Screen Recording Detection
private func startRecordingListening() {
guard !isRecordingListening else { return }
isRecordingListening = true
if #available(iOS 11.0, *) {
NotificationCenter.default.addObserver(
self,
selector: #selector(screenCapturedDidChange),
name: UIScreen.capturedDidChangeNotification,
object: nil
)
// Check initial state
isScreenRecording = UIScreen.main.isCaptured
}
updateSharedPreferencesState("")
}
private func stopRecordingListening() {
guard isRecordingListening else { return }
isRecordingListening = false
if #available(iOS 11.0, *) {
NotificationCenter.default.removeObserver(
self,
name: UIScreen.capturedDidChangeNotification,
object: nil
)
}
isScreenRecording = false
updateSharedPreferencesState("")
}
@objc private func screenCapturedDidChange() {
if #available(iOS 11.0, *) {
isScreenRecording = UIScreen.main.isCaptured
}
updateSharedPreferencesState("")
}
@objc private func screenshotDetected() {
print("Screenshot detected")
updateSharedPreferencesState(IOSNoScreenshotPlugin.screenshotPathPlaceholder)
}
private func updateScreenshotState(isScreenshotBlocked: Bool) {
attachWindowIfNeeded()
if isScreenshotBlocked {
enablePreventScreenshot()
} else {
disablePreventScreenshot()
}
}
private func updateSharedPreferencesState(_ screenshotData: String) {
let map: [String: Any] = [
"is_screenshot_on": IOSNoScreenshotPlugin.preventScreenShot,
"screenshot_path": screenshotData,
"was_screenshot_taken": !screenshotData.isEmpty,
"is_screen_recording": isScreenRecording
]
let jsonString = convertMapToJsonString(map)
if lastSharedPreferencesState != jsonString {
hasSharedPreferencesChanged = true
lastSharedPreferencesState = jsonString
}
}
private func convertMapToJsonString(_ map: [String: Any]) -> String {
if let jsonData = try? JSONSerialization.data(withJSONObject: map, options: .prettyPrinted) {
return String(data: jsonData, encoding: .utf8) ?? ""
}
return ""
}
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.screenshotStream()
}
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
private func screenshotStream() {
if hasSharedPreferencesChanged {
eventSink?(lastSharedPreferencesState)
hasSharedPreferencesChanged = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.screenshotStream()
}
}
private func attachWindowIfNeeded() {
var activeWindow: UIWindow?
if #available(iOS 13.0, *) {
if let windowScene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
let active = windowScene.windows.first(where: { $0.isKeyWindow }) {
activeWindow = active
}
} else {
activeWindow = UIApplication.shared.windows.filter {$0.isKeyWindow}.first
}
guard let window = activeWindow else {
print("No active window found.")
return
}
// Skip re-configuration if already attached to this window.
if window === attachedWindow {
return
}
// Clean up old state before re-attaching to a new window.
if isImageOverlayModeEnabled {
disableImageScreen()
}
disablePreventScreenshot()
// Undo previous layer reparenting: move the old window's layer
// back to the root layer and detach the text field's layer.
if let oldWindow = attachedWindow,
let rootLayer = screenPrevent.layer.superlayer {
rootLayer.addSublayer(oldWindow.layer)
screenPrevent.layer.removeFromSuperlayer()
}
// Use a fresh UITextField to avoid stale layer state.
screenPrevent = UITextField()
configurePreventionScreenshot(window: window)
self.attachedWindow = window
}
}