From 24d048b4abbe5c266b09965cc6f3ebdf83f97855 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 14 Mar 2026 00:16:48 +0100 Subject: [PATCH] bump versions --- config.lock.yaml | 4 +- .../no_screenshot/NoScreenshotPlugin.kt | 434 +++++++++++- .../ios/Classes/IOSNoScreenshotPlugin.swift | 383 ---------- no_screenshot/ios/no_screenshot.podspec | 8 +- no_screenshot/ios/no_screenshot/Package.swift | 24 + .../no_screenshot/IOSNoScreenshotPlugin.swift | 638 +++++++++++++++++ .../no_screenshot}/PrivacyInfo.xcprivacy | 0 no_screenshot/lib/constants.dart | 5 + no_screenshot/lib/no_screenshot.dart | 97 ++- .../lib/no_screenshot_method_channel.dart | 52 +- .../lib/no_screenshot_platform_interface.dart | 44 +- no_screenshot/lib/no_screenshot_web.dart | 210 ++++++ no_screenshot/lib/overlay_mode.dart | 31 + no_screenshot/lib/screenshot_snapshot.dart | 26 +- .../lib/secure_navigator_observer.dart | 82 +++ no_screenshot/lib/secure_widget.dart | 68 ++ no_screenshot/pubspec.yaml | 25 +- .../no_screenshot_method_channel_test.dart | 666 ++++++++++++++++-- ...no_screenshot_platform_interface_test.dart | 269 +++++-- no_screenshot/test/no_screenshot_test.dart | 79 ++- .../test/no_screenshot_web_test.dart | 131 ++++ .../test/secure_navigator_observer_test.dart | 240 +++++++ no_screenshot/test/secure_widget_test.dart | 236 +++++++ restart_app/LICENSE | 2 +- .../gabrimatic/info/restart/RestartPlugin.kt | 89 ++- .../ios/Classes/RestartAppPlugin.swift | 25 - restart_app/ios/restart_app.podspec | 12 +- restart_app/ios/restart_app/Package.swift | 20 + .../restart_app/RestartAppPlugin.swift | 96 +++ restart_app/lib/restart_app.dart | 37 +- restart_app/lib/restart_web.dart | 43 +- restart_app/pubspec.yaml | 25 +- restart_app/test/restart_app_test.dart | 101 +++ 33 files changed, 3567 insertions(+), 635 deletions(-) delete mode 100644 no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift create mode 100644 no_screenshot/ios/no_screenshot/Package.swift create mode 100644 no_screenshot/ios/no_screenshot/Sources/no_screenshot/IOSNoScreenshotPlugin.swift rename no_screenshot/ios/{Resources => no_screenshot/Sources/no_screenshot}/PrivacyInfo.xcprivacy (100%) create mode 100644 no_screenshot/lib/no_screenshot_web.dart create mode 100644 no_screenshot/lib/overlay_mode.dart create mode 100644 no_screenshot/lib/secure_navigator_observer.dart create mode 100644 no_screenshot/lib/secure_widget.dart create mode 100644 no_screenshot/test/no_screenshot_web_test.dart create mode 100644 no_screenshot/test/secure_navigator_observer_test.dart create mode 100644 no_screenshot/test/secure_widget_test.dart delete mode 100644 restart_app/ios/Classes/RestartAppPlugin.swift create mode 100644 restart_app/ios/restart_app/Package.swift create mode 100644 restart_app/ios/restart_app/Sources/restart_app/RestartAppPlugin.swift create mode 100644 restart_app/test/restart_app_test.dart diff --git a/config.lock.yaml b/config.lock.yaml index bb36918..6f69c2b 100644 --- a/config.lock.yaml +++ b/config.lock.yaml @@ -9,11 +9,11 @@ introduction_screen: 4a90e557630b28834479ed9c64a9d2d0185d8e48 libsignal_protocol_dart: 618f0c0b49534245a640a31d204265440cbac9ee lottie: 4f1a5a52bdf1e1c1e12fa97c96174dcb05419e19 mutex: 84ca903a3ac863735e3228c75a212133621f680f -no_screenshot: 9ca2a492ff12e5179583a1fa015bf0843382b866 +no_screenshot: 299c2d1d70448f23f1e9835a18585edfc9dbe6fa optional: 71c638891ce4f2aff35c7387727989f31f9d877d photo_view: a13ca2fc387a3fb1276126959e092c44d0029987 pointycastle: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd qr: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e qr_flutter: d5e7206396105d643113618290bbcc755d05f492 -restart_app: 12339f63bf8e9631e619c4f9f6b4e013fa324715 +restart_app: 66897cb67e235bab85421647bfae036acb4438cb x25519: ecb1d357714537bba6e276ef45f093846d4beaee diff --git a/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt b/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt index c01a389..f4d25cd 100644 --- a/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt +++ b/no_screenshot/android/src/main/kotlin/com/flutterplaza/no_screenshot/NoScreenshotPlugin.kt @@ -5,13 +5,24 @@ import android.app.Application import android.content.Context import android.content.SharedPreferences import android.database.ContentObserver +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.RenderEffect +import android.graphics.Shader import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur import android.util.Log +import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.view.WindowManager.LayoutParams import android.widget.FrameLayout import android.widget.ImageView @@ -35,7 +46,16 @@ const val SCREENSHOT_PATH = "screenshot_path" const val PREF_KEY_SCREENSHOT = "is_screenshot_on" const val SCREENSHOT_TAKEN = "was_screenshot_taken" const val SET_IMAGE_CONST = "toggleScreenshotWithImage" +const val SET_BLUR_CONST = "toggleScreenshotWithBlur" +const val SET_COLOR_CONST = "toggleScreenshotWithColor" +const val ENABLE_IMAGE_CONST = "screenshotWithImage" +const val ENABLE_BLUR_CONST = "screenshotWithBlur" +const val ENABLE_COLOR_CONST = "screenshotWithColor" const val PREF_KEY_IMAGE_OVERLAY = "is_image_overlay_mode_enabled" +const val PREF_KEY_BLUR_OVERLAY = "is_blur_overlay_mode_enabled" +const val PREF_KEY_COLOR_OVERLAY = "is_color_overlay_mode_enabled" +const val PREF_KEY_BLUR_RADIUS = "blur_radius" +const val PREF_KEY_COLOR_VALUE = "color_value" const val IS_SCREEN_RECORDING = "is_screen_recording" const val START_SCREEN_RECORDING_LISTENING_CONST = "startScreenRecordingListening" const val STOP_SCREEN_RECORDING_LISTENING_CONST = "stopScreenRecordingListening" @@ -57,11 +77,18 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ private var lastSharedPreferencesState: String = "" private var hasSharedPreferencesChanged: Boolean = false private var isImageOverlayModeEnabled: Boolean = false + private var isBlurOverlayModeEnabled: Boolean = false + private var isColorOverlayModeEnabled: Boolean = false private var overlayImageView: ImageView? = null + private var overlayBlurView: View? = null + private var overlayColorView: View? = null + private var blurRadius: Float = 30f + private var colorValue: Int = 0xFF000000.toInt() private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null private var isScreenRecording: Boolean = false private var isRecordingListening: Boolean = false private var screenCaptureCallback: Any? = null + private var screenRecordingCallback: Any? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext @@ -87,13 +114,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ activity = binding.activity restoreScreenshotState() if (isRecordingListening) { - registerScreenCaptureCallback() + registerScreenRecordingCallbacks() } } override fun onDetachedFromActivityForConfigChanges() { - unregisterScreenCaptureCallback() + unregisterScreenRecordingCallbacks() removeImageOverlay() + removeBlurOverlay() + removeColorOverlay() activity = null } @@ -101,13 +130,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ activity = binding.activity restoreScreenshotState() if (isRecordingListening) { - registerScreenCaptureCallback() + registerScreenRecordingCallbacks() } } override fun onDetachedFromActivity() { - unregisterScreenCaptureCallback() + unregisterScreenRecordingCallbacks() removeImageOverlay() + removeBlurOverlay() + removeColorOverlay() activity = null } @@ -140,6 +171,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ result.success(toggleScreenshotWithImage()) } + SET_BLUR_CONST -> { + val radius = (call.argument("radius") ?: 30.0).toFloat() + result.success(toggleScreenshotWithBlur(radius)) + } + + SET_COLOR_CONST -> { + val color = call.argument("color") ?: 0xFF000000.toInt() + result.success(toggleScreenshotWithColor(color)) + } + + ENABLE_IMAGE_CONST -> { + result.success(enableImageOverlay()) + } + + ENABLE_BLUR_CONST -> { + val radius = (call.argument("radius") ?: 30.0).toFloat() + result.success(enableBlurOverlay(radius)) + } + + ENABLE_COLOR_CONST -> { + val color = call.argument("color") ?: 0xFF000000.toInt() + result.success(enableColorOverlay(color)) + } + START_SCREEN_RECORDING_LISTENING_CONST -> { startRecordingListening() result.success("Recording listening started") @@ -171,7 +226,12 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ if (act == activity && isImageOverlayModeEnabled) { act.window?.clearFlags(LayoutParams.FLAG_SECURE) showImageOverlay(act) - + } else if (act == activity && isBlurOverlayModeEnabled) { + act.window?.clearFlags(LayoutParams.FLAG_SECURE) + showBlurOverlay(act) + } else if (act == activity && isColorOverlayModeEnabled) { + act.window?.clearFlags(LayoutParams.FLAG_SECURE) + showColorOverlay(act) } } @@ -179,6 +239,12 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ if (act == activity && isImageOverlayModeEnabled) { removeImageOverlay() act.window?.addFlags(LayoutParams.FLAG_SECURE) + } else if (act == activity && isBlurOverlayModeEnabled) { + removeBlurOverlay() + act.window?.addFlags(LayoutParams.FLAG_SECURE) + } else if (act == activity && isColorOverlayModeEnabled) { + removeColorOverlay() + act.window?.addFlags(LayoutParams.FLAG_SECURE) } } @@ -188,6 +254,10 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ override fun onActivitySaveInstanceState(act: Activity, outState: Bundle) { if (act == activity && isImageOverlayModeEnabled) { showImageOverlay(act) + } else if (act == activity && isBlurOverlayModeEnabled) { + showBlurOverlay(act) + } else if (act == activity && isColorOverlayModeEnabled) { + showColorOverlay(act) } } override fun onActivityDestroyed(act: Activity) {} @@ -238,6 +308,17 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ saveImageOverlayState(isImageOverlayModeEnabled) if (isImageOverlayModeEnabled) { + // Deactivate blur and color modes if active (mutual exclusivity) + if (isBlurOverlayModeEnabled) { + isBlurOverlayModeEnabled = false + saveBlurOverlayState(false) + removeBlurOverlay() + } + if (isColorOverlayModeEnabled) { + isColorOverlayModeEnabled = false + saveColorOverlayState(false) + removeColorOverlay() + } screenshotOff() } else { screenshotOn() @@ -247,31 +328,301 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ return isImageOverlayModeEnabled } + private fun toggleScreenshotWithBlur(radius: Float): Boolean { + isBlurOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_BLUR_OVERLAY, false) + blurRadius = radius + saveBlurOverlayState(isBlurOverlayModeEnabled) + saveBlurRadius(radius) + + if (isBlurOverlayModeEnabled) { + // Deactivate image and color modes if active (mutual exclusivity) + if (isImageOverlayModeEnabled) { + isImageOverlayModeEnabled = false + saveImageOverlayState(false) + removeImageOverlay() + } + if (isColorOverlayModeEnabled) { + isColorOverlayModeEnabled = false + saveColorOverlayState(false) + removeColorOverlay() + } + screenshotOff() + } else { + screenshotOn() + removeBlurOverlay() + } + updateSharedPreferencesState("") + return isBlurOverlayModeEnabled + } + + @Suppress("DEPRECATION") + private fun showBlurOverlay(activity: Activity) { + if (overlayBlurView != null) return + val decorView = activity.window?.decorView ?: return + val radius = blurRadius.coerceAtLeast(0.1f) + + if (Build.VERSION.SDK_INT >= 31) { + // API 31+: GPU blur via RenderEffect on decorView — supports any radius + activity.runOnUiThread { + decorView.setRenderEffect( + RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP) + ) + overlayBlurView = decorView + } + } else if (Build.VERSION.SDK_INT >= 17) { + // API 17–30: Capture bitmap, blur with RenderScript (max 25f) + activity.runOnUiThread { + val width = decorView.width + val height = decorView.height + if (width <= 0 || height <= 0) return@runOnUiThread + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + decorView.draw(canvas) + + val rs = RenderScript.create(activity) + val input = Allocation.createFromBitmap(rs, bitmap) + val output = Allocation.createTyped(rs, input.type) + val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + script.setRadius(radius.coerceAtMost(25f)) + script.setInput(input) + script.forEach(output) + output.copyTo(bitmap) + script.destroy() + input.destroy() + output.destroy() + rs.destroy() + + val imageView = ImageView(activity).apply { + setImageBitmap(bitmap) + scaleType = ImageView.ScaleType.FIT_XY + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + (decorView as? ViewGroup)?.addView(imageView) + overlayBlurView = imageView + } + } + // API <17: FLAG_SECURE alone prevents app switcher preview; no blur needed. + } + + private fun removeBlurOverlay() { + val blurView = overlayBlurView ?: return + val act = activity + if (act != null) { + act.runOnUiThread { + if (Build.VERSION.SDK_INT >= 31 && blurView === act.window?.decorView) { + blurView.setRenderEffect(null) + } else { + (blurView.parent as? ViewGroup)?.removeView(blurView) + } + overlayBlurView = null + } + } else { + if (Build.VERSION.SDK_INT >= 31) { + blurView.setRenderEffect(null) + } else { + (blurView.parent as? ViewGroup)?.removeView(blurView) + } + overlayBlurView = null + } + } + + private fun toggleScreenshotWithColor(color: Int): Boolean { + isColorOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_COLOR_OVERLAY, false) + colorValue = color + saveColorOverlayState(isColorOverlayModeEnabled) + saveColorValue(color) + + if (isColorOverlayModeEnabled) { + // Deactivate image and blur modes if active (mutual exclusivity) + if (isImageOverlayModeEnabled) { + isImageOverlayModeEnabled = false + saveImageOverlayState(false) + removeImageOverlay() + } + if (isBlurOverlayModeEnabled) { + isBlurOverlayModeEnabled = false + saveBlurOverlayState(false) + removeBlurOverlay() + } + screenshotOff() + } else { + screenshotOn() + removeColorOverlay() + } + updateSharedPreferencesState("") + return isColorOverlayModeEnabled + } + + private fun enableImageOverlay(): Boolean { + isImageOverlayModeEnabled = true + saveImageOverlayState(true) + if (isBlurOverlayModeEnabled) { + isBlurOverlayModeEnabled = false + saveBlurOverlayState(false) + removeBlurOverlay() + } + if (isColorOverlayModeEnabled) { + isColorOverlayModeEnabled = false + saveColorOverlayState(false) + removeColorOverlay() + } + screenshotOff() + updateSharedPreferencesState("") + return true + } + + private fun enableBlurOverlay(radius: Float): Boolean { + isBlurOverlayModeEnabled = true + blurRadius = radius + saveBlurOverlayState(true) + saveBlurRadius(radius) + if (isImageOverlayModeEnabled) { + isImageOverlayModeEnabled = false + saveImageOverlayState(false) + removeImageOverlay() + } + if (isColorOverlayModeEnabled) { + isColorOverlayModeEnabled = false + saveColorOverlayState(false) + removeColorOverlay() + } + screenshotOff() + updateSharedPreferencesState("") + return true + } + + private fun enableColorOverlay(color: Int): Boolean { + isColorOverlayModeEnabled = true + colorValue = color + saveColorOverlayState(true) + saveColorValue(color) + if (isImageOverlayModeEnabled) { + isImageOverlayModeEnabled = false + saveImageOverlayState(false) + removeImageOverlay() + } + if (isBlurOverlayModeEnabled) { + isBlurOverlayModeEnabled = false + saveBlurOverlayState(false) + removeBlurOverlay() + } + screenshotOff() + updateSharedPreferencesState("") + return true + } + + private fun showColorOverlay(activity: Activity) { + if (overlayColorView != null) return + val decorView = activity.window?.decorView ?: return + activity.runOnUiThread { + val colorView = View(activity).apply { + setBackgroundColor(colorValue) + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + (decorView as? ViewGroup)?.addView(colorView) + overlayColorView = colorView + } + } + + private fun removeColorOverlay() { + val colorView = overlayColorView ?: return + val act = activity + if (act != null) { + act.runOnUiThread { + (colorView.parent as? ViewGroup)?.removeView(colorView) + overlayColorView = null + } + } else { + (colorView.parent as? ViewGroup)?.removeView(colorView) + overlayColorView = null + } + } + // ── Screen Recording Detection ───────────────────────────────────── + // + // API 35+ (Android 15): WindowManager.addScreenRecordingCallback + // → true start/stop detection via SCREEN_RECORDING_STATE_VISIBLE / _NOT_VISIBLE + // API 34 (Android 14): Activity.ScreenCaptureCallback + // → fires on screen capture (start only, no stop event) + // API <34: graceful no-op private fun startRecordingListening() { if (isRecordingListening) return isRecordingListening = true - registerScreenCaptureCallback() + registerScreenRecordingCallbacks() updateSharedPreferencesState("") } private fun stopRecordingListening() { if (!isRecordingListening) return isRecordingListening = false - unregisterScreenCaptureCallback() + unregisterScreenRecordingCallbacks() isScreenRecording = false updateSharedPreferencesState("") } + private fun registerScreenRecordingCallbacks() { + if (Build.VERSION.SDK_INT >= 35) { + registerScreenRecordingCallback() + } else if (Build.VERSION.SDK_INT >= 34) { + registerScreenCaptureCallback() + } + } + + private fun unregisterScreenRecordingCallbacks() { + if (Build.VERSION.SDK_INT >= 35) { + unregisterScreenRecordingCallback() + } + if (Build.VERSION.SDK_INT >= 34) { + unregisterScreenCaptureCallback() + } + } + + @Suppress("NewApi") + private fun registerScreenRecordingCallback() { + val act = activity ?: return + if (screenRecordingCallback != null) return + + val callback = java.util.function.Consumer { state -> + val wasRecording = isScreenRecording + isScreenRecording = (state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE) + if (isScreenRecording != wasRecording) { + updateSharedPreferencesState("", System.currentTimeMillis()) + } + } + val initialState = act.windowManager.addScreenRecordingCallback(act.mainExecutor, callback) + screenRecordingCallback = callback + // Process initial state + val wasRecording = isScreenRecording + isScreenRecording = (initialState == WindowManager.SCREEN_RECORDING_STATE_VISIBLE) + if (isScreenRecording != wasRecording) { + updateSharedPreferencesState("", System.currentTimeMillis()) + } + } + + @Suppress("NewApi", "UNCHECKED_CAST") + private fun unregisterScreenRecordingCallback() { + val act = activity ?: return + val callback = screenRecordingCallback as? java.util.function.Consumer ?: return + act.windowManager.removeScreenRecordingCallback(callback) + screenRecordingCallback = null + } + private fun registerScreenCaptureCallback() { - if (android.os.Build.VERSION.SDK_INT >= 34) { + if (Build.VERSION.SDK_INT >= 34) { val act = activity ?: return if (screenCaptureCallback != null) return val callback = Activity.ScreenCaptureCallback { isScreenRecording = true - updateSharedPreferencesState("") + updateSharedPreferencesState("", System.currentTimeMillis()) } act.registerScreenCaptureCallback(act.mainExecutor, callback) screenCaptureCallback = callback @@ -279,7 +630,7 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ } private fun unregisterScreenCaptureCallback() { - if (android.os.Build.VERSION.SDK_INT >= 34) { + if (Build.VERSION.SDK_INT >= 34) { val act = activity ?: return val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback ?: return act.unregisterScreenCaptureCallback(callback) @@ -296,7 +647,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ .contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString()) ) { Log.d("ScreenshotProtection", "Screenshot detected") - updateSharedPreferencesState(it.path ?: "") + var timestampMs = System.currentTimeMillis() + var displayName = "" + try { + val projection = arrayOf( + MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.DISPLAY_NAME + ) + context.contentResolver.query(it, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val dateIdx = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED) + if (dateIdx >= 0) { + val dateAdded = cursor.getLong(dateIdx) + if (dateAdded > 0) timestampMs = dateAdded * 1000 + } + val nameIdx = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + if (nameIdx >= 0) { + displayName = cursor.getString(nameIdx) ?: "" + } + } + } + } catch (_: Exception) { + // Query may fail due to permissions; fall back to defaults. + } + updateSharedPreferencesState(it.path ?: "", timestampMs, displayName) } } } @@ -355,14 +729,44 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ } } + private fun saveBlurOverlayState(enabled: Boolean) { + Executors.newSingleThreadExecutor().execute { + preferences.edit().putBoolean(PREF_KEY_BLUR_OVERLAY, enabled).apply() + } + } + + private fun saveBlurRadius(radius: Float) { + Executors.newSingleThreadExecutor().execute { + preferences.edit().putFloat(PREF_KEY_BLUR_RADIUS, radius).apply() + } + } + + private fun saveColorOverlayState(enabled: Boolean) { + Executors.newSingleThreadExecutor().execute { + preferences.edit().putBoolean(PREF_KEY_COLOR_OVERLAY, enabled).apply() + } + } + + private fun saveColorValue(color: Int) { + Executors.newSingleThreadExecutor().execute { + preferences.edit().putInt(PREF_KEY_COLOR_VALUE, color).apply() + } + } + private fun restoreScreenshotState() { Executors.newSingleThreadExecutor().execute { val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false) val overlayEnabled = preferences.getBoolean(PREF_KEY_IMAGE_OVERLAY, false) + val blurEnabled = preferences.getBoolean(PREF_KEY_BLUR_OVERLAY, false) + val colorEnabled = preferences.getBoolean(PREF_KEY_COLOR_OVERLAY, false) isImageOverlayModeEnabled = overlayEnabled + isBlurOverlayModeEnabled = blurEnabled + isColorOverlayModeEnabled = colorEnabled + blurRadius = preferences.getFloat(PREF_KEY_BLUR_RADIUS, 30f) + colorValue = preferences.getInt(PREF_KEY_COLOR_VALUE, 0xFF000000.toInt()) activity?.runOnUiThread { - if (isImageOverlayModeEnabled || isSecure) { + if (isImageOverlayModeEnabled || isBlurOverlayModeEnabled || isColorOverlayModeEnabled || isSecure) { screenshotOff() } else { screenshotOn() @@ -371,7 +775,7 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ } } - private fun updateSharedPreferencesState(screenshotData: String) { + private fun updateSharedPreferencesState(screenshotData: String, timestampMs: Long = 0L, sourceApp: String = "") { Handler(Looper.getMainLooper()).postDelayed({ val isSecure = (activity?.window?.attributes?.flags ?: 0) and LayoutParams.FLAG_SECURE != 0 @@ -380,7 +784,9 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ PREF_KEY_SCREENSHOT to isSecure, SCREENSHOT_PATH to screenshotData, SCREENSHOT_TAKEN to screenshotData.isNotEmpty(), - IS_SCREEN_RECORDING to isScreenRecording + IS_SCREEN_RECORDING to isScreenRecording, + "timestamp" to timestampMs, + "source_app" to sourceApp ) ) if (lastSharedPreferencesState != jsonString) { diff --git a/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift b/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift deleted file mode 100644 index b233626..0000000 --- a/no_screenshot/ios/Classes/IOSNoScreenshotPlugin.swift +++ /dev/null @@ -1,383 +0,0 @@ -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 - } -} diff --git a/no_screenshot/ios/no_screenshot.podspec b/no_screenshot/ios/no_screenshot.podspec index 930aad2..324cc20 100644 --- a/no_screenshot/ios/no_screenshot.podspec +++ b/no_screenshot/ios/no_screenshot.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'no_screenshot' - s.version = '0.3.2-beta.3' + s.version = '0.10.0' s.summary = 'Flutter plugin to enable, disable or toggle screenshot support in your application.' s.description = <<-DESC A new Flutter plugin project. @@ -13,12 +13,12 @@ A new Flutter plugin project. s.license = { :file => '../LICENSE' } s.author = { 'FlutterPlaza' => 'dev@flutterplaza.com' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'no_screenshot/Sources/no_screenshot/**/*.swift', 'Classes/**/*.{h,m}' s.dependency 'Flutter' - s.platform = :ios, '10.0' + s.platform = :ios, '13.0' + s.resource_bundles = { 'no_screenshot_privacy' => ['no_screenshot/Sources/no_screenshot/PrivacyInfo.xcprivacy'] } # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - # Updated swift_version to a single version as an array is not supported for this attribute s.swift_version = "5.0" end diff --git a/no_screenshot/ios/no_screenshot/Package.swift b/no_screenshot/ios/no_screenshot/Package.swift new file mode 100644 index 0000000..c20ec7d --- /dev/null +++ b/no_screenshot/ios/no_screenshot/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "no_screenshot", + platforms: [ + .iOS("13.0") + ], + products: [ + .library(name: "no-screenshot", targets: ["no_screenshot"]) + ], + dependencies: [], + targets: [ + .target( + name: "no_screenshot", + dependencies: [], + resources: [ + .process("PrivacyInfo.xcprivacy") + ] + ) + ] +) diff --git a/no_screenshot/ios/no_screenshot/Sources/no_screenshot/IOSNoScreenshotPlugin.swift b/no_screenshot/ios/no_screenshot/Sources/no_screenshot/IOSNoScreenshotPlugin.swift new file mode 100644 index 0000000..5f0b300 --- /dev/null +++ b/no_screenshot/ios/no_screenshot/Sources/no_screenshot/IOSNoScreenshotPlugin.swift @@ -0,0 +1,638 @@ +import Flutter +import UIKit + +#if SWIFT_PACKAGE +@objc(NoScreenshotPlugin) +#endif +public class IOSNoScreenshotPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, FlutterSceneLifeCycleDelegate { + 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 isBlurOverlayModeEnabled: Bool = false + private var blurOverlayView: UIView? = nil + private var blurRadius: Double = 30.0 + private var isColorOverlayModeEnabled: Bool = false + private var colorOverlayView: UIView? = nil + private var colorValue: Int = 0xFF000000 + 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 blurOverlayModeKey = "blurOverlayMode" + private static let blurRadiusKey = "blurRadius" + private static let colorOverlayModeKey = "colorOverlayMode" + private static let colorValueKey = "colorValue" + 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) + isBlurOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.blurOverlayModeKey) + let savedRadius = UserDefaults.standard.double(forKey: IOSNoScreenshotPlugin.blurRadiusKey) + blurRadius = savedRadius > 0 ? savedRadius : 30.0 + isColorOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.colorOverlayModeKey) + colorValue = UserDefaults.standard.integer(forKey: IOSNoScreenshotPlugin.colorValueKey) + if colorValue == 0 { colorValue = 0xFF000000 } + 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) + registrar.addSceneDelegate(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: window.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: - Shared Lifecycle Helpers + // + // Overlay lifecycle is intentionally handled in exactly two places: + // SHOW: handleWillResignActive (app is about to lose focus) + // HIDE: handleDidBecomeActive (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 overlay. + + private func handleWillResignActive() { + 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") + } else if isBlurOverlayModeEnabled { + disablePreventScreenshot() + enableBlurScreen(radius: blurRadius) + } else if isColorOverlayModeEnabled { + disablePreventScreenshot() + enableColorScreen(color: colorValue) + } + } + + private func handleDidBecomeActive() { + // Remove overlays FIRST. + if isImageOverlayModeEnabled { + disableImageScreen() + } else if isBlurOverlayModeEnabled { + disableBlurScreen() + } else if isColorOverlayModeEnabled { + disableColorScreen() + } + + // Now restore screenshot protection (and re-attach the window if it + // changed while the app was in the background). + fetchPersistedState() + } + + private func handleDidEnterBackground() { + persistState() + } + + private func handleWillTerminate() { + persistState() + } + + // MARK: - App Delegate Lifecycle (for apps not yet using UIScene) + + public func applicationWillResignActive(_ application: UIApplication) { handleWillResignActive() } + public func applicationDidBecomeActive(_ application: UIApplication) { handleDidBecomeActive() } + public func applicationWillEnterForeground(_ application: UIApplication) { /* handled in didBecomeActive */ } + public func applicationDidEnterBackground(_ application: UIApplication) { handleDidEnterBackground() } + public func applicationWillTerminate(_ application: UIApplication) { handleWillTerminate() } + + // MARK: - Scene Delegate Lifecycle (for apps using UIScene) + + public func sceneWillResignActive(_ scene: UIScene) { handleWillResignActive() } + public func sceneDidBecomeActive(_ scene: UIScene) { handleDidBecomeActive() } + public func sceneWillEnterForeground(_ scene: UIScene) { /* handled in didBecomeActive */ } + public func sceneDidEnterBackground(_ scene: UIScene) { handleDidEnterBackground() } + + func persistState() { + // Persist the state when changed + UserDefaults.standard.set(IOSNoScreenshotPlugin.preventScreenShot, forKey: IOSNoScreenshotPlugin.preventScreenShotKey) + UserDefaults.standard.set(isImageOverlayModeEnabled, forKey: IOSNoScreenshotPlugin.imageOverlayModeKey) + UserDefaults.standard.set(isBlurOverlayModeEnabled, forKey: IOSNoScreenshotPlugin.blurOverlayModeKey) + UserDefaults.standard.set(blurRadius, forKey: IOSNoScreenshotPlugin.blurRadiusKey) + UserDefaults.standard.set(isColorOverlayModeEnabled, forKey: IOSNoScreenshotPlugin.colorOverlayModeKey) + UserDefaults.standard.set(colorValue, forKey: IOSNoScreenshotPlugin.colorValueKey) + print("Persisted state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled), blurOverlay: \(isBlurOverlayModeEnabled), blurRadius: \(blurRadius), colorOverlay: \(isColorOverlayModeEnabled), colorValue: \(colorValue)") + 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) + isBlurOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.blurOverlayModeKey) + let savedRadius = UserDefaults.standard.double(forKey: IOSNoScreenshotPlugin.blurRadiusKey) + blurRadius = savedRadius > 0 ? savedRadius : 30.0 + isColorOverlayModeEnabled = UserDefaults.standard.bool(forKey: IOSNoScreenshotPlugin.colorOverlayModeKey) + colorValue = UserDefaults.standard.integer(forKey: IOSNoScreenshotPlugin.colorValueKey) + if colorValue == 0 { colorValue = 0xFF000000 } + updateScreenshotState(isScreenshotBlocked: fetchVal) + print("Fetched state: \(IOSNoScreenshotPlugin.preventScreenShot), imageOverlay: \(isImageOverlayModeEnabled), blurOverlay: \(isBlurOverlayModeEnabled), blurRadius: \(blurRadius), colorOverlay: \(isColorOverlayModeEnabled), colorValue: \(colorValue)") + } + + 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 "toggleScreenshotWithBlur": + let radius = (call.arguments as? [String: Any])?["radius"] as? Double ?? 30.0 + let isActive = toggleScreenshotWithBlur(radius: radius) + result(isActive) + case "toggleScreenshotWithColor": + let color = (call.arguments as? [String: Any])?["color"] as? Int ?? 0xFF000000 + let isActive = toggleScreenshotWithColor(color: color) + result(isActive) + case "toggleScreenshot": + IOSNoScreenshotPlugin.preventScreenShot ? shotOn() : shotOff() + result(true) + case "screenshotWithImage": + enableImageOverlay() + result(true) + case "screenshotWithBlur": + let radius = (call.arguments as? [String: Any])?["radius"] as? Double ?? 30.0 + enableBlurOverlay(radius: radius) + result(true) + case "screenshotWithColor": + let color = (call.arguments as? [String: Any])?["color"] as? Int ?? 0xFF000000 + enableColorOverlay(color: color) + 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 { + // Deactivate blur mode if active (mutual exclusivity) + if isBlurOverlayModeEnabled { + isBlurOverlayModeEnabled = false + disableBlurScreen() + } + // Deactivate color mode if active (mutual exclusivity) + if isColorOverlayModeEnabled { + isColorOverlayModeEnabled = false + disableColorScreen() + } + // 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 toggleScreenshotWithBlur(radius: Double) -> Bool { + isBlurOverlayModeEnabled.toggle() + blurRadius = radius + + if isBlurOverlayModeEnabled { + // Deactivate image mode if active (mutual exclusivity) + if isImageOverlayModeEnabled { + isImageOverlayModeEnabled = false + disableImageScreen() + } + // Deactivate color mode if active (mutual exclusivity) + if isColorOverlayModeEnabled { + isColorOverlayModeEnabled = false + disableColorScreen() + } + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + } else { + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT + disablePreventScreenshot() + disableBlurScreen() + } + + persistState() + return isBlurOverlayModeEnabled + } + + private func enableBlurScreen(radius: Double) { + guard let window = attachedWindow else { return } + + // Capture the current window content as a snapshot. + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + let snapshot = renderer.image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + + // Apply a true CIGaussianBlur (no tinting / darkening). + guard let ciImage = CIImage(image: snapshot), + let filter = CIFilter(name: "CIGaussianBlur") else { return } + + filter.setValue(ciImage, forKey: kCIInputImageKey) + filter.setValue(radius, forKey: kCIInputRadiusKey) + + let context = CIContext(options: nil) + guard let output = filter.outputImage, + let cgImage = context.createCGImage(output, from: ciImage.extent) else { return } + + let imageView = UIImageView(frame: window.bounds) + imageView.image = UIImage(cgImage: cgImage) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.isUserInteractionEnabled = false + imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(imageView) + blurOverlayView = imageView + } + + private func disableBlurScreen() { + blurOverlayView?.removeFromSuperview() + blurOverlayView = nil + } + + private func toggleScreenshotWithColor(color: Int) -> Bool { + isColorOverlayModeEnabled.toggle() + colorValue = color + + if isColorOverlayModeEnabled { + // Deactivate image and blur modes (mutual exclusivity) + if isImageOverlayModeEnabled { + isImageOverlayModeEnabled = false + disableImageScreen() + } + if isBlurOverlayModeEnabled { + isBlurOverlayModeEnabled = false + disableBlurScreen() + } + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + } else { + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.ENABLESCREENSHOT + disablePreventScreenshot() + disableColorScreen() + } + + persistState() + return isColorOverlayModeEnabled + } + + // MARK: - Idempotent enable methods (always-on, no toggle) + + private func enableImageOverlay() { + isImageOverlayModeEnabled = true + if isBlurOverlayModeEnabled { + isBlurOverlayModeEnabled = false + disableBlurScreen() + } + if isColorOverlayModeEnabled { + isColorOverlayModeEnabled = false + disableColorScreen() + } + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + persistState() + } + + private func enableBlurOverlay(radius: Double) { + isBlurOverlayModeEnabled = true + blurRadius = radius + if isImageOverlayModeEnabled { + isImageOverlayModeEnabled = false + disableImageScreen() + } + if isColorOverlayModeEnabled { + isColorOverlayModeEnabled = false + disableColorScreen() + } + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + persistState() + } + + private func enableColorOverlay(color: Int) { + isColorOverlayModeEnabled = true + colorValue = color + if isImageOverlayModeEnabled { + isImageOverlayModeEnabled = false + disableImageScreen() + } + if isBlurOverlayModeEnabled { + isBlurOverlayModeEnabled = false + disableBlurScreen() + } + IOSNoScreenshotPlugin.preventScreenShot = IOSNoScreenshotPlugin.DISABLESCREENSHOT + enablePreventScreenshot() + persistState() + } + + private func enableColorScreen(color: Int) { + guard let window = attachedWindow else { return } + let a = CGFloat((color >> 24) & 0xFF) / 255.0 + let r = CGFloat((color >> 16) & 0xFF) / 255.0 + let g = CGFloat((color >> 8) & 0xFF) / 255.0 + let b = CGFloat(color & 0xFF) / 255.0 + let uiColor = UIColor(red: r, green: g, blue: b, alpha: a) + + let colorView = UIView(frame: window.bounds) + colorView.backgroundColor = uiColor + colorView.isUserInteractionEnabled = false + colorView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(colorView) + colorOverlayView = colorView + } + + private func disableColorScreen() { + colorOverlayView?.removeFromSuperview() + colorOverlayView = nil + } + + 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 var isScreenCaptured: Bool { + if let windowScene = attachedWindow?.windowScene { + return windowScene.screen.isCaptured + } + if let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + return windowScene.screen.isCaptured + } + return false + } + + private func startRecordingListening() { + guard !isRecordingListening else { return } + isRecordingListening = true + + NotificationCenter.default.addObserver( + self, + selector: #selector(screenCapturedDidChange), + name: UIScreen.capturedDidChangeNotification, + object: nil + ) + // Check initial state + isScreenRecording = isScreenCaptured + + updateSharedPreferencesState("") + } + + private func stopRecordingListening() { + guard isRecordingListening else { return } + isRecordingListening = false + + NotificationCenter.default.removeObserver( + self, + name: UIScreen.capturedDidChangeNotification, + object: nil + ) + + isScreenRecording = false + updateSharedPreferencesState("") + } + + @objc private func screenCapturedDidChange() { + isScreenRecording = isScreenCaptured + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + updateSharedPreferencesState("", timestamp: nowMs) + } + + @objc private func screenshotDetected() { + print("Screenshot detected") + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + updateSharedPreferencesState(IOSNoScreenshotPlugin.screenshotPathPlaceholder, timestamp: nowMs) + } + + private func updateScreenshotState(isScreenshotBlocked: Bool) { + attachWindowIfNeeded() + if isScreenshotBlocked { + enablePreventScreenshot() + } else { + disablePreventScreenshot() + } + } + + private func updateSharedPreferencesState(_ screenshotData: String, timestamp: Int64 = 0, sourceApp: String = "") { + let map: [String: Any] = [ + "is_screenshot_on": IOSNoScreenshotPlugin.preventScreenShot, + "screenshot_path": screenshotData, + "was_screenshot_taken": !screenshotData.isEmpty, + "is_screen_recording": isScreenRecording, + "timestamp": timestamp, + "source_app": sourceApp + ] + 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 let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + if #available(iOS 15.0, *) { + activeWindow = windowScene.keyWindow + } else { + activeWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + } + } + + 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() + } + if isBlurOverlayModeEnabled { + disableBlurScreen() + } + if isColorOverlayModeEnabled { + disableColorScreen() + } + 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 + } +} + +#if SWIFT_PACKAGE +// When building with Swift Package Manager, expose the plugin class name +// that matches pluginClass in pubspec.yaml for Flutter's registration. +public typealias NoScreenshotPlugin = IOSNoScreenshotPlugin +#endif diff --git a/no_screenshot/ios/Resources/PrivacyInfo.xcprivacy b/no_screenshot/ios/no_screenshot/Sources/no_screenshot/PrivacyInfo.xcprivacy similarity index 100% rename from no_screenshot/ios/Resources/PrivacyInfo.xcprivacy rename to no_screenshot/ios/no_screenshot/Sources/no_screenshot/PrivacyInfo.xcprivacy diff --git a/no_screenshot/lib/constants.dart b/no_screenshot/lib/constants.dart index f378f61..32842be 100644 --- a/no_screenshot/lib/constants.dart +++ b/no_screenshot/lib/constants.dart @@ -1,6 +1,11 @@ const screenShotOnConst = "screenshotOn"; const screenShotOffConst = "screenshotOff"; const screenSetImage = "toggleScreenshotWithImage"; +const screenSetBlur = "toggleScreenshotWithBlur"; +const screenSetColor = "toggleScreenshotWithColor"; +const screenEnableImage = "screenshotWithImage"; +const screenEnableBlur = "screenshotWithBlur"; +const screenEnableColor = "screenshotWithColor"; const toggleScreenShotConst = "toggleScreenshot"; const startScreenshotListeningConst = 'startScreenshotListening'; const stopScreenshotListeningConst = 'stopScreenshotListening'; diff --git a/no_screenshot/lib/no_screenshot.dart b/no_screenshot/lib/no_screenshot.dart index 00ccc64..b52ea17 100644 --- a/no_screenshot/lib/no_screenshot.dart +++ b/no_screenshot/lib/no_screenshot.dart @@ -1,18 +1,81 @@ +import 'dart:async'; + import 'package:no_screenshot/screenshot_snapshot.dart'; import 'no_screenshot_platform_interface.dart'; +/// Callback type for screenshot and recording events. +typedef ScreenshotEventCallback = void Function(ScreenshotSnapshot snapshot); + /// A class that provides a platform-agnostic way to disable screenshots. /// class NoScreenshot implements NoScreenshotPlatform { - final _instancePlatform = NoScreenshotPlatform.instance; + NoScreenshotPlatform get _instancePlatform => NoScreenshotPlatform.instance; NoScreenshot._(); @Deprecated( - "Using this may cause issue\nUse instance directly\ne.g: 'NoScreenshot.instance.screenshotOff()'") + "Using this may cause issue\nUse instance directly\ne.g: 'NoScreenshot.instance.screenshotOff()'", + ) NoScreenshot(); - static NoScreenshot get instance => NoScreenshot._(); + static final NoScreenshot instance = NoScreenshot._(); + + // ── Granular Callbacks (P15) ──────────────────────────────────────── + + /// Called when a screenshot is detected. + ScreenshotEventCallback? onScreenshotDetected; + + /// Called when screen recording starts. + ScreenshotEventCallback? onScreenRecordingStarted; + + /// Called when screen recording stops. + ScreenshotEventCallback? onScreenRecordingStopped; + + StreamSubscription? _callbackSubscription; + bool _wasRecording = false; + + /// Starts dispatching events to [onScreenshotDetected], + /// [onScreenRecordingStarted], and [onScreenRecordingStopped]. + /// + /// Listens to [screenshotStream] internally. Call [stopCallbacks] or + /// [removeAllCallbacks] to cancel. + void startCallbacks() { + if (_callbackSubscription != null) return; + _callbackSubscription = screenshotStream.listen(_dispatchCallbacks); + } + + /// Stops dispatching events but keeps callback assignments. + void stopCallbacks() { + _callbackSubscription?.cancel(); + _callbackSubscription = null; + } + + /// Stops dispatching and clears all callback assignments. + void removeAllCallbacks() { + stopCallbacks(); + onScreenshotDetected = null; + onScreenRecordingStarted = null; + onScreenRecordingStopped = null; + _wasRecording = false; + } + + /// Whether callbacks are currently being dispatched. + bool get hasActiveCallbacks => _callbackSubscription != null; + + void _dispatchCallbacks(ScreenshotSnapshot snapshot) { + if (snapshot.wasScreenshotTaken) { + onScreenshotDetected?.call(snapshot); + } + if (!_wasRecording && snapshot.isScreenRecording) { + onScreenRecordingStarted?.call(snapshot); + } + if (_wasRecording && !snapshot.isScreenRecording) { + onScreenRecordingStopped?.call(snapshot); + } + _wasRecording = snapshot.isScreenRecording; + } + + // ── Platform delegation ───────────────────────────────────────────── /// Return `true` if screenshot capabilities has been /// successfully disabled or is currently disabled and `false` otherwise. @@ -37,6 +100,34 @@ class NoScreenshot implements NoScreenshotPlatform { return _instancePlatform.toggleScreenshotWithImage(); } + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) { + return _instancePlatform.toggleScreenshotWithBlur(blurRadius: blurRadius); + } + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) { + return _instancePlatform.toggleScreenshotWithColor(color: color); + } + + /// Always enables image overlay mode (idempotent — safe to call repeatedly). + @override + Future screenshotWithImage() { + return _instancePlatform.screenshotWithImage(); + } + + /// Always enables blur overlay mode (idempotent — safe to call repeatedly). + @override + Future screenshotWithBlur({double blurRadius = 30.0}) { + return _instancePlatform.screenshotWithBlur(blurRadius: blurRadius); + } + + /// Always enables color overlay mode (idempotent — safe to call repeatedly). + @override + Future screenshotWithColor({int color = 0xFF000000}) { + return _instancePlatform.screenshotWithColor(color: color); + } + /// Return `true` if screenshot capabilities has been /// successfully toggle from it previous state and `false` if the attempt /// to toggle failed. diff --git a/no_screenshot/lib/no_screenshot_method_channel.dart b/no_screenshot/lib/no_screenshot_method_channel.dart index 94615ed..cd7f59a 100644 --- a/no_screenshot/lib/no_screenshot_method_channel.dart +++ b/no_screenshot/lib/no_screenshot_method_channel.dart @@ -15,16 +15,22 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform { @visibleForTesting final eventChannel = const EventChannel(screenshotEventChannel); + Stream? _cachedStream; + @override Stream get screenshotStream { - return eventChannel.receiveBroadcastStream().map((event) => - ScreenshotSnapshot.fromMap(jsonDecode(event) as Map)); + _cachedStream ??= eventChannel.receiveBroadcastStream().map( + (event) => + ScreenshotSnapshot.fromMap(jsonDecode(event) as Map), + ); + return _cachedStream!; } @override Future toggleScreenshot() async { - final result = - await methodChannel.invokeMethod(toggleScreenShotConst); + final result = await methodChannel.invokeMethod( + toggleScreenShotConst, + ); return result ?? false; } @@ -46,6 +52,44 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform { return result ?? false; } + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async { + final result = await methodChannel.invokeMethod(screenSetBlur, { + 'radius': blurRadius, + }); + return result ?? false; + } + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async { + final result = await methodChannel.invokeMethod(screenSetColor, { + 'color': color, + }); + return result ?? false; + } + + @override + Future screenshotWithImage() async { + final result = await methodChannel.invokeMethod(screenEnableImage); + return result ?? false; + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + final result = await methodChannel.invokeMethod(screenEnableBlur, { + 'radius': blurRadius, + }); + return result ?? false; + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + final result = await methodChannel.invokeMethod(screenEnableColor, { + 'color': color, + }); + return result ?? false; + } + @override Future startScreenshotListening() { return methodChannel.invokeMethod(startScreenshotListeningConst); diff --git a/no_screenshot/lib/no_screenshot_platform_interface.dart b/no_screenshot/lib/no_screenshot_platform_interface.dart index 6808efa..05d9876 100644 --- a/no_screenshot/lib/no_screenshot_platform_interface.dart +++ b/no_screenshot/lib/no_screenshot_platform_interface.dart @@ -43,7 +43,35 @@ abstract class NoScreenshotPlatform extends PlatformInterface { /// throw `UnmimplementedError` if not implement Future toggleScreenshotWithImage() { throw UnimplementedError( - 'toggleScreenshotWithImage() has not been implemented.'); + 'toggleScreenshotWithImage() has not been implemented.', + ); + } + + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) { + throw UnimplementedError( + 'toggleScreenshotWithBlur() has not been implemented.', + ); + } + + Future toggleScreenshotWithColor({int color = 0xFF000000}) { + throw UnimplementedError( + 'toggleScreenshotWithColor() has not been implemented.', + ); + } + + /// Always enables image overlay mode (idempotent — safe to call repeatedly). + Future screenshotWithImage() { + throw UnimplementedError('screenshotWithImage() has not been implemented.'); + } + + /// Always enables blur overlay mode (idempotent — safe to call repeatedly). + Future screenshotWithBlur({double blurRadius = 30.0}) { + throw UnimplementedError('screenshotWithBlur() has not been implemented.'); + } + + /// Always enables color overlay mode (idempotent — safe to call repeatedly). + Future screenshotWithColor({int color = 0xFF000000}) { + throw UnimplementedError('screenshotWithColor() has not been implemented.'); } /// Return `true` if screenshot capabilities has been @@ -62,27 +90,31 @@ abstract class NoScreenshotPlatform extends PlatformInterface { throw UnimplementedError('incrementStream has not been implemented.'); } -// Start listening to screenshot activities + // Start listening to screenshot activities Future startScreenshotListening() { throw UnimplementedError( - 'startScreenshotListening has not been implemented.'); + 'startScreenshotListening has not been implemented.', + ); } /// Stop listening to screenshot activities Future stopScreenshotListening() { throw UnimplementedError( - 'stopScreenshotListening has not been implemented.'); + 'stopScreenshotListening has not been implemented.', + ); } /// Start listening to screen recording activities Future startScreenRecordingListening() { throw UnimplementedError( - 'startScreenRecordingListening has not been implemented.'); + 'startScreenRecordingListening has not been implemented.', + ); } /// Stop listening to screen recording activities Future stopScreenRecordingListening() { throw UnimplementedError( - 'stopScreenRecordingListening has not been implemented.'); + 'stopScreenRecordingListening has not been implemented.', + ); } } diff --git a/no_screenshot/lib/no_screenshot_web.dart b/no_screenshot/lib/no_screenshot_web.dart new file mode 100644 index 0000000..dd970fc --- /dev/null +++ b/no_screenshot/lib/no_screenshot_web.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:no_screenshot/no_screenshot_platform_interface.dart'; +import 'package:no_screenshot/screenshot_snapshot.dart'; +import 'package:web/web.dart' as web; + +/// Web implementation of [NoScreenshotPlatform]. +/// +/// Browsers cannot truly prevent OS-level screenshots. This provides +/// best-effort JS deterrents: right-click blocking, PrintScreen +/// interception, `user-select: none`, and `visibilitychange` detection. +class NoScreenshotWeb extends NoScreenshotPlatform { + NoScreenshotWeb._(); + + /// Creates an instance for testing without going through [registerWith]. + factory NoScreenshotWeb.createForTest() => NoScreenshotWeb._(); + + static void registerWith(Registrar registrar) { + NoScreenshotPlatform.instance = NoScreenshotWeb._(); + } + + bool _isProtectionOn = false; + bool _isListening = false; + + final StreamController _controller = + StreamController.broadcast(); + + // ── JS event listeners (stored for removal) ──────────────────────── + + JSFunction? _contextMenuHandler; + JSFunction? _keyDownHandler; + JSFunction? _visibilityHandler; + + // ── Stream ───────────────────────────────────────────────────────── + + @override + Stream get screenshotStream => _controller.stream; + + // ── Protection ───────────────────────────────────────────────────── + + @override + Future screenshotOff() async { + _enableProtection(); + return true; + } + + @override + Future screenshotOn() async { + _disableProtection(); + return true; + } + + @override + Future toggleScreenshot() async { + _isProtectionOn ? _disableProtection() : _enableProtection(); + return true; + } + + @override + Future toggleScreenshotWithImage() async { + _isProtectionOn ? _disableProtection() : _enableProtection(); + return _isProtectionOn; + } + + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async { + _isProtectionOn ? _disableProtection() : _enableProtection(); + return _isProtectionOn; + } + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async { + _isProtectionOn ? _disableProtection() : _enableProtection(); + return _isProtectionOn; + } + + @override + Future screenshotWithImage() async { + _enableProtection(); + return true; + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + _enableProtection(); + return true; + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + _enableProtection(); + return true; + } + + // ── Screenshot Listening ─────────────────────────────────────────── + + @override + Future startScreenshotListening() async { + if (_isListening) return; + _isListening = true; + _addVisibilityListener(); + } + + @override + Future stopScreenshotListening() async { + _isListening = false; + _removeVisibilityListener(); + } + + // ── Recording Listening (no-op on web) ───────────────────────────── + + @override + Future startScreenRecordingListening() async {} + + @override + Future stopScreenRecordingListening() async {} + + // ── Internal ─────────────────────────────────────────────────────── + + void _enableProtection() { + if (_isProtectionOn) return; + _isProtectionOn = true; + _addContextMenuBlocker(); + _addPrintScreenBlocker(); + _setUserSelectNone(true); + _emitState(); + } + + void _disableProtection() { + if (!_isProtectionOn) return; + _isProtectionOn = false; + _removeContextMenuBlocker(); + _removePrintScreenBlocker(); + _setUserSelectNone(false); + _emitState(); + } + + void _emitState({bool wasScreenshotTaken = false}) { + _controller.add( + ScreenshotSnapshot( + screenshotPath: '', + isScreenshotProtectionOn: _isProtectionOn, + wasScreenshotTaken: wasScreenshotTaken, + ), + ); + } + + // ── Context menu blocker ─────────────────────────────────────────── + + void _addContextMenuBlocker() { + _contextMenuHandler = ((web.Event e) { + e.preventDefault(); + }).toJS; + web.document.addEventListener('contextmenu', _contextMenuHandler!); + } + + void _removeContextMenuBlocker() { + if (_contextMenuHandler != null) { + web.document.removeEventListener('contextmenu', _contextMenuHandler!); + _contextMenuHandler = null; + } + } + + // ── PrintScreen blocker ──────────────────────────────────────────── + + void _addPrintScreenBlocker() { + _keyDownHandler = ((web.KeyboardEvent e) { + if (e.key == 'PrintScreen') { + e.preventDefault(); + } + }).toJS; + web.document.addEventListener('keydown', _keyDownHandler!); + } + + void _removePrintScreenBlocker() { + if (_keyDownHandler != null) { + web.document.removeEventListener('keydown', _keyDownHandler!); + _keyDownHandler = null; + } + } + + // ── user-select CSS ──────────────────────────────────────────────── + + void _setUserSelectNone(bool disable) { + final style = web.document.body?.style; + if (style == null) return; + style.setProperty('user-select', disable ? 'none' : ''); + style.setProperty('-webkit-user-select', disable ? 'none' : ''); + } + + // ── Visibility listener ──────────────────────────────────────────── + + void _addVisibilityListener() { + _visibilityHandler = ((web.Event _) { + if (web.document.visibilityState == 'visible') { + _emitState(wasScreenshotTaken: true); + } + }).toJS; + web.document.addEventListener('visibilitychange', _visibilityHandler!); + } + + void _removeVisibilityListener() { + if (_visibilityHandler != null) { + web.document.removeEventListener('visibilitychange', _visibilityHandler!); + _visibilityHandler = null; + } + } +} diff --git a/no_screenshot/lib/overlay_mode.dart b/no_screenshot/lib/overlay_mode.dart new file mode 100644 index 0000000..00c4af6 --- /dev/null +++ b/no_screenshot/lib/overlay_mode.dart @@ -0,0 +1,31 @@ +import 'package:no_screenshot/no_screenshot.dart'; + +/// The protection mode to apply. +enum OverlayMode { none, secure, blur, color, image } + +/// Applies the given [mode] using the idempotent NoScreenshot API. +/// +/// - [none] re-enables screenshots (no protection). +/// - [secure] blocks screenshots and screen recording. +/// - [blur] shows a blur overlay in the app switcher. +/// - [color] shows a solid color overlay in the app switcher. +/// - [image] shows a custom image overlay in the app switcher. +Future applyOverlayMode( + OverlayMode mode, { + double blurRadius = 30.0, + int color = 0xFF000000, +}) async { + final noScreenshot = NoScreenshot.instance; + switch (mode) { + case OverlayMode.none: + await noScreenshot.screenshotOn(); + case OverlayMode.secure: + await noScreenshot.screenshotOff(); + case OverlayMode.blur: + await noScreenshot.screenshotWithBlur(blurRadius: blurRadius); + case OverlayMode.color: + await noScreenshot.screenshotWithColor(color: color); + case OverlayMode.image: + await noScreenshot.screenshotWithImage(); + } +} diff --git a/no_screenshot/lib/screenshot_snapshot.dart b/no_screenshot/lib/screenshot_snapshot.dart index fa62c21..04efeaa 100644 --- a/no_screenshot/lib/screenshot_snapshot.dart +++ b/no_screenshot/lib/screenshot_snapshot.dart @@ -12,11 +12,23 @@ class ScreenshotSnapshot { final bool wasScreenshotTaken; final bool isScreenRecording; + /// Milliseconds since epoch when the event was detected. + /// + /// `0` means unknown (e.g. the native platform did not provide timing data). + final int timestamp; + + /// Human-readable name of the application that triggered the event. + /// + /// Empty string means unknown or not applicable. + final String sourceApp; + ScreenshotSnapshot({ required this.screenshotPath, required this.isScreenshotProtectionOn, required this.wasScreenshotTaken, this.isScreenRecording = false, + this.timestamp = 0, + this.sourceApp = '', }); factory ScreenshotSnapshot.fromMap(Map map) { @@ -25,6 +37,8 @@ class ScreenshotSnapshot { isScreenshotProtectionOn: map['is_screenshot_on'] as bool? ?? false, wasScreenshotTaken: map['was_screenshot_taken'] as bool? ?? false, isScreenRecording: map['is_screen_recording'] as bool? ?? false, + timestamp: map['timestamp'] as int? ?? 0, + sourceApp: map['source_app'] as String? ?? '', ); } @@ -34,12 +48,14 @@ class ScreenshotSnapshot { 'is_screenshot_on': isScreenshotProtectionOn, 'was_screenshot_taken': wasScreenshotTaken, 'is_screen_recording': isScreenRecording, + 'timestamp': timestamp, + 'source_app': sourceApp, }; } @override String toString() { - return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken, \nisScreenRecording: $isScreenRecording\n)'; + return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken, \nisScreenRecording: $isScreenRecording, \ntimestamp: $timestamp, \nsourceApp: $sourceApp\n)'; } @override @@ -50,7 +66,9 @@ class ScreenshotSnapshot { other.screenshotPath == screenshotPath && other.isScreenshotProtectionOn == isScreenshotProtectionOn && other.wasScreenshotTaken == wasScreenshotTaken && - other.isScreenRecording == isScreenRecording; + other.isScreenRecording == isScreenRecording && + other.timestamp == timestamp && + other.sourceApp == sourceApp; } @override @@ -58,6 +76,8 @@ class ScreenshotSnapshot { return screenshotPath.hashCode ^ isScreenshotProtectionOn.hashCode ^ wasScreenshotTaken.hashCode ^ - isScreenRecording.hashCode; + isScreenRecording.hashCode ^ + timestamp.hashCode ^ + sourceApp.hashCode; } } diff --git a/no_screenshot/lib/secure_navigator_observer.dart b/no_screenshot/lib/secure_navigator_observer.dart new file mode 100644 index 0000000..abfa2b5 --- /dev/null +++ b/no_screenshot/lib/secure_navigator_observer.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:no_screenshot/overlay_mode.dart'; + +/// Configuration for a single route's protection policy. +class SecureRouteConfig { + const SecureRouteConfig({ + this.mode = OverlayMode.secure, + this.blurRadius = 30.0, + this.color = 0xFF000000, + }); + + final OverlayMode mode; + final double blurRadius; + final int color; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SecureRouteConfig && + runtimeType == other.runtimeType && + mode == other.mode && + blurRadius == other.blurRadius && + color == other.color; + + @override + int get hashCode => Object.hash(mode, blurRadius, color); +} + +/// A [NavigatorObserver] that applies different protection levels per route. +/// +/// ```dart +/// MaterialApp( +/// navigatorObservers: [ +/// SecureNavigatorObserver( +/// policies: { +/// '/payment': SecureRouteConfig(mode: OverlayMode.secure), +/// '/profile': SecureRouteConfig(mode: OverlayMode.blur, blurRadius: 50), +/// '/home': SecureRouteConfig(mode: OverlayMode.none), +/// }, +/// ), +/// ], +/// ) +/// ``` +class SecureNavigatorObserver extends NavigatorObserver { + SecureNavigatorObserver({ + this.policies = const {}, + this.defaultConfig = const SecureRouteConfig(mode: OverlayMode.none), + }); + + final Map policies; + final SecureRouteConfig defaultConfig; + + @override + void didPush(Route route, Route? previousRoute) { + _applyPolicyForRoute(route); + } + + @override + void didPop(Route route, Route? previousRoute) { + _applyPolicyForRoute(previousRoute); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _applyPolicyForRoute(newRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _applyPolicyForRoute(previousRoute); + } + + void _applyPolicyForRoute(Route? route) { + final name = route?.settings.name; + final config = (name != null ? policies[name] : null) ?? defaultConfig; + applyOverlayMode( + config.mode, + blurRadius: config.blurRadius, + color: config.color, + ); + } +} diff --git a/no_screenshot/lib/secure_widget.dart b/no_screenshot/lib/secure_widget.dart new file mode 100644 index 0000000..424c656 --- /dev/null +++ b/no_screenshot/lib/secure_widget.dart @@ -0,0 +1,68 @@ +import 'package:flutter/widgets.dart'; +import 'package:no_screenshot/no_screenshot.dart'; +import 'package:no_screenshot/overlay_mode.dart'; + +/// A widget that automatically enables screenshot protection when mounted +/// and disables it when unmounted. +/// +/// Wrap any subtree with [SecureWidget] to declaratively protect it: +/// +/// ```dart +/// SecureWidget( +/// mode: OverlayMode.blur, +/// blurRadius: 50.0, +/// child: MySecurePage(), +/// ) +/// ``` +class SecureWidget extends StatefulWidget { + const SecureWidget({ + super.key, + required this.child, + this.mode = OverlayMode.secure, + this.blurRadius = 30.0, + this.color = 0xFF000000, + }); + + final Widget child; + final OverlayMode mode; + final double blurRadius; + final int color; + + @override + State createState() => _SecureWidgetState(); +} + +class _SecureWidgetState extends State { + @override + void initState() { + super.initState(); + _applyMode(); + } + + @override + void didUpdateWidget(SecureWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mode != widget.mode || + oldWidget.blurRadius != widget.blurRadius || + oldWidget.color != widget.color) { + _applyMode(); + } + } + + @override + void dispose() { + NoScreenshot.instance.screenshotOn(); + super.dispose(); + } + + void _applyMode() { + applyOverlayMode( + widget.mode, + blurRadius: widget.blurRadius, + color: widget.color, + ); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/no_screenshot/pubspec.yaml b/no_screenshot/pubspec.yaml index 622334c..ef9a42a 100644 --- a/no_screenshot/pubspec.yaml +++ b/no_screenshot/pubspec.yaml @@ -1,17 +1,27 @@ name: no_screenshot -description: Flutter plugin to enable, disable, toggle or stream screenshot and screen recording activities in your application. -version: 0.4.0 +description: Flutter plugin to prevent screenshots, detect screen recording, and show blur/color/image overlays in the app switcher on Android, iOS, macOS, Linux, Windows, and Web. +version: 1.0.0 homepage: https://flutterplaza.com -repository: https://github.com/FlutterPlaza/no_screenshot/releases/tag/v0.4.0 +repository: https://github.com/FlutterPlaza/no_screenshot + +topics: + - screenshot + - security + - privacy + - screen-capture + - app-switcher environment: - sdk: '>=3.0.0 <4.0.0' - flutter: ">=1.17.0" + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.38.0" dependencies: flutter: sdk: flutter + flutter_web_plugins: + sdk: flutter plugin_platform_interface: ^2.1.8 + web: ^1.1.0 dev_dependencies: flutter_test: @@ -32,3 +42,8 @@ flutter: pluginClass: MacOSNoScreenshotPlugin linux: pluginClass: NoScreenshotPlugin + windows: + pluginClass: NoScreenshotPluginCApi + web: + pluginClass: NoScreenshotWeb + fileName: no_screenshot_web.dart diff --git a/no_screenshot/test/no_screenshot_method_channel_test.dart b/no_screenshot/test/no_screenshot_method_channel_test.dart index cdf1991..d383c30 100644 --- a/no_screenshot/test/no_screenshot_method_channel_test.dart +++ b/no_screenshot/test/no_screenshot_method_channel_test.dart @@ -1,7 +1,12 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:no_screenshot/constants.dart'; +import 'package:no_screenshot/no_screenshot.dart'; import 'package:no_screenshot/no_screenshot_method_channel.dart'; +import 'package:no_screenshot/no_screenshot_platform_interface.dart'; import 'package:no_screenshot/screenshot_snapshot.dart'; void main() { @@ -20,11 +25,11 @@ void main() { const bool expected = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == screenShotOnConst) { - return expected; - } - return null; - }); + if (methodCall.method == screenShotOnConst) { + return expected; + } + return null; + }); final result = await platform.screenshotOn(); expect(result, expected); @@ -34,11 +39,11 @@ void main() { const bool expected = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == screenShotOffConst) { - return expected; - } - return null; - }); + if (methodCall.method == screenShotOffConst) { + return expected; + } + return null; + }); final result = await platform.screenshotOff(); expect(result, expected); @@ -48,11 +53,11 @@ void main() { const bool expected = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == toggleScreenShotConst) { - return expected; - } - return null; - }); + if (methodCall.method == toggleScreenShotConst) { + return expected; + } + return null; + }); final result = await platform.toggleScreenshot(); expect(result, expected); @@ -61,11 +66,11 @@ void main() { test('startScreenshotListening', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == startScreenshotListeningConst) { - return null; - } - return null; - }); + if (methodCall.method == startScreenshotListeningConst) { + return null; + } + return null; + }); await platform.startScreenshotListening(); expect(true, true); // Add more specific expectations if needed @@ -74,11 +79,11 @@ void main() { test('stopScreenshotListening', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == stopScreenshotListeningConst) { - return null; - } - return null; - }); + if (methodCall.method == stopScreenshotListeningConst) { + return null; + } + return null; + }); await platform.stopScreenshotListening(); expect(true, true); // Add more specific expectations if needed @@ -88,32 +93,122 @@ void main() { const bool expected = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == screenSetImage) { - return expected; - } - return null; - }); + if (methodCall.method == screenSetImage) { + return expected; + } + return null; + }); final result = await platform.toggleScreenshotWithImage(); expect(result, expected); }); - test('toggleScreenshotWithImage returns false when channel returns null', - () async { + test('toggleScreenshotWithBlur', () async { + const bool expected = true; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return null; - }); + if (methodCall.method == screenSetBlur) { + expect(methodCall.arguments, {'radius': 30.0}); + return expected; + } + return null; + }); - final result = await platform.toggleScreenshotWithImage(); - expect(result, false); + final result = await platform.toggleScreenshotWithBlur(); + expect(result, expected); }); + test('toggleScreenshotWithBlur with custom radius', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenSetBlur) { + expect(methodCall.arguments, {'radius': 50.0}); + return expected; + } + return null; + }); + + final result = await platform.toggleScreenshotWithBlur(blurRadius: 50.0); + expect(result, expected); + }); + + test( + 'toggleScreenshotWithBlur returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.toggleScreenshotWithBlur(); + expect(result, false); + }, + ); + + test('toggleScreenshotWithColor', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenSetColor) { + expect(methodCall.arguments, {'color': 0xFF000000}); + return expected; + } + return null; + }); + + final result = await platform.toggleScreenshotWithColor(); + expect(result, expected); + }); + + test('toggleScreenshotWithColor with custom color', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenSetColor) { + expect(methodCall.arguments, {'color': 0xFFFF0000}); + return expected; + } + return null; + }); + + final result = await platform.toggleScreenshotWithColor( + color: 0xFFFF0000, + ); + expect(result, expected); + }); + + test( + 'toggleScreenshotWithColor returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.toggleScreenshotWithColor(); + expect(result, false); + }, + ); + + test( + 'toggleScreenshotWithImage returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.toggleScreenshotWithImage(); + expect(result, false); + }, + ); + test('screenshotOn returns false when channel returns null', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return null; - }); + return null; + }); final result = await platform.screenshotOn(); expect(result, false); @@ -122,8 +217,8 @@ void main() { test('screenshotOff returns false when channel returns null', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return null; - }); + return null; + }); final result = await platform.screenshotOff(); expect(result, false); @@ -132,21 +227,134 @@ void main() { test('toggleScreenshot returns false when channel returns null', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return null; - }); + return null; + }); final result = await platform.toggleScreenshot(); expect(result, false); }); + test('screenshotWithImage', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenEnableImage) { + return expected; + } + return null; + }); + + final result = await platform.screenshotWithImage(); + expect(result, expected); + }); + + test( + 'screenshotWithImage returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.screenshotWithImage(); + expect(result, false); + }, + ); + + test('screenshotWithBlur', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenEnableBlur) { + expect(methodCall.arguments, {'radius': 30.0}); + return expected; + } + return null; + }); + + final result = await platform.screenshotWithBlur(); + expect(result, expected); + }); + + test('screenshotWithBlur with custom radius', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenEnableBlur) { + expect(methodCall.arguments, {'radius': 50.0}); + return expected; + } + return null; + }); + + final result = await platform.screenshotWithBlur(blurRadius: 50.0); + expect(result, expected); + }); + + test( + 'screenshotWithBlur returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.screenshotWithBlur(); + expect(result, false); + }, + ); + + test('screenshotWithColor', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenEnableColor) { + expect(methodCall.arguments, {'color': 0xFF000000}); + return expected; + } + return null; + }); + + final result = await platform.screenshotWithColor(); + expect(result, expected); + }); + + test('screenshotWithColor with custom color', () async { + const bool expected = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == screenEnableColor) { + expect(methodCall.arguments, {'color': 0xFFFF0000}); + return expected; + } + return null; + }); + + final result = await platform.screenshotWithColor(color: 0xFFFF0000); + expect(result, expected); + }); + + test( + 'screenshotWithColor returns false when channel returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await platform.screenshotWithColor(); + expect(result, false); + }, + ); + test('startScreenRecordingListening', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == startScreenRecordingListeningConst) { - return null; - } - return null; - }); + if (methodCall.method == startScreenRecordingListeningConst) { + return null; + } + return null; + }); await platform.startScreenRecordingListening(); expect(true, true); @@ -155,15 +363,54 @@ void main() { test('stopScreenRecordingListening', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == stopScreenRecordingListeningConst) { - return null; - } - return null; - }); + if (methodCall.method == stopScreenRecordingListeningConst) { + return null; + } + return null; + }); await platform.stopScreenRecordingListening(); expect(true, true); }); + + test( + 'screenshotStream returns a stream that emits ScreenshotSnapshot', + () async { + final snapshotMap = { + 'screenshot_path': '/test/path', + 'is_screenshot_on': true, + 'was_screenshot_taken': true, + 'is_screen_recording': false, + 'timestamp': 0, + 'source_app': '', + }; + final encoded = jsonEncode(snapshotMap); + + // Mock the event channel by handling the underlying method channel + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + platform.eventChannel, + MockStreamHandler.inline( + onListen: (arguments, events) { + events.success(encoded); + }, + ), + ); + + final stream = platform.screenshotStream; + final snapshot = await stream.first; + + expect(snapshot.screenshotPath, '/test/path'); + expect(snapshot.isScreenshotProtectionOn, true); + expect(snapshot.wasScreenshotTaken, true); + }, + ); + + test('screenshotStream caches and returns the same stream instance', () { + final stream1 = platform.screenshotStream; + final stream2 = platform.screenshotStream; + expect(identical(stream1, stream2), true); + }); }); group('ScreenshotSnapshot', () { @@ -283,6 +530,8 @@ void main() { expect(snapshot.isScreenshotProtectionOn, false); expect(snapshot.wasScreenshotTaken, false); expect(snapshot.isScreenRecording, false); + expect(snapshot.timestamp, 0); + expect(snapshot.sourceApp, ''); }); test('fromMap with null values uses defaults', () { @@ -291,12 +540,92 @@ void main() { 'is_screenshot_on': null, 'was_screenshot_taken': null, 'is_screen_recording': null, + 'timestamp': null, + 'source_app': null, }; final snapshot = ScreenshotSnapshot.fromMap(map); expect(snapshot.screenshotPath, ''); expect(snapshot.isScreenshotProtectionOn, false); expect(snapshot.wasScreenshotTaken, false); expect(snapshot.isScreenRecording, false); + expect(snapshot.timestamp, 0); + expect(snapshot.sourceApp, ''); + }); + + test('fromMap with metadata', () { + final map = { + 'screenshot_path': '/example/path', + 'is_screenshot_on': true, + 'was_screenshot_taken': true, + 'is_screen_recording': false, + 'timestamp': 1700000000000, + 'source_app': 'screencaptureui', + }; + final snapshot = ScreenshotSnapshot.fromMap(map); + expect(snapshot.screenshotPath, '/example/path'); + expect(snapshot.isScreenshotProtectionOn, true); + expect(snapshot.wasScreenshotTaken, true); + expect(snapshot.timestamp, 1700000000000); + expect(snapshot.sourceApp, 'screencaptureui'); + }); + + test('fromMap without metadata defaults timestamp and sourceApp', () { + final map = { + 'screenshot_path': '/example/path', + 'is_screenshot_on': true, + 'was_screenshot_taken': true, + }; + final snapshot = ScreenshotSnapshot.fromMap(map); + expect(snapshot.timestamp, 0); + expect(snapshot.sourceApp, ''); + }); + + test('toMap includes metadata', () { + final snapshot = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000000, + sourceApp: 'GNOME Screenshot', + ); + final map = snapshot.toMap(); + expect(map['timestamp'], 1700000000000); + expect(map['source_app'], 'GNOME Screenshot'); + }); + + test('equality with metadata', () { + final snapshot1 = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000000, + sourceApp: 'screencaptureui', + ); + final snapshot2 = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000000, + sourceApp: 'screencaptureui', + ); + final snapshot3 = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000001, + sourceApp: 'screencaptureui', + ); + final snapshot4 = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000000, + sourceApp: 'different_app', + ); + + expect(snapshot1 == snapshot2, true); + expect(snapshot1 == snapshot3, false); + expect(snapshot1 == snapshot4, false); }); test('toString', () { @@ -306,8 +635,10 @@ void main() { wasScreenshotTaken: true, ); final string = snapshot.toString(); - expect(string, - 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false\n)'); + expect( + string, + 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false, \ntimestamp: 0, \nsourceApp: \n)', + ); }); test('toString with isScreenRecording true', () { @@ -318,8 +649,231 @@ void main() { isScreenRecording: true, ); final string = snapshot.toString(); - expect(string, - 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true\n)'); + expect( + string, + 'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true, \ntimestamp: 0, \nsourceApp: \n)', + ); + }); + + test('toString with metadata', () { + final snapshot = ScreenshotSnapshot( + screenshotPath: '/example/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + timestamp: 1700000000000, + sourceApp: 'screencaptureui', + ); + final string = snapshot.toString(); + expect(string, contains('timestamp: 1700000000000')); + expect(string, contains('sourceApp: screencaptureui')); + }); + }); + + group('Granular Callbacks (P15)', () { + late StreamController controller; + late _MockNoScreenshotPlatform mockPlatform; + late NoScreenshot noScreenshot; + + setUp(() { + controller = StreamController.broadcast(); + mockPlatform = _MockNoScreenshotPlatform(controller.stream); + NoScreenshotPlatform.instance = mockPlatform; + // Create a fresh instance for each test to avoid shared state. + noScreenshot = NoScreenshot.instance; + noScreenshot.removeAllCallbacks(); + }); + + tearDown(() { + noScreenshot.removeAllCallbacks(); + controller.close(); + }); + + test( + 'onScreenshotDetected fires when wasScreenshotTaken is true', + () async { + final detected = []; + noScreenshot.onScreenshotDetected = detected.add; + noScreenshot.startCallbacks(); + + controller.add( + ScreenshotSnapshot( + screenshotPath: '/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(detected, hasLength(1)); + expect(detected.first.wasScreenshotTaken, true); + }, + ); + + test( + 'onScreenshotDetected does NOT fire when wasScreenshotTaken is false', + () async { + final detected = []; + noScreenshot.onScreenshotDetected = detected.add; + noScreenshot.startCallbacks(); + + controller.add( + ScreenshotSnapshot( + screenshotPath: '', + isScreenshotProtectionOn: true, + wasScreenshotTaken: false, + ), + ); + await Future.delayed(Duration.zero); + + expect(detected, isEmpty); + }, + ); + + test('onScreenRecordingStarted fires on false→true transition', () async { + final started = []; + noScreenshot.onScreenRecordingStarted = started.add; + noScreenshot.startCallbacks(); + + // Initial state: not recording → recording starts + controller.add( + ScreenshotSnapshot( + screenshotPath: '', + isScreenshotProtectionOn: true, + wasScreenshotTaken: false, + isScreenRecording: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(started, hasLength(1)); + expect(started.first.isScreenRecording, true); + }); + + test('onScreenRecordingStopped fires on true→false transition', () async { + final stopped = []; + noScreenshot.onScreenRecordingStopped = stopped.add; + noScreenshot.startCallbacks(); + + // First: recording starts + controller.add( + ScreenshotSnapshot( + screenshotPath: '', + isScreenshotProtectionOn: true, + wasScreenshotTaken: false, + isScreenRecording: true, + ), + ); + await Future.delayed(Duration.zero); + + // Then: recording stops + controller.add( + ScreenshotSnapshot( + screenshotPath: '', + isScreenshotProtectionOn: true, + wasScreenshotTaken: false, + isScreenRecording: false, + ), + ); + await Future.delayed(Duration.zero); + + expect(stopped, hasLength(1)); + expect(stopped.first.isScreenRecording, false); + }); + + test( + 'removeAllCallbacks clears all callbacks and stops subscription', + () async { + final detected = []; + noScreenshot.onScreenshotDetected = detected.add; + noScreenshot.startCallbacks(); + expect(noScreenshot.hasActiveCallbacks, true); + + noScreenshot.removeAllCallbacks(); + expect(noScreenshot.hasActiveCallbacks, false); + expect(noScreenshot.onScreenshotDetected, isNull); + expect(noScreenshot.onScreenRecordingStarted, isNull); + expect(noScreenshot.onScreenRecordingStopped, isNull); + + // Events after removal should not fire + controller.add( + ScreenshotSnapshot( + screenshotPath: '/path', + isScreenshotProtectionOn: true, + wasScreenshotTaken: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(detected, isEmpty); + }, + ); + + test('hasActiveCallbacks reflects subscription state', () { + expect(noScreenshot.hasActiveCallbacks, false); + + noScreenshot.onScreenshotDetected = (_) {}; + noScreenshot.startCallbacks(); + expect(noScreenshot.hasActiveCallbacks, true); + + noScreenshot.stopCallbacks(); + expect(noScreenshot.hasActiveCallbacks, false); + }); + + test('startCallbacks is idempotent', () { + noScreenshot.onScreenshotDetected = (_) {}; + noScreenshot.startCallbacks(); + noScreenshot.startCallbacks(); // second call should be no-op + expect(noScreenshot.hasActiveCallbacks, true); }); }); } + +class _MockNoScreenshotPlatform extends NoScreenshotPlatform { + final Stream _stream; + + _MockNoScreenshotPlatform(this._stream); + + @override + Stream get screenshotStream => _stream; + + @override + Future screenshotOff() async => true; + + @override + Future screenshotOn() async => true; + + @override + Future toggleScreenshot() async => true; + + @override + Future toggleScreenshotWithImage() async => true; + + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async => + true; + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async => + true; + + @override + Future screenshotWithImage() async => true; + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async => true; + + @override + Future screenshotWithColor({int color = 0xFF000000}) async => true; + + @override + Future startScreenshotListening() async {} + + @override + Future stopScreenshotListening() async {} + + @override + Future startScreenRecordingListening() async {} + + @override + Future stopScreenRecordingListening() async {} +} diff --git a/no_screenshot/test/no_screenshot_platform_interface_test.dart b/no_screenshot/test/no_screenshot_platform_interface_test.dart index 6a2ea85..a57e0b9 100644 --- a/no_screenshot/test/no_screenshot_platform_interface_test.dart +++ b/no_screenshot/test/no_screenshot_platform_interface_test.dart @@ -38,11 +38,36 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform { return true; } + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async { + return true; + } + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async { + return true; + } + @override Future stopScreenshotListening() async { return; } + @override + Future screenshotWithImage() async { + return true; + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + return true; + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + return true; + } + @override Future startScreenRecordingListening() async { return; @@ -59,8 +84,10 @@ void main() { group('NoScreenshotPlatform', () { test('default instance should be MethodChannelNoScreenshot', () { - expect(NoScreenshotPlatform.instance, - isInstanceOf()); + expect( + NoScreenshotPlatform.instance, + isInstanceOf(), + ); }); test('screenshotOff should return true when called', () async { @@ -75,101 +102,207 @@ void main() { expect(await platform.toggleScreenshot(), isTrue); }); - test('screenshotStream should not throw UnimplementedError when accessed', - () { - expect(() => platform.screenshotStream, isNot(throwsUnimplementedError)); - }); test( - 'startScreenshotListening should not throw UnimplementedError when called', - () async { - expect(platform.startScreenshotListening(), completes); - }); + 'screenshotStream should not throw UnimplementedError when accessed', + () { + expect( + () => platform.screenshotStream, + isNot(throwsUnimplementedError), + ); + }, + ); + test( + 'startScreenshotListening should not throw UnimplementedError when called', + () async { + expect(platform.startScreenshotListening(), completes); + }, + ); test( - 'stopScreenshotListening should not throw UnimplementedError when called', - () async { - expect(platform.stopScreenshotListening(), completes); - }); + 'stopScreenshotListening should not throw UnimplementedError when called', + () async { + expect(platform.stopScreenshotListening(), completes); + }, + ); test('toggleScreenshotWithImage should return true when called', () async { expect(await platform.toggleScreenshotWithImage(), isTrue); }); test( - 'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.toggleScreenshotWithImage(), - throwsUnimplementedError); - }); + 'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.toggleScreenshotWithImage(), + throwsUnimplementedError, + ); + }, + ); - test('base NoScreenshotPlatform.screenshotOff() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.screenshotOff(), throwsUnimplementedError); - }); - - test('base NoScreenshotPlatform.screenshotOn() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.screenshotOn(), throwsUnimplementedError); + test('toggleScreenshotWithBlur should return true when called', () async { + expect(await platform.toggleScreenshotWithBlur(), isTrue); }); test( - 'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError); - }); + 'base NoScreenshotPlatform.toggleScreenshotWithBlur() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.toggleScreenshotWithBlur(), + throwsUnimplementedError, + ); + }, + ); - test('base NoScreenshotPlatform.screenshotStream throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.screenshotStream, throwsUnimplementedError); + test('toggleScreenshotWithColor should return true when called', () async { + expect(await platform.toggleScreenshotWithColor(), isTrue); }); test( - 'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.startScreenshotListening(), - throwsUnimplementedError); + 'base NoScreenshotPlatform.toggleScreenshotWithColor() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.toggleScreenshotWithColor(), + throwsUnimplementedError, + ); + }, + ); + + test('screenshotWithImage should return true when called', () async { + expect(await platform.screenshotWithImage(), isTrue); }); test( - 'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.stopScreenshotListening(), - throwsUnimplementedError); + 'base NoScreenshotPlatform.screenshotWithImage() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.screenshotWithImage(), + throwsUnimplementedError, + ); + }, + ); + + test('screenshotWithBlur should return true when called', () async { + expect(await platform.screenshotWithBlur(), isTrue); }); test( - 'startScreenRecordingListening should not throw UnimplementedError when called', - () async { - expect(platform.startScreenRecordingListening(), completes); + 'base NoScreenshotPlatform.screenshotWithBlur() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.screenshotWithBlur(), + throwsUnimplementedError, + ); + }, + ); + + test('screenshotWithColor should return true when called', () async { + expect(await platform.screenshotWithColor(), isTrue); }); test( - 'stopScreenRecordingListening should not throw UnimplementedError when called', - () async { - expect(platform.stopScreenRecordingListening(), completes); - }); + 'base NoScreenshotPlatform.screenshotWithColor() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.screenshotWithColor(), + throwsUnimplementedError, + ); + }, + ); test( - 'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.startScreenRecordingListening(), - throwsUnimplementedError); - }); + 'base NoScreenshotPlatform.screenshotOff() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotOff(), throwsUnimplementedError); + }, + ); test( - 'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError', - () { - final basePlatform = BaseNoScreenshotPlatform(); - expect(() => basePlatform.stopScreenRecordingListening(), - throwsUnimplementedError); - }); + 'base NoScreenshotPlatform.screenshotOn() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotOn(), throwsUnimplementedError); + }, + ); + + test( + 'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError); + }, + ); + + test( + 'base NoScreenshotPlatform.screenshotStream throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect(() => basePlatform.screenshotStream, throwsUnimplementedError); + }, + ); + + test( + 'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.startScreenshotListening(), + throwsUnimplementedError, + ); + }, + ); + + test( + 'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.stopScreenshotListening(), + throwsUnimplementedError, + ); + }, + ); + + test( + 'startScreenRecordingListening should not throw UnimplementedError when called', + () async { + expect(platform.startScreenRecordingListening(), completes); + }, + ); + + test( + 'stopScreenRecordingListening should not throw UnimplementedError when called', + () async { + expect(platform.stopScreenRecordingListening(), completes); + }, + ); + + test( + 'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.startScreenRecordingListening(), + throwsUnimplementedError, + ); + }, + ); + + test( + 'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError', + () { + final basePlatform = BaseNoScreenshotPlatform(); + expect( + () => basePlatform.stopScreenRecordingListening(), + throwsUnimplementedError, + ); + }, + ); }); } diff --git a/no_screenshot/test/no_screenshot_test.dart b/no_screenshot/test/no_screenshot_test.dart index 650a070..e4f7d58 100644 --- a/no_screenshot/test/no_screenshot_test.dart +++ b/no_screenshot/test/no_screenshot_test.dart @@ -25,6 +25,16 @@ class MockNoScreenshotPlatform return Future.value(true); } + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async { + return Future.value(true); + } + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async { + return Future.value(true); + } + @override Future toggleScreenshot() async { // Mock implementation or return a fixed value @@ -34,6 +44,21 @@ class MockNoScreenshotPlatform @override Stream get screenshotStream => const Stream.empty(); + @override + Future screenshotWithImage() async { + return Future.value(true); + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + return Future.value(true); + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + return Future.value(true); + } + @override Future startScreenshotListening() { return Future.value(); @@ -90,8 +115,10 @@ void main() { }); test('screenshotStream', () async { - expect(NoScreenshot.instance.screenshotStream, - isInstanceOf>()); + expect( + NoScreenshot.instance.screenshotStream, + isInstanceOf>(), + ); }); test('startScreenshotListening', () async { expect(NoScreenshot.instance.startScreenshotListening(), completes); @@ -105,6 +132,54 @@ void main() { expect(await NoScreenshot.instance.toggleScreenshotWithImage(), true); }); + test('toggleScreenshotWithBlur', () async { + expect(await NoScreenshot.instance.toggleScreenshotWithBlur(), true); + }); + + test('toggleScreenshotWithBlur with custom radius', () async { + expect( + await NoScreenshot.instance.toggleScreenshotWithBlur(blurRadius: 50.0), + true, + ); + }); + + test('toggleScreenshotWithColor', () async { + expect(await NoScreenshot.instance.toggleScreenshotWithColor(), true); + }); + + test('toggleScreenshotWithColor with custom color', () async { + expect( + await NoScreenshot.instance.toggleScreenshotWithColor(color: 0xFFFF0000), + true, + ); + }); + + test('screenshotWithImage', () async { + expect(await NoScreenshot.instance.screenshotWithImage(), true); + }); + + test('screenshotWithBlur', () async { + expect(await NoScreenshot.instance.screenshotWithBlur(), true); + }); + + test('screenshotWithBlur with custom radius', () async { + expect( + await NoScreenshot.instance.screenshotWithBlur(blurRadius: 50.0), + true, + ); + }); + + test('screenshotWithColor', () async { + expect(await NoScreenshot.instance.screenshotWithColor(), true); + }); + + test('screenshotWithColor with custom color', () async { + expect( + await NoScreenshot.instance.screenshotWithColor(color: 0xFFFF0000), + true, + ); + }); + test('NoScreenshot equality operator', () { final instance1 = NoScreenshot.instance; final instance2 = NoScreenshot.instance; diff --git a/no_screenshot/test/no_screenshot_web_test.dart b/no_screenshot/test/no_screenshot_web_test.dart new file mode 100644 index 0000000..e4551e5 --- /dev/null +++ b/no_screenshot/test/no_screenshot_web_test.dart @@ -0,0 +1,131 @@ +@TestOn('browser') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_screenshot/no_screenshot_web.dart'; +import 'package:no_screenshot/screenshot_snapshot.dart'; + +void main() { + late NoScreenshotWeb platform; + + setUp(() { + platform = NoScreenshotWeb.createForTest(); + }); + + group('NoScreenshotWeb', () { + test('screenshotOff returns true and emits protection on', () async { + final events = []; + platform.screenshotStream.listen(events.add); + + final result = await platform.screenshotOff(); + + expect(result, true); + await Future.delayed(Duration.zero); + expect(events, isNotEmpty); + expect(events.last.isScreenshotProtectionOn, true); + }); + + test('screenshotOn returns true and emits protection off', () async { + await platform.screenshotOff(); // enable first + + final events = []; + platform.screenshotStream.listen(events.add); + + final result = await platform.screenshotOn(); + + expect(result, true); + await Future.delayed(Duration.zero); + expect(events, isNotEmpty); + expect(events.last.isScreenshotProtectionOn, false); + }); + + test('toggleScreenshot returns true', () async { + final result = await platform.toggleScreenshot(); + expect(result, true); + }); + + test('toggleScreenshotWithImage returns toggle state', () async { + // First toggle → on + var result = await platform.toggleScreenshotWithImage(); + expect(result, true); + + // Second toggle → off + result = await platform.toggleScreenshotWithImage(); + expect(result, false); + }); + + test('toggleScreenshotWithBlur returns toggle state', () async { + var result = await platform.toggleScreenshotWithBlur(); + expect(result, true); + + result = await platform.toggleScreenshotWithBlur(); + expect(result, false); + }); + + test('toggleScreenshotWithColor returns toggle state', () async { + var result = await platform.toggleScreenshotWithColor(); + expect(result, true); + + result = await platform.toggleScreenshotWithColor(); + expect(result, false); + }); + + test('screenshotWithImage returns true', () async { + final result = await platform.screenshotWithImage(); + expect(result, true); + }); + + test('screenshotWithBlur returns true', () async { + final result = await platform.screenshotWithBlur(); + expect(result, true); + }); + + test('screenshotWithColor returns true', () async { + final result = await platform.screenshotWithColor(); + expect(result, true); + }); + + test('startScreenshotListening completes without error', () async { + await expectLater(platform.startScreenshotListening(), completes); + }); + + test('stopScreenshotListening completes without error', () async { + await platform.startScreenshotListening(); + await expectLater(platform.stopScreenshotListening(), completes); + }); + + test('startScreenRecordingListening completes (no-op)', () async { + await expectLater(platform.startScreenRecordingListening(), completes); + }); + + test('stopScreenRecordingListening completes (no-op)', () async { + await expectLater(platform.stopScreenRecordingListening(), completes); + }); + + test('screenshotStream emits on state changes', () async { + final events = []; + platform.screenshotStream.listen(events.add); + + await platform.screenshotOff(); + await Future.delayed(Duration.zero); + + await platform.screenshotOn(); + await Future.delayed(Duration.zero); + + expect(events.length, 2); + expect(events[0].isScreenshotProtectionOn, true); + expect(events[1].isScreenshotProtectionOn, false); + }); + + test('enable is idempotent — does not double-emit', () async { + final events = []; + platform.screenshotStream.listen(events.add); + + await platform.screenshotOff(); + await platform.screenshotOff(); // second call should be no-op + await Future.delayed(Duration.zero); + + expect(events.length, 1); + }); + }); +} diff --git a/no_screenshot/test/secure_navigator_observer_test.dart b/no_screenshot/test/secure_navigator_observer_test.dart new file mode 100644 index 0000000..61862d8 --- /dev/null +++ b/no_screenshot/test/secure_navigator_observer_test.dart @@ -0,0 +1,240 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_screenshot/no_screenshot_platform_interface.dart'; +import 'package:no_screenshot/overlay_mode.dart'; +import 'package:no_screenshot/screenshot_snapshot.dart'; +import 'package:no_screenshot/secure_navigator_observer.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _RecordingPlatform extends NoScreenshotPlatform + with MockPlatformInterfaceMixin { + final List calls = []; + + @override + Future screenshotOff() async { + calls.add('screenshotOff'); + return true; + } + + @override + Future screenshotOn() async { + calls.add('screenshotOn'); + return true; + } + + @override + Future screenshotWithImage() async { + calls.add('screenshotWithImage'); + return true; + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + calls.add('screenshotWithBlur($blurRadius)'); + return true; + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + calls.add('screenshotWithColor($color)'); + return true; + } + + @override + Future toggleScreenshot() async => true; + + @override + Future toggleScreenshotWithImage() async => true; + + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async => + true; + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async => + true; + + @override + Stream get screenshotStream => const Stream.empty(); + + @override + Future startScreenshotListening() async {} + + @override + Future stopScreenshotListening() async {} + + @override + Future startScreenRecordingListening() async {} + + @override + Future stopScreenRecordingListening() async {} +} + +// Helper to create a fake route with a given name +Route _fakeRoute(String? name) { + return PageRouteBuilder( + settings: RouteSettings(name: name), + pageBuilder: (_, __, ___) => const SizedBox(), + ); +} + +void main() { + late _RecordingPlatform fakePlatform; + late NoScreenshotPlatform originalPlatform; + + setUp(() { + originalPlatform = NoScreenshotPlatform.instance; + fakePlatform = _RecordingPlatform(); + NoScreenshotPlatform.instance = fakePlatform; + }); + + tearDown(() { + NoScreenshotPlatform.instance = originalPlatform; + }); + + group('SecureRouteConfig', () { + test('equality', () { + const a = SecureRouteConfig(mode: OverlayMode.blur, blurRadius: 50.0); + const b = SecureRouteConfig(mode: OverlayMode.blur, blurRadius: 50.0); + const c = SecureRouteConfig(mode: OverlayMode.secure); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('hashCode', () { + const a = SecureRouteConfig(mode: OverlayMode.blur, blurRadius: 50.0); + const b = SecureRouteConfig(mode: OverlayMode.blur, blurRadius: 50.0); + + expect(a.hashCode, equals(b.hashCode)); + }); + + test('default values', () { + const config = SecureRouteConfig(); + expect(config.mode, OverlayMode.secure); + expect(config.blurRadius, 30.0); + expect(config.color, 0xFF000000); + }); + }); + + group('SecureNavigatorObserver', () { + test('didPush applies policy for pushed route', () async { + final observer = SecureNavigatorObserver( + policies: { + '/payment': const SecureRouteConfig(mode: OverlayMode.secure), + }, + ); + + observer.didPush(_fakeRoute('/payment'), null); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotOff')); + }); + + test('didPop applies policy for previous route', () async { + final observer = SecureNavigatorObserver( + policies: {'/home': const SecureRouteConfig(mode: OverlayMode.none)}, + ); + + observer.didPop(_fakeRoute('/payment'), _fakeRoute('/home')); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + test('didReplace applies policy for new route', () async { + final observer = SecureNavigatorObserver( + policies: { + '/profile': const SecureRouteConfig( + mode: OverlayMode.blur, + blurRadius: 50.0, + ), + }, + ); + + observer.didReplace( + newRoute: _fakeRoute('/profile'), + oldRoute: _fakeRoute('/home'), + ); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotWithBlur(50.0)')); + }); + + test('didRemove applies policy for previous route', () async { + final observer = SecureNavigatorObserver( + policies: {'/home': const SecureRouteConfig(mode: OverlayMode.none)}, + ); + + observer.didRemove(_fakeRoute('/payment'), _fakeRoute('/home')); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + test('unmapped routes use defaultConfig', () async { + final observer = SecureNavigatorObserver( + policies: { + '/payment': const SecureRouteConfig(mode: OverlayMode.secure), + }, + defaultConfig: const SecureRouteConfig(mode: OverlayMode.none), + ); + + observer.didPush(_fakeRoute('/unknown'), null); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + test('custom defaultConfig works', () async { + final observer = SecureNavigatorObserver( + defaultConfig: const SecureRouteConfig(mode: OverlayMode.blur), + ); + + observer.didPush(_fakeRoute('/anything'), null); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotWithBlur(30.0)')); + }); + + test('null route name uses defaultConfig', () async { + final observer = SecureNavigatorObserver( + policies: { + '/payment': const SecureRouteConfig(mode: OverlayMode.secure), + }, + defaultConfig: const SecureRouteConfig(mode: OverlayMode.none), + ); + + observer.didPush(_fakeRoute(null), null); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + test('blur params passed correctly', () async { + final observer = SecureNavigatorObserver( + policies: { + '/settings': const SecureRouteConfig( + mode: OverlayMode.blur, + blurRadius: 75.0, + ), + }, + ); + + observer.didPush(_fakeRoute('/settings'), null); + await Future.delayed(Duration.zero); + expect(fakePlatform.calls, contains('screenshotWithBlur(75.0)')); + }); + + test('color params passed correctly', () async { + final observer = SecureNavigatorObserver( + policies: { + '/branded': const SecureRouteConfig( + mode: OverlayMode.color, + color: 0xFF2196F3, + ), + }, + ); + + observer.didPush(_fakeRoute('/branded'), null); + await Future.delayed(Duration.zero); + expect( + fakePlatform.calls, + contains('screenshotWithColor(${0xFF2196F3})'), + ); + }); + }); +} diff --git a/no_screenshot/test/secure_widget_test.dart b/no_screenshot/test/secure_widget_test.dart new file mode 100644 index 0000000..5dcc6fd --- /dev/null +++ b/no_screenshot/test/secure_widget_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:no_screenshot/no_screenshot_platform_interface.dart'; +import 'package:no_screenshot/overlay_mode.dart'; +import 'package:no_screenshot/screenshot_snapshot.dart'; +import 'package:no_screenshot/secure_widget.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _RecordingPlatform extends NoScreenshotPlatform + with MockPlatformInterfaceMixin { + final List calls = []; + + @override + Future screenshotOff() async { + calls.add('screenshotOff'); + return true; + } + + @override + Future screenshotOn() async { + calls.add('screenshotOn'); + return true; + } + + @override + Future screenshotWithImage() async { + calls.add('screenshotWithImage'); + return true; + } + + @override + Future screenshotWithBlur({double blurRadius = 30.0}) async { + calls.add('screenshotWithBlur($blurRadius)'); + return true; + } + + @override + Future screenshotWithColor({int color = 0xFF000000}) async { + calls.add('screenshotWithColor($color)'); + return true; + } + + @override + Future toggleScreenshot() async => true; + + @override + Future toggleScreenshotWithImage() async => true; + + @override + Future toggleScreenshotWithBlur({double blurRadius = 30.0}) async => + true; + + @override + Future toggleScreenshotWithColor({int color = 0xFF000000}) async => + true; + + @override + Stream get screenshotStream => const Stream.empty(); + + @override + Future startScreenshotListening() async {} + + @override + Future stopScreenshotListening() async {} + + @override + Future startScreenRecordingListening() async {} + + @override + Future stopScreenRecordingListening() async {} +} + +void main() { + late _RecordingPlatform fakePlatform; + late NoScreenshotPlatform originalPlatform; + + setUp(() { + originalPlatform = NoScreenshotPlatform.instance; + fakePlatform = _RecordingPlatform(); + NoScreenshotPlatform.instance = fakePlatform; + }); + + tearDown(() { + NoScreenshotPlatform.instance = originalPlatform; + }); + + testWidgets('default mode is OverlayMode.secure', (tester) async { + await tester.pumpWidget(const SecureWidget(child: SizedBox())); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotOff')); + }); + + testWidgets('initState calls screenshotOff for OverlayMode.secure', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.secure, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotOff')); + }); + + testWidgets('initState calls screenshotWithBlur for OverlayMode.blur', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.blur, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithBlur(30.0)')); + }); + + testWidgets('initState calls screenshotWithColor for OverlayMode.color', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.color, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithColor(4278190080)')); + }); + + testWidgets('initState calls screenshotWithImage for OverlayMode.image', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.image, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithImage')); + }); + + testWidgets('initState calls screenshotOn for OverlayMode.none', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.none, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + testWidgets('dispose calls screenshotOn', (tester) async { + await tester.pumpWidget(const SecureWidget(child: SizedBox())); + await tester.pump(); + fakePlatform.calls.clear(); + + // Remove the widget to trigger dispose + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotOn')); + }); + + testWidgets('didUpdateWidget re-applies when mode changes', (tester) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.secure, child: SizedBox()), + ); + await tester.pump(); + fakePlatform.calls.clear(); + + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.blur, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithBlur(30.0)')); + }); + + testWidgets('didUpdateWidget re-applies when blurRadius changes', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget( + mode: OverlayMode.blur, + blurRadius: 30.0, + child: SizedBox(), + ), + ); + await tester.pump(); + fakePlatform.calls.clear(); + + await tester.pumpWidget( + const SecureWidget( + mode: OverlayMode.blur, + blurRadius: 50.0, + child: SizedBox(), + ), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithBlur(50.0)')); + }); + + testWidgets('didUpdateWidget re-applies when color changes', (tester) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.color, child: SizedBox()), + ); + await tester.pump(); + fakePlatform.calls.clear(); + + await tester.pumpWidget( + const SecureWidget( + mode: OverlayMode.color, + color: 0xFFFF0000, + child: SizedBox(), + ), + ); + await tester.pump(); + expect(fakePlatform.calls, contains('screenshotWithColor(4294901760)')); + }); + + testWidgets('didUpdateWidget does not re-apply when nothing changes', ( + tester, + ) async { + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.secure, child: SizedBox()), + ); + await tester.pump(); + fakePlatform.calls.clear(); + + // Rebuild with same params + await tester.pumpWidget( + const SecureWidget(mode: OverlayMode.secure, child: SizedBox()), + ); + await tester.pump(); + expect(fakePlatform.calls, isEmpty); + }); + + testWidgets('child is rendered correctly', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: SecureWidget(child: Text('Hello')), + ), + ); + expect(find.text('Hello'), findsOneWidget); + }); +} diff --git a/restart_app/LICENSE b/restart_app/LICENSE index 58e9821..c20190a 100644 --- a/restart_app/LICENSE +++ b/restart_app/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Hossein Yousefpour +Copyright (c) 2021-2026 Soroush Yousefpour Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/restart_app/android/src/main/kotlin/gabrimatic/info/restart/RestartPlugin.kt b/restart_app/android/src/main/kotlin/gabrimatic/info/restart/RestartPlugin.kt index ae5ba31..ce836dd 100644 --- a/restart_app/android/src/main/kotlin/gabrimatic/info/restart/RestartPlugin.kt +++ b/restart_app/android/src/main/kotlin/gabrimatic/info/restart/RestartPlugin.kt @@ -1,9 +1,11 @@ package gabrimatic.info.restart import android.app.Activity -import android.content.Context import android.content.Intent -import androidx.annotation.NonNull +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -20,19 +22,19 @@ import io.flutter.plugin.common.MethodChannel.Result * * The main functionality is provided by the `onMethodCall` method. */ -class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { - private lateinit var context: Context +class RestartPlugin : + FlutterPlugin, + MethodCallHandler, + ActivityAware { private lateinit var channel: MethodChannel private var activity: Activity? = null /** * Called when the plugin is attached to the Flutter engine. * - * It initializes the `context` with the application context and - * sets this plugin instance as the handler for method calls from Flutter. + * Sets this plugin instance as the handler for method calls from Flutter. */ - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "restart") channel.setMethodCallHandler(this) } @@ -41,12 +43,62 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { * Handles method calls from the Flutter code. * * If the method call is 'restartApp', it restarts the app and sends a successful result. + * The result is sent before the restart is triggered so the Flutter engine has time to + * deliver it across the platform channel. Without this delay, finishAffinity() can tear + * down the engine mid-delivery, causing a FlutterJNI detached error. + * + * When forceKill is true, the process is terminated immediately after the new activity + * launches, ensuring a clean cold restart with no stale native resources. A longer delay + * gives the new activity time to initialize before the current process exits. + * * For any other method call, it sends a 'not implemented' result. */ - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + override fun onMethodCall( + call: MethodCall, + result: Result, + ) { if (call.method == "restartApp") { - restartApp() + val forceKill = call.argument("forceKill") ?: false + val currentActivity = activity + + if (currentActivity == null) { + result.error("RESTART_FAILED", "No activity available", null) + return + } + + val pm = currentActivity.packageManager + val pkg = currentActivity.packageName + + // Try the standard launcher intent first, then fall back to the leanback + // launcher used by Android TV and Fire TV devices (API 21+). + var intent = pm.getLaunchIntentForPackage(pkg) + if (intent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + intent = pm.getLeanbackLaunchIntentForPackage(pkg) + } + + if (intent == null) { + result.error("RESTART_FAILED", "No launchable activity found for $pkg", null) + return + } + result.success("ok") + + // Delay the destructive operations so the platform channel result can be delivered + // to the Dart side before the Flutter engine is torn down. + val delay = if (forceKill) 300L else 100L + Handler(Looper.getMainLooper()).postDelayed({ + try { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + currentActivity.startActivity(intent) + if (forceKill) { + Runtime.getRuntime().exit(0) + } else { + currentActivity.finishAffinity() + } + } catch (e: Exception) { + Log.e("RestartPlugin", "Restart failed: ${e.message}", e) + } + }, delay) } else { result.notImplemented() } @@ -57,23 +109,10 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { * * It removes the handler for method calls from Flutter. */ - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } - /** - * Restarts the application. - */ - private fun restartApp() { - activity?.let { currentActivity -> - val intent = - currentActivity.packageManager.getLaunchIntentForPackage(currentActivity.packageName) - intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - currentActivity.startActivity(intent) - currentActivity.finishAffinity() - } - } - override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity } @@ -89,4 +128,4 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { override fun onDetachedFromActivity() { activity = null } -} \ No newline at end of file +} diff --git a/restart_app/ios/Classes/RestartAppPlugin.swift b/restart_app/ios/Classes/RestartAppPlugin.swift deleted file mode 100644 index afa431b..0000000 --- a/restart_app/ios/Classes/RestartAppPlugin.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Flutter -import UIKit - -public class RestartAppPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "restart", binaryMessenger: registrar.messenger()) - let instance = RestartAppPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method == "restartApp" { - DispatchQueue.main.async { - if let appDelegate = UIApplication.shared.delegate as? AppDelegate { - appDelegate.restartFlutterApp() - result("ok") - } else { - result(FlutterError(code: "APP_DELEGATE_NOT_FOUND", message: "Could not find AppDelegate", details: nil)) - } - } - } else { - result(FlutterMethodNotImplemented) - } - } -} \ No newline at end of file diff --git a/restart_app/ios/restart_app.podspec b/restart_app/ios/restart_app.podspec index 756f7d9..58eedf5 100644 --- a/restart_app/ios/restart_app.podspec +++ b/restart_app/ios/restart_app.podspec @@ -4,16 +4,16 @@ # Pod::Spec.new do |s| s.name = 'restart_app' - s.version = '0.0.1' - s.summary = 'A new Flutter project.' + s.version = '1.7.3' + s.summary = 'A Flutter plugin to restart the app using native APIs.' s.description = <<-DESC -A new Flutter project. +A Flutter plugin that helps you to restart the whole Flutter app with a single function call by using native APIs. DESC - s.homepage = 'http://example.com' + s.homepage = 'https://github.com/gabrimatic/restart_app' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'Soroush Yousefpour' => 'https://gabrimatic.info' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'restart_app/Sources/restart_app/**/*' s.dependency 'Flutter' s.platform = :ios, '11.0' diff --git a/restart_app/ios/restart_app/Package.swift b/restart_app/ios/restart_app/Package.swift new file mode 100644 index 0000000..072ea2f --- /dev/null +++ b/restart_app/ios/restart_app/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "restart_app", + platforms: [ + .iOS("12.0") + ], + products: [ + .library(name: "restart-app", targets: ["restart_app"]) + ], + dependencies: [], + targets: [ + .target( + name: "restart_app", + dependencies: [], + path: "Sources/restart_app" + ) + ] +) diff --git a/restart_app/ios/restart_app/Sources/restart_app/RestartAppPlugin.swift b/restart_app/ios/restart_app/Sources/restart_app/RestartAppPlugin.swift new file mode 100644 index 0000000..e6856f2 --- /dev/null +++ b/restart_app/ios/restart_app/Sources/restart_app/RestartAppPlugin.swift @@ -0,0 +1,96 @@ +import Flutter +import UIKit +import UserNotifications + +public class RestartAppPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "restart", binaryMessenger: registrar.messenger()) + let instance = RestartAppPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + // Remove any stale restart notification from a previous launch that + // fired after the app was already reopened. + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["restart_app"]) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "restartApp" { + let args = call.arguments as? [String: Any] + let title = args?["notificationTitle"] as? String ?? "Restart" + let body = args?["notificationBody"] as? String ?? "Tap to reopen the app." + + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + self.scheduleAndExit(title: title, body: body, result: result) + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in + DispatchQueue.main.async { + if let error = error { + result( + FlutterError( + code: "AUTHORIZATION_ERROR", + message: + "Failed to request notification permission: \(error.localizedDescription)", + details: nil + )) + } else if granted { + self.scheduleAndExit(title: title, body: body, result: result) + } else { + result( + FlutterError( + code: "NOTIFICATION_DENIED", + message: "Notification permission is required to restart the app on iOS." + + " The user must grant notification permission before calling restartApp().", + details: nil + )) + } + } + } + default: + result( + FlutterError( + code: "NOTIFICATION_DENIED", + message: "Notification permission is required to restart the app on iOS." + + " The user has denied notification permission.", + details: nil + )) + } + } + } + } else { + result(FlutterMethodNotImplemented) + } + } + + private func scheduleAndExit(title: String, body: String, result: @escaping FlutterResult) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "restart_app", content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + DispatchQueue.main.async { + if let error = error { + result( + FlutterError( + code: "NOTIFICATION_FAILED", + message: "Failed to schedule restart notification: \(error.localizedDescription)", + details: nil + )) + } else { + result("ok") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exit(0) + } + } + } + } + } +} diff --git a/restart_app/lib/restart_app.dart b/restart_app/lib/restart_app.dart index 9accaef..1a8e80e 100644 --- a/restart_app/lib/restart_app.dart +++ b/restart_app/lib/restart_app.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/services.dart'; /// `Restart` class provides a method to restart a Flutter application. @@ -11,31 +9,44 @@ import 'package:flutter/services.dart'; class Restart { /// A private constant `MethodChannel`. This channel is used to communicate with the /// platform-specific code to perform the restart operation. - static const MethodChannel _channel = const MethodChannel('restart'); + static const MethodChannel _channel = MethodChannel('restart'); /// Restarts the Flutter application. /// - /// The `webOrigin` parameter is optional. If it's null, the method uses the `window.origin` - /// to get the site origin. This parameter should only be filled when your current origin - /// is different than the app's origin. It defaults to null. + /// The [webOrigin] parameter is optional and web-only. If null, the method + /// uses `window.origin` to reload the page. Use this when your current origin + /// differs from the app's origin. Supports hash URL strategy (e.g. `'#/home'`). /// - /// The `customMessage` parameter is optional. It allows customization of the notification - /// message displayed on iOS when restarting the app. If not provided, a default message - /// will be used. + /// The [notificationTitle] and [notificationBody] parameters are iOS-only. + /// On iOS, the app terminates via `exit(0)` and a local notification is shown + /// to let the user reopen it. These parameters customize that notification's + /// content. Notification permission must be granted before calling this method + /// on iOS. Note: Apple's App Store guidelines prohibit calling `exit()` in + /// most circumstances; use this on iOS only when the tradeoff is acceptable + /// for your use case. /// - /// This method communicates with the platform-specific code to perform the restart operation, - /// and then checks the response. If the response is "ok", it returns true, signifying that - /// the restart operation was successful. Otherwise, it returns false. + /// The [forceKill] parameter is Android-only. When true, the old process is + /// fully terminated after the new activity starts, preventing stale native + /// resource locks. Defaults to false. + /// + /// Returns true if the restart was initiated successfully. static Future restartApp({ String? webOrigin, String? notificationTitle, String? notificationBody, + bool forceKill = false, }) async { final Map args = { 'webOrigin': webOrigin, 'notificationTitle': notificationTitle, 'notificationBody': notificationBody, + 'forceKill': forceKill, }; - return (await _channel.invokeMethod('restartApp', args)) == "ok"; + try { + final result = await _channel.invokeMethod('restartApp', args); + return result == 'ok'; + } on PlatformException { + return false; + } } } diff --git a/restart_app/lib/restart_web.dart b/restart_app/lib/restart_web.dart index a42735a..a6bb845 100644 --- a/restart_app/lib/restart_web.dart +++ b/restart_app/lib/restart_web.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; // In order to *not* need this ignore, consider extracting the "web" version @@ -33,13 +31,18 @@ class RestartWeb { /// Handles method calls from the Flutter code. /// /// If the method call is 'restartApp', it calls the `restart` method with the given `webOrigin`. - /// Otherwise, it returns 'false' to signify that the method call was not recognized. + /// Otherwise, throws a [PlatformException] for unrecognized method calls. Future handleMethodCall(MethodCall call) async { switch (call.method) { case 'restartApp': - return restart(call.arguments as String?); + final args = call.arguments as Map?; + final webOrigin = args?['webOrigin'] as String?; + return restart(webOrigin); default: - return 'false'; + throw PlatformException( + code: 'Unimplemented', + message: '${call.method} is not implemented on the web platform.', + ); } } @@ -51,9 +54,31 @@ class RestartWeb { /// /// This method replaces the current location with the given `webOrigin` (or `window.origin` if /// `webOrigin` is null), effectively reloading the web app. - void restart(String? webOrigin) { - web.window.location.replace( - webOrigin ?? web.window.origin.toString(), - ); + String restart(String? webOrigin) { + try { + final origin = + (webOrigin != null && webOrigin.isNotEmpty) ? webOrigin : null; + if (origin != null && origin.startsWith('#')) { + web.window.location.hash = origin; + web.window.location.reload(); + } else if (origin != null) { + web.window.location.replace(origin); + } else { + // window.origin returns the literal string "null" in sandboxed iframes, + // so we avoid passing it to replace() and fall back to a simple reload. + final windowOrigin = web.window.origin.toString(); + if (windowOrigin.isNotEmpty && windowOrigin != 'null') { + web.window.location.replace(windowOrigin); + } else { + web.window.location.reload(); + } + } + return 'ok'; + } catch (e) { + throw PlatformException( + code: 'RESTART_FAILED', + message: 'Failed to reload the page: $e', + ); + } } } diff --git a/restart_app/pubspec.yaml b/restart_app/pubspec.yaml index 5e5610d..ee89333 100644 --- a/restart_app/pubspec.yaml +++ b/restart_app/pubspec.yaml @@ -1,22 +1,29 @@ name: restart_app description: A Flutter plugin that helps you to restart the whole Flutter app with a single function call by using native APIs. -version: 1.3.2 +version: 1.7.3 +homepage: https://gabrimatic.info repository: https://github.com/gabrimatic/restart_app issue_tracker: https://github.com/gabrimatic/restart_app/issues +topics: + - restart + - app-lifecycle + - flutter-plugin +funding: + - https://www.buymeacoffee.com/gabrimatic environment: - sdk: ^3.5.1 - flutter: ">=1.17.0" + sdk: ^3.4.0 + flutter: ">=3.22.0" dependencies: flutter: sdk: flutter - web: ^1.0.0 - plugin_platform_interface: ^2.1.8 flutter_web_plugins: sdk: flutter + web: ^1.0.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter @@ -30,4 +37,10 @@ flutter: pluginClass: RestartAppPlugin web: pluginClass: RestartWeb - fileName: restart_web.dart \ No newline at end of file + fileName: restart_web.dart + linux: + pluginClass: RestartAppPlugin + macos: + pluginClass: RestartAppPlugin + windows: + pluginClass: RestartAppPluginCApi diff --git a/restart_app/test/restart_app_test.dart b/restart_app/test/restart_app_test.dart new file mode 100644 index 0000000..caded31 --- /dev/null +++ b/restart_app/test/restart_app_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restart_app/restart_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('restart'); + final log = []; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return 'ok'; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('restartApp returns true on success', () async { + final result = await Restart.restartApp(); + expect(result, isTrue); + }); + + test('restartApp returns false on non-ok response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return 'error'; + }); + + final result = await Restart.restartApp(); + expect(result, isFalse); + }); + + test('restartApp sends correct method name', () async { + await Restart.restartApp(); + expect(log, hasLength(1)); + expect(log.first.method, 'restartApp'); + }); + + test('restartApp passes default arguments', () async { + await Restart.restartApp(); + final args = log.first.arguments as Map; + expect(args['webOrigin'], isNull); + expect(args['notificationTitle'], isNull); + expect(args['notificationBody'], isNull); + expect(args['forceKill'], isFalse); + }); + + test('restartApp passes webOrigin', () async { + await Restart.restartApp(webOrigin: 'http://example.com'); + final args = log.first.arguments as Map; + expect(args['webOrigin'], 'http://example.com'); + }); + + test('restartApp passes iOS notification parameters', () async { + await Restart.restartApp( + notificationTitle: 'Title', + notificationBody: 'Body', + ); + final args = log.first.arguments as Map; + expect(args['notificationTitle'], 'Title'); + expect(args['notificationBody'], 'Body'); + }); + + test('restartApp passes forceKill', () async { + await Restart.restartApp(forceKill: true); + final args = log.first.arguments as Map; + expect(args['forceKill'], isTrue); + }); + + test('restartApp passes all parameters together', () async { + await Restart.restartApp( + webOrigin: '#/home', + notificationTitle: 'Restart', + notificationBody: 'Tap to reopen', + forceKill: true, + ); + final args = log.first.arguments as Map; + expect(args['webOrigin'], '#/home'); + expect(args['notificationTitle'], 'Restart'); + expect(args['notificationBody'], 'Tap to reopen'); + expect(args['forceKill'], isTrue); + }); + + test('restartApp returns false on PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'RESTART_FAILED', message: 'No activity'); + }); + + final result = await Restart.restartApp(); + expect(result, isFalse); + }); +}