bump versions

This commit is contained in:
otsmr 2026-03-14 00:16:48 +01:00
parent 33111edeb2
commit 24d048b4ab
33 changed files with 3567 additions and 635 deletions

View file

@ -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

View file

@ -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<Double>("radius") ?: 30.0).toFloat()
result.success(toggleScreenshotWithBlur(radius))
}
SET_COLOR_CONST -> {
val color = call.argument<Int>("color") ?: 0xFF000000.toInt()
result.success(toggleScreenshotWithColor(color))
}
ENABLE_IMAGE_CONST -> {
result.success(enableImageOverlay())
}
ENABLE_BLUR_CONST -> {
val radius = (call.argument<Double>("radius") ?: 30.0).toFloat()
result.success(enableBlurOverlay(radius))
}
ENABLE_COLOR_CONST -> {
val color = call.argument<Int>("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 1730: 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<Int> { 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<Int> ?: 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) {

View file

@ -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
}
}

View file

@ -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

View file

@ -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")
]
)
]
)

View file

@ -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

View file

@ -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';

View file

@ -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<ScreenshotSnapshot>? _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<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) {
return _instancePlatform.toggleScreenshotWithBlur(blurRadius: blurRadius);
}
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) {
return _instancePlatform.toggleScreenshotWithColor(color: color);
}
/// Always enables image overlay mode (idempotent safe to call repeatedly).
@override
Future<bool> screenshotWithImage() {
return _instancePlatform.screenshotWithImage();
}
/// Always enables blur overlay mode (idempotent safe to call repeatedly).
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) {
return _instancePlatform.screenshotWithBlur(blurRadius: blurRadius);
}
/// Always enables color overlay mode (idempotent safe to call repeatedly).
@override
Future<bool> 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.

View file

@ -15,16 +15,22 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
@visibleForTesting
final eventChannel = const EventChannel(screenshotEventChannel);
Stream<ScreenshotSnapshot>? _cachedStream;
@override
Stream<ScreenshotSnapshot> get screenshotStream {
return eventChannel.receiveBroadcastStream().map((event) =>
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>));
_cachedStream ??= eventChannel.receiveBroadcastStream().map(
(event) =>
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>),
);
return _cachedStream!;
}
@override
Future<bool> toggleScreenshot() async {
final result =
await methodChannel.invokeMethod<bool>(toggleScreenShotConst);
final result = await methodChannel.invokeMethod<bool>(
toggleScreenShotConst,
);
return result ?? false;
}
@ -46,6 +52,44 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
return result ?? false;
}
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
final result = await methodChannel.invokeMethod<bool>(screenSetBlur, {
'radius': blurRadius,
});
return result ?? false;
}
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
final result = await methodChannel.invokeMethod<bool>(screenSetColor, {
'color': color,
});
return result ?? false;
}
@override
Future<bool> screenshotWithImage() async {
final result = await methodChannel.invokeMethod<bool>(screenEnableImage);
return result ?? false;
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
final result = await methodChannel.invokeMethod<bool>(screenEnableBlur, {
'radius': blurRadius,
});
return result ?? false;
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
final result = await methodChannel.invokeMethod<bool>(screenEnableColor, {
'color': color,
});
return result ?? false;
}
@override
Future<void> startScreenshotListening() {
return methodChannel.invokeMethod<void>(startScreenshotListeningConst);

View file

@ -43,7 +43,35 @@ abstract class NoScreenshotPlatform extends PlatformInterface {
/// throw `UnmimplementedError` if not implement
Future<bool> toggleScreenshotWithImage() {
throw UnimplementedError(
'toggleScreenshotWithImage() has not been implemented.');
'toggleScreenshotWithImage() has not been implemented.',
);
}
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) {
throw UnimplementedError(
'toggleScreenshotWithBlur() has not been implemented.',
);
}
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) {
throw UnimplementedError(
'toggleScreenshotWithColor() has not been implemented.',
);
}
/// Always enables image overlay mode (idempotent safe to call repeatedly).
Future<bool> screenshotWithImage() {
throw UnimplementedError('screenshotWithImage() has not been implemented.');
}
/// Always enables blur overlay mode (idempotent safe to call repeatedly).
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) {
throw UnimplementedError('screenshotWithBlur() has not been implemented.');
}
/// Always enables color overlay mode (idempotent safe to call repeatedly).
Future<bool> 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<void> startScreenshotListening() {
throw UnimplementedError(
'startScreenshotListening has not been implemented.');
'startScreenshotListening has not been implemented.',
);
}
/// Stop listening to screenshot activities
Future<void> stopScreenshotListening() {
throw UnimplementedError(
'stopScreenshotListening has not been implemented.');
'stopScreenshotListening has not been implemented.',
);
}
/// Start listening to screen recording activities
Future<void> startScreenRecordingListening() {
throw UnimplementedError(
'startScreenRecordingListening has not been implemented.');
'startScreenRecordingListening has not been implemented.',
);
}
/// Stop listening to screen recording activities
Future<void> stopScreenRecordingListening() {
throw UnimplementedError(
'stopScreenRecordingListening has not been implemented.');
'stopScreenRecordingListening has not been implemented.',
);
}
}

View file

@ -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<ScreenshotSnapshot> _controller =
StreamController<ScreenshotSnapshot>.broadcast();
// JS event listeners (stored for removal)
JSFunction? _contextMenuHandler;
JSFunction? _keyDownHandler;
JSFunction? _visibilityHandler;
// Stream
@override
Stream<ScreenshotSnapshot> get screenshotStream => _controller.stream;
// Protection
@override
Future<bool> screenshotOff() async {
_enableProtection();
return true;
}
@override
Future<bool> screenshotOn() async {
_disableProtection();
return true;
}
@override
Future<bool> toggleScreenshot() async {
_isProtectionOn ? _disableProtection() : _enableProtection();
return true;
}
@override
Future<bool> toggleScreenshotWithImage() async {
_isProtectionOn ? _disableProtection() : _enableProtection();
return _isProtectionOn;
}
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
_isProtectionOn ? _disableProtection() : _enableProtection();
return _isProtectionOn;
}
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
_isProtectionOn ? _disableProtection() : _enableProtection();
return _isProtectionOn;
}
@override
Future<bool> screenshotWithImage() async {
_enableProtection();
return true;
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
_enableProtection();
return true;
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
_enableProtection();
return true;
}
// Screenshot Listening
@override
Future<void> startScreenshotListening() async {
if (_isListening) return;
_isListening = true;
_addVisibilityListener();
}
@override
Future<void> stopScreenshotListening() async {
_isListening = false;
_removeVisibilityListener();
}
// Recording Listening (no-op on web)
@override
Future<void> startScreenRecordingListening() async {}
@override
Future<void> 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;
}
}
}

View file

@ -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<void> 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();
}
}

View file

@ -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<String, dynamic> 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;
}
}

View file

@ -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<String, SecureRouteConfig> policies;
final SecureRouteConfig defaultConfig;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_applyPolicyForRoute(route);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
_applyPolicyForRoute(previousRoute);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
_applyPolicyForRoute(newRoute);
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
_applyPolicyForRoute(previousRoute);
}
void _applyPolicyForRoute(Route<dynamic>? route) {
final name = route?.settings.name;
final config = (name != null ? policies[name] : null) ?? defaultConfig;
applyOverlayMode(
config.mode,
blurRadius: config.blurRadius,
color: config.color,
);
}
}

View file

@ -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<SecureWidget> createState() => _SecureWidgetState();
}
class _SecureWidgetState extends State<SecureWidget> {
@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;
}

View file

@ -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

View file

@ -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<ScreenshotSnapshot> controller;
late _MockNoScreenshotPlatform mockPlatform;
late NoScreenshot noScreenshot;
setUp(() {
controller = StreamController<ScreenshotSnapshot>.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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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<ScreenshotSnapshot> _stream;
_MockNoScreenshotPlatform(this._stream);
@override
Stream<ScreenshotSnapshot> get screenshotStream => _stream;
@override
Future<bool> screenshotOff() async => true;
@override
Future<bool> screenshotOn() async => true;
@override
Future<bool> toggleScreenshot() async => true;
@override
Future<bool> toggleScreenshotWithImage() async => true;
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async =>
true;
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async =>
true;
@override
Future<bool> screenshotWithImage() async => true;
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async => true;
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async => true;
@override
Future<void> startScreenshotListening() async {}
@override
Future<void> stopScreenshotListening() async {}
@override
Future<void> startScreenRecordingListening() async {}
@override
Future<void> stopScreenRecordingListening() async {}
}

View file

@ -38,11 +38,36 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform {
return true;
}
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
return true;
}
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
return true;
}
@override
Future<void> stopScreenshotListening() async {
return;
}
@override
Future<bool> screenshotWithImage() async {
return true;
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
return true;
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
return true;
}
@override
Future<void> startScreenRecordingListening() async {
return;
@ -59,8 +84,10 @@ void main() {
group('NoScreenshotPlatform', () {
test('default instance should be MethodChannelNoScreenshot', () {
expect(NoScreenshotPlatform.instance,
isInstanceOf<MethodChannelNoScreenshot>());
expect(
NoScreenshotPlatform.instance,
isInstanceOf<MethodChannelNoScreenshot>(),
);
});
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,
);
},
);
});
}

View file

@ -25,6 +25,16 @@ class MockNoScreenshotPlatform
return Future.value(true);
}
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
return Future.value(true);
}
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
return Future.value(true);
}
@override
Future<bool> toggleScreenshot() async {
// Mock implementation or return a fixed value
@ -34,6 +44,21 @@ class MockNoScreenshotPlatform
@override
Stream<ScreenshotSnapshot> get screenshotStream => const Stream.empty();
@override
Future<bool> screenshotWithImage() async {
return Future.value(true);
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
return Future.value(true);
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
return Future.value(true);
}
@override
Future<void> startScreenshotListening() {
return Future.value();
@ -90,8 +115,10 @@ void main() {
});
test('screenshotStream', () async {
expect(NoScreenshot.instance.screenshotStream,
isInstanceOf<Stream<ScreenshotSnapshot>>());
expect(
NoScreenshot.instance.screenshotStream,
isInstanceOf<Stream<ScreenshotSnapshot>>(),
);
});
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;

View file

@ -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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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 = <ScreenshotSnapshot>[];
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);
});
});
}

View file

@ -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<String> calls = [];
@override
Future<bool> screenshotOff() async {
calls.add('screenshotOff');
return true;
}
@override
Future<bool> screenshotOn() async {
calls.add('screenshotOn');
return true;
}
@override
Future<bool> screenshotWithImage() async {
calls.add('screenshotWithImage');
return true;
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
calls.add('screenshotWithBlur($blurRadius)');
return true;
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
calls.add('screenshotWithColor($color)');
return true;
}
@override
Future<bool> toggleScreenshot() async => true;
@override
Future<bool> toggleScreenshotWithImage() async => true;
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async =>
true;
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async =>
true;
@override
Stream<ScreenshotSnapshot> get screenshotStream => const Stream.empty();
@override
Future<void> startScreenshotListening() async {}
@override
Future<void> stopScreenshotListening() async {}
@override
Future<void> startScreenRecordingListening() async {}
@override
Future<void> stopScreenRecordingListening() async {}
}
// Helper to create a fake route with a given name
Route<dynamic> _fakeRoute(String? name) {
return PageRouteBuilder<void>(
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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
expect(
fakePlatform.calls,
contains('screenshotWithColor(${0xFF2196F3})'),
);
});
});
}

View file

@ -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<String> calls = [];
@override
Future<bool> screenshotOff() async {
calls.add('screenshotOff');
return true;
}
@override
Future<bool> screenshotOn() async {
calls.add('screenshotOn');
return true;
}
@override
Future<bool> screenshotWithImage() async {
calls.add('screenshotWithImage');
return true;
}
@override
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
calls.add('screenshotWithBlur($blurRadius)');
return true;
}
@override
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
calls.add('screenshotWithColor($color)');
return true;
}
@override
Future<bool> toggleScreenshot() async => true;
@override
Future<bool> toggleScreenshotWithImage() async => true;
@override
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async =>
true;
@override
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async =>
true;
@override
Stream<ScreenshotSnapshot> get screenshotStream => const Stream.empty();
@override
Future<void> startScreenshotListening() async {}
@override
Future<void> stopScreenshotListening() async {}
@override
Future<void> startScreenRecordingListening() async {}
@override
Future<void> 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);
});
}

View file

@ -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

View file

@ -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<Boolean>("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
}
}
}

View file

@ -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)
}
}
}

View file

@ -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'

View file

@ -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"
)
]
)

View file

@ -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)
}
}
}
}
}
}

View file

@ -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<bool> restartApp({
String? webOrigin,
String? notificationTitle,
String? notificationBody,
bool forceKill = false,
}) async {
final Map<String, dynamic> args = {
'webOrigin': webOrigin,
'notificationTitle': notificationTitle,
'notificationBody': notificationBody,
'forceKill': forceKill,
};
return (await _channel.invokeMethod('restartApp', args)) == "ok";
try {
final result = await _channel.invokeMethod<String>('restartApp', args);
return result == 'ok';
} on PlatformException {
return false;
}
}
}

View file

@ -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<dynamic> 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',
);
}
}
}

View file

@ -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
fileName: restart_web.dart
linux:
pluginClass: RestartAppPlugin
macos:
pluginClass: RestartAppPlugin
windows:
pluginClass: RestartAppPluginCApi

View file

@ -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 = <MethodCall>[];
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);
});
}