bump versions
This commit is contained in:
parent
33111edeb2
commit
24d048b4ab
33 changed files with 3567 additions and 635 deletions
|
|
@ -9,11 +9,11 @@ introduction_screen: 4a90e557630b28834479ed9c64a9d2d0185d8e48
|
||||||
libsignal_protocol_dart: 618f0c0b49534245a640a31d204265440cbac9ee
|
libsignal_protocol_dart: 618f0c0b49534245a640a31d204265440cbac9ee
|
||||||
lottie: 4f1a5a52bdf1e1c1e12fa97c96174dcb05419e19
|
lottie: 4f1a5a52bdf1e1c1e12fa97c96174dcb05419e19
|
||||||
mutex: 84ca903a3ac863735e3228c75a212133621f680f
|
mutex: 84ca903a3ac863735e3228c75a212133621f680f
|
||||||
no_screenshot: 9ca2a492ff12e5179583a1fa015bf0843382b866
|
no_screenshot: 299c2d1d70448f23f1e9835a18585edfc9dbe6fa
|
||||||
optional: 71c638891ce4f2aff35c7387727989f31f9d877d
|
optional: 71c638891ce4f2aff35c7387727989f31f9d877d
|
||||||
photo_view: a13ca2fc387a3fb1276126959e092c44d0029987
|
photo_view: a13ca2fc387a3fb1276126959e092c44d0029987
|
||||||
pointycastle: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd
|
pointycastle: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd
|
||||||
qr: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e
|
qr: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e
|
||||||
qr_flutter: d5e7206396105d643113618290bbcc755d05f492
|
qr_flutter: d5e7206396105d643113618290bbcc755d05f492
|
||||||
restart_app: 12339f63bf8e9631e619c4f9f6b4e013fa324715
|
restart_app: 66897cb67e235bab85421647bfae036acb4438cb
|
||||||
x25519: ecb1d357714537bba6e276ef45f093846d4beaee
|
x25519: ecb1d357714537bba6e276ef45f093846d4beaee
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,24 @@ import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.database.ContentObserver
|
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.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
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.util.Log
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
import android.view.WindowManager.LayoutParams
|
import android.view.WindowManager.LayoutParams
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
|
@ -35,7 +46,16 @@ const val SCREENSHOT_PATH = "screenshot_path"
|
||||||
const val PREF_KEY_SCREENSHOT = "is_screenshot_on"
|
const val PREF_KEY_SCREENSHOT = "is_screenshot_on"
|
||||||
const val SCREENSHOT_TAKEN = "was_screenshot_taken"
|
const val SCREENSHOT_TAKEN = "was_screenshot_taken"
|
||||||
const val SET_IMAGE_CONST = "toggleScreenshotWithImage"
|
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_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 IS_SCREEN_RECORDING = "is_screen_recording"
|
||||||
const val START_SCREEN_RECORDING_LISTENING_CONST = "startScreenRecordingListening"
|
const val START_SCREEN_RECORDING_LISTENING_CONST = "startScreenRecordingListening"
|
||||||
const val STOP_SCREEN_RECORDING_LISTENING_CONST = "stopScreenRecordingListening"
|
const val STOP_SCREEN_RECORDING_LISTENING_CONST = "stopScreenRecordingListening"
|
||||||
|
|
@ -57,11 +77,18 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
private var lastSharedPreferencesState: String = ""
|
private var lastSharedPreferencesState: String = ""
|
||||||
private var hasSharedPreferencesChanged: Boolean = false
|
private var hasSharedPreferencesChanged: Boolean = false
|
||||||
private var isImageOverlayModeEnabled: Boolean = false
|
private var isImageOverlayModeEnabled: Boolean = false
|
||||||
|
private var isBlurOverlayModeEnabled: Boolean = false
|
||||||
|
private var isColorOverlayModeEnabled: Boolean = false
|
||||||
private var overlayImageView: ImageView? = null
|
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 lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||||
private var isScreenRecording: Boolean = false
|
private var isScreenRecording: Boolean = false
|
||||||
private var isRecordingListening: Boolean = false
|
private var isRecordingListening: Boolean = false
|
||||||
private var screenCaptureCallback: Any? = null
|
private var screenCaptureCallback: Any? = null
|
||||||
|
private var screenRecordingCallback: Any? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
context = flutterPluginBinding.applicationContext
|
context = flutterPluginBinding.applicationContext
|
||||||
|
|
@ -87,13 +114,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
activity = binding.activity
|
activity = binding.activity
|
||||||
restoreScreenshotState()
|
restoreScreenshotState()
|
||||||
if (isRecordingListening) {
|
if (isRecordingListening) {
|
||||||
registerScreenCaptureCallback()
|
registerScreenRecordingCallbacks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
unregisterScreenCaptureCallback()
|
unregisterScreenRecordingCallbacks()
|
||||||
removeImageOverlay()
|
removeImageOverlay()
|
||||||
|
removeBlurOverlay()
|
||||||
|
removeColorOverlay()
|
||||||
activity = null
|
activity = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,13 +130,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
activity = binding.activity
|
activity = binding.activity
|
||||||
restoreScreenshotState()
|
restoreScreenshotState()
|
||||||
if (isRecordingListening) {
|
if (isRecordingListening) {
|
||||||
registerScreenCaptureCallback()
|
registerScreenRecordingCallbacks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
unregisterScreenCaptureCallback()
|
unregisterScreenRecordingCallbacks()
|
||||||
removeImageOverlay()
|
removeImageOverlay()
|
||||||
|
removeBlurOverlay()
|
||||||
|
removeColorOverlay()
|
||||||
activity = null
|
activity = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +171,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
result.success(toggleScreenshotWithImage())
|
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 -> {
|
START_SCREEN_RECORDING_LISTENING_CONST -> {
|
||||||
startRecordingListening()
|
startRecordingListening()
|
||||||
result.success("Recording listening started")
|
result.success("Recording listening started")
|
||||||
|
|
@ -171,7 +226,12 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
if (act == activity && isImageOverlayModeEnabled) {
|
if (act == activity && isImageOverlayModeEnabled) {
|
||||||
act.window?.clearFlags(LayoutParams.FLAG_SECURE)
|
act.window?.clearFlags(LayoutParams.FLAG_SECURE)
|
||||||
showImageOverlay(act)
|
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) {
|
if (act == activity && isImageOverlayModeEnabled) {
|
||||||
removeImageOverlay()
|
removeImageOverlay()
|
||||||
act.window?.addFlags(LayoutParams.FLAG_SECURE)
|
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) {
|
override fun onActivitySaveInstanceState(act: Activity, outState: Bundle) {
|
||||||
if (act == activity && isImageOverlayModeEnabled) {
|
if (act == activity && isImageOverlayModeEnabled) {
|
||||||
showImageOverlay(act)
|
showImageOverlay(act)
|
||||||
|
} else if (act == activity && isBlurOverlayModeEnabled) {
|
||||||
|
showBlurOverlay(act)
|
||||||
|
} else if (act == activity && isColorOverlayModeEnabled) {
|
||||||
|
showColorOverlay(act)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onActivityDestroyed(act: Activity) {}
|
override fun onActivityDestroyed(act: Activity) {}
|
||||||
|
|
@ -238,6 +308,17 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
saveImageOverlayState(isImageOverlayModeEnabled)
|
saveImageOverlayState(isImageOverlayModeEnabled)
|
||||||
|
|
||||||
if (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()
|
screenshotOff()
|
||||||
} else {
|
} else {
|
||||||
screenshotOn()
|
screenshotOn()
|
||||||
|
|
@ -247,31 +328,301 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
return isImageOverlayModeEnabled
|
return isImageOverlayModeEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toggleScreenshotWithBlur(radius: Float): Boolean {
|
||||||
|
isBlurOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_BLUR_OVERLAY, false)
|
||||||
|
blurRadius = radius
|
||||||
|
saveBlurOverlayState(isBlurOverlayModeEnabled)
|
||||||
|
saveBlurRadius(radius)
|
||||||
|
|
||||||
|
if (isBlurOverlayModeEnabled) {
|
||||||
|
// Deactivate image and color modes if active (mutual exclusivity)
|
||||||
|
if (isImageOverlayModeEnabled) {
|
||||||
|
isImageOverlayModeEnabled = false
|
||||||
|
saveImageOverlayState(false)
|
||||||
|
removeImageOverlay()
|
||||||
|
}
|
||||||
|
if (isColorOverlayModeEnabled) {
|
||||||
|
isColorOverlayModeEnabled = false
|
||||||
|
saveColorOverlayState(false)
|
||||||
|
removeColorOverlay()
|
||||||
|
}
|
||||||
|
screenshotOff()
|
||||||
|
} else {
|
||||||
|
screenshotOn()
|
||||||
|
removeBlurOverlay()
|
||||||
|
}
|
||||||
|
updateSharedPreferencesState("")
|
||||||
|
return isBlurOverlayModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun showBlurOverlay(activity: Activity) {
|
||||||
|
if (overlayBlurView != null) return
|
||||||
|
val decorView = activity.window?.decorView ?: return
|
||||||
|
val radius = blurRadius.coerceAtLeast(0.1f)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
// API 31+: GPU blur via RenderEffect on decorView — supports any radius
|
||||||
|
activity.runOnUiThread {
|
||||||
|
decorView.setRenderEffect(
|
||||||
|
RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
|
||||||
|
)
|
||||||
|
overlayBlurView = decorView
|
||||||
|
}
|
||||||
|
} else if (Build.VERSION.SDK_INT >= 17) {
|
||||||
|
// API 17–30: Capture bitmap, blur with RenderScript (max 25f)
|
||||||
|
activity.runOnUiThread {
|
||||||
|
val width = decorView.width
|
||||||
|
val height = decorView.height
|
||||||
|
if (width <= 0 || height <= 0) return@runOnUiThread
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
decorView.draw(canvas)
|
||||||
|
|
||||||
|
val rs = RenderScript.create(activity)
|
||||||
|
val input = Allocation.createFromBitmap(rs, bitmap)
|
||||||
|
val output = Allocation.createTyped(rs, input.type)
|
||||||
|
val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
|
||||||
|
script.setRadius(radius.coerceAtMost(25f))
|
||||||
|
script.setInput(input)
|
||||||
|
script.forEach(output)
|
||||||
|
output.copyTo(bitmap)
|
||||||
|
script.destroy()
|
||||||
|
input.destroy()
|
||||||
|
output.destroy()
|
||||||
|
rs.destroy()
|
||||||
|
|
||||||
|
val imageView = ImageView(activity).apply {
|
||||||
|
setImageBitmap(bitmap)
|
||||||
|
scaleType = ImageView.ScaleType.FIT_XY
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(decorView as? ViewGroup)?.addView(imageView)
|
||||||
|
overlayBlurView = imageView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// API <17: FLAG_SECURE alone prevents app switcher preview; no blur needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeBlurOverlay() {
|
||||||
|
val blurView = overlayBlurView ?: return
|
||||||
|
val act = activity
|
||||||
|
if (act != null) {
|
||||||
|
act.runOnUiThread {
|
||||||
|
if (Build.VERSION.SDK_INT >= 31 && blurView === act.window?.decorView) {
|
||||||
|
blurView.setRenderEffect(null)
|
||||||
|
} else {
|
||||||
|
(blurView.parent as? ViewGroup)?.removeView(blurView)
|
||||||
|
}
|
||||||
|
overlayBlurView = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
blurView.setRenderEffect(null)
|
||||||
|
} else {
|
||||||
|
(blurView.parent as? ViewGroup)?.removeView(blurView)
|
||||||
|
}
|
||||||
|
overlayBlurView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleScreenshotWithColor(color: Int): Boolean {
|
||||||
|
isColorOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_COLOR_OVERLAY, false)
|
||||||
|
colorValue = color
|
||||||
|
saveColorOverlayState(isColorOverlayModeEnabled)
|
||||||
|
saveColorValue(color)
|
||||||
|
|
||||||
|
if (isColorOverlayModeEnabled) {
|
||||||
|
// Deactivate image and blur modes if active (mutual exclusivity)
|
||||||
|
if (isImageOverlayModeEnabled) {
|
||||||
|
isImageOverlayModeEnabled = false
|
||||||
|
saveImageOverlayState(false)
|
||||||
|
removeImageOverlay()
|
||||||
|
}
|
||||||
|
if (isBlurOverlayModeEnabled) {
|
||||||
|
isBlurOverlayModeEnabled = false
|
||||||
|
saveBlurOverlayState(false)
|
||||||
|
removeBlurOverlay()
|
||||||
|
}
|
||||||
|
screenshotOff()
|
||||||
|
} else {
|
||||||
|
screenshotOn()
|
||||||
|
removeColorOverlay()
|
||||||
|
}
|
||||||
|
updateSharedPreferencesState("")
|
||||||
|
return isColorOverlayModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableImageOverlay(): Boolean {
|
||||||
|
isImageOverlayModeEnabled = true
|
||||||
|
saveImageOverlayState(true)
|
||||||
|
if (isBlurOverlayModeEnabled) {
|
||||||
|
isBlurOverlayModeEnabled = false
|
||||||
|
saveBlurOverlayState(false)
|
||||||
|
removeBlurOverlay()
|
||||||
|
}
|
||||||
|
if (isColorOverlayModeEnabled) {
|
||||||
|
isColorOverlayModeEnabled = false
|
||||||
|
saveColorOverlayState(false)
|
||||||
|
removeColorOverlay()
|
||||||
|
}
|
||||||
|
screenshotOff()
|
||||||
|
updateSharedPreferencesState("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableBlurOverlay(radius: Float): Boolean {
|
||||||
|
isBlurOverlayModeEnabled = true
|
||||||
|
blurRadius = radius
|
||||||
|
saveBlurOverlayState(true)
|
||||||
|
saveBlurRadius(radius)
|
||||||
|
if (isImageOverlayModeEnabled) {
|
||||||
|
isImageOverlayModeEnabled = false
|
||||||
|
saveImageOverlayState(false)
|
||||||
|
removeImageOverlay()
|
||||||
|
}
|
||||||
|
if (isColorOverlayModeEnabled) {
|
||||||
|
isColorOverlayModeEnabled = false
|
||||||
|
saveColorOverlayState(false)
|
||||||
|
removeColorOverlay()
|
||||||
|
}
|
||||||
|
screenshotOff()
|
||||||
|
updateSharedPreferencesState("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableColorOverlay(color: Int): Boolean {
|
||||||
|
isColorOverlayModeEnabled = true
|
||||||
|
colorValue = color
|
||||||
|
saveColorOverlayState(true)
|
||||||
|
saveColorValue(color)
|
||||||
|
if (isImageOverlayModeEnabled) {
|
||||||
|
isImageOverlayModeEnabled = false
|
||||||
|
saveImageOverlayState(false)
|
||||||
|
removeImageOverlay()
|
||||||
|
}
|
||||||
|
if (isBlurOverlayModeEnabled) {
|
||||||
|
isBlurOverlayModeEnabled = false
|
||||||
|
saveBlurOverlayState(false)
|
||||||
|
removeBlurOverlay()
|
||||||
|
}
|
||||||
|
screenshotOff()
|
||||||
|
updateSharedPreferencesState("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showColorOverlay(activity: Activity) {
|
||||||
|
if (overlayColorView != null) return
|
||||||
|
val decorView = activity.window?.decorView ?: return
|
||||||
|
activity.runOnUiThread {
|
||||||
|
val colorView = View(activity).apply {
|
||||||
|
setBackgroundColor(colorValue)
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(decorView as? ViewGroup)?.addView(colorView)
|
||||||
|
overlayColorView = colorView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeColorOverlay() {
|
||||||
|
val colorView = overlayColorView ?: return
|
||||||
|
val act = activity
|
||||||
|
if (act != null) {
|
||||||
|
act.runOnUiThread {
|
||||||
|
(colorView.parent as? ViewGroup)?.removeView(colorView)
|
||||||
|
overlayColorView = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(colorView.parent as? ViewGroup)?.removeView(colorView)
|
||||||
|
overlayColorView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Screen Recording Detection ─────────────────────────────────────
|
// ── 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() {
|
private fun startRecordingListening() {
|
||||||
if (isRecordingListening) return
|
if (isRecordingListening) return
|
||||||
isRecordingListening = true
|
isRecordingListening = true
|
||||||
registerScreenCaptureCallback()
|
registerScreenRecordingCallbacks()
|
||||||
updateSharedPreferencesState("")
|
updateSharedPreferencesState("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopRecordingListening() {
|
private fun stopRecordingListening() {
|
||||||
if (!isRecordingListening) return
|
if (!isRecordingListening) return
|
||||||
isRecordingListening = false
|
isRecordingListening = false
|
||||||
unregisterScreenCaptureCallback()
|
unregisterScreenRecordingCallbacks()
|
||||||
isScreenRecording = false
|
isScreenRecording = false
|
||||||
updateSharedPreferencesState("")
|
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() {
|
private fun registerScreenCaptureCallback() {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
val act = activity ?: return
|
val act = activity ?: return
|
||||||
if (screenCaptureCallback != null) return
|
if (screenCaptureCallback != null) return
|
||||||
|
|
||||||
val callback = Activity.ScreenCaptureCallback {
|
val callback = Activity.ScreenCaptureCallback {
|
||||||
isScreenRecording = true
|
isScreenRecording = true
|
||||||
updateSharedPreferencesState("")
|
updateSharedPreferencesState("", System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
act.registerScreenCaptureCallback(act.mainExecutor, callback)
|
act.registerScreenCaptureCallback(act.mainExecutor, callback)
|
||||||
screenCaptureCallback = callback
|
screenCaptureCallback = callback
|
||||||
|
|
@ -279,7 +630,7 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unregisterScreenCaptureCallback() {
|
private fun unregisterScreenCaptureCallback() {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
val act = activity ?: return
|
val act = activity ?: return
|
||||||
val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback ?: return
|
val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback ?: return
|
||||||
act.unregisterScreenCaptureCallback(callback)
|
act.unregisterScreenCaptureCallback(callback)
|
||||||
|
|
@ -296,7 +647,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
||||||
.contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString())
|
.contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString())
|
||||||
) {
|
) {
|
||||||
Log.d("ScreenshotProtection", "Screenshot detected")
|
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() {
|
private fun restoreScreenshotState() {
|
||||||
Executors.newSingleThreadExecutor().execute {
|
Executors.newSingleThreadExecutor().execute {
|
||||||
val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false)
|
val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false)
|
||||||
val overlayEnabled = preferences.getBoolean(PREF_KEY_IMAGE_OVERLAY, 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
|
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 {
|
activity?.runOnUiThread {
|
||||||
if (isImageOverlayModeEnabled || isSecure) {
|
if (isImageOverlayModeEnabled || isBlurOverlayModeEnabled || isColorOverlayModeEnabled || isSecure) {
|
||||||
screenshotOff()
|
screenshotOff()
|
||||||
} else {
|
} else {
|
||||||
screenshotOn()
|
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({
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
val isSecure =
|
val isSecure =
|
||||||
(activity?.window?.attributes?.flags ?: 0) and LayoutParams.FLAG_SECURE != 0
|
(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,
|
PREF_KEY_SCREENSHOT to isSecure,
|
||||||
SCREENSHOT_PATH to screenshotData,
|
SCREENSHOT_PATH to screenshotData,
|
||||||
SCREENSHOT_TAKEN to screenshotData.isNotEmpty(),
|
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) {
|
if (lastSharedPreferencesState != jsonString) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
#
|
#
|
||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'no_screenshot'
|
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.summary = 'Flutter plugin to enable, disable or toggle screenshot support in your application.'
|
||||||
s.description = <<-DESC
|
s.description = <<-DESC
|
||||||
A new Flutter plugin project.
|
A new Flutter plugin project.
|
||||||
|
|
@ -13,12 +13,12 @@ A new Flutter plugin project.
|
||||||
s.license = { :file => '../LICENSE' }
|
s.license = { :file => '../LICENSE' }
|
||||||
s.author = { 'FlutterPlaza' => 'dev@flutterplaza.com' }
|
s.author = { 'FlutterPlaza' => 'dev@flutterplaza.com' }
|
||||||
s.source = { :path => '.' }
|
s.source = { :path => '.' }
|
||||||
s.source_files = 'Classes/**/*'
|
s.source_files = 'no_screenshot/Sources/no_screenshot/**/*.swift', 'Classes/**/*.{h,m}'
|
||||||
s.dependency 'Flutter'
|
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.
|
# Flutter.framework does not contain a i386 slice.
|
||||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
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"
|
s.swift_version = "5.0"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
24
no_screenshot/ios/no_screenshot/Package.swift
Normal file
24
no_screenshot/ios/no_screenshot/Package.swift
Normal 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")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
const screenShotOnConst = "screenshotOn";
|
const screenShotOnConst = "screenshotOn";
|
||||||
const screenShotOffConst = "screenshotOff";
|
const screenShotOffConst = "screenshotOff";
|
||||||
const screenSetImage = "toggleScreenshotWithImage";
|
const screenSetImage = "toggleScreenshotWithImage";
|
||||||
|
const screenSetBlur = "toggleScreenshotWithBlur";
|
||||||
|
const screenSetColor = "toggleScreenshotWithColor";
|
||||||
|
const screenEnableImage = "screenshotWithImage";
|
||||||
|
const screenEnableBlur = "screenshotWithBlur";
|
||||||
|
const screenEnableColor = "screenshotWithColor";
|
||||||
const toggleScreenShotConst = "toggleScreenshot";
|
const toggleScreenShotConst = "toggleScreenshot";
|
||||||
const startScreenshotListeningConst = 'startScreenshotListening';
|
const startScreenshotListeningConst = 'startScreenshotListening';
|
||||||
const stopScreenshotListeningConst = 'stopScreenshotListening';
|
const stopScreenshotListeningConst = 'stopScreenshotListening';
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,81 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:no_screenshot/screenshot_snapshot.dart';
|
import 'package:no_screenshot/screenshot_snapshot.dart';
|
||||||
|
|
||||||
import 'no_screenshot_platform_interface.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.
|
/// A class that provides a platform-agnostic way to disable screenshots.
|
||||||
///
|
///
|
||||||
class NoScreenshot implements NoScreenshotPlatform {
|
class NoScreenshot implements NoScreenshotPlatform {
|
||||||
final _instancePlatform = NoScreenshotPlatform.instance;
|
NoScreenshotPlatform get _instancePlatform => NoScreenshotPlatform.instance;
|
||||||
NoScreenshot._();
|
NoScreenshot._();
|
||||||
|
|
||||||
@Deprecated(
|
@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();
|
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
|
/// Return `true` if screenshot capabilities has been
|
||||||
/// successfully disabled or is currently disabled and `false` otherwise.
|
/// successfully disabled or is currently disabled and `false` otherwise.
|
||||||
|
|
@ -37,6 +100,34 @@ class NoScreenshot implements NoScreenshotPlatform {
|
||||||
return _instancePlatform.toggleScreenshotWithImage();
|
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
|
/// Return `true` if screenshot capabilities has been
|
||||||
/// successfully toggle from it previous state and `false` if the attempt
|
/// successfully toggle from it previous state and `false` if the attempt
|
||||||
/// to toggle failed.
|
/// to toggle failed.
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,22 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
final eventChannel = const EventChannel(screenshotEventChannel);
|
final eventChannel = const EventChannel(screenshotEventChannel);
|
||||||
|
|
||||||
|
Stream<ScreenshotSnapshot>? _cachedStream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<ScreenshotSnapshot> get screenshotStream {
|
Stream<ScreenshotSnapshot> get screenshotStream {
|
||||||
return eventChannel.receiveBroadcastStream().map((event) =>
|
_cachedStream ??= eventChannel.receiveBroadcastStream().map(
|
||||||
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>));
|
(event) =>
|
||||||
|
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
return _cachedStream!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> toggleScreenshot() async {
|
Future<bool> toggleScreenshot() async {
|
||||||
final result =
|
final result = await methodChannel.invokeMethod<bool>(
|
||||||
await methodChannel.invokeMethod<bool>(toggleScreenShotConst);
|
toggleScreenShotConst,
|
||||||
|
);
|
||||||
return result ?? false;
|
return result ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +52,44 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
|
||||||
return result ?? false;
|
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
|
@override
|
||||||
Future<void> startScreenshotListening() {
|
Future<void> startScreenshotListening() {
|
||||||
return methodChannel.invokeMethod<void>(startScreenshotListeningConst);
|
return methodChannel.invokeMethod<void>(startScreenshotListeningConst);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,35 @@ abstract class NoScreenshotPlatform extends PlatformInterface {
|
||||||
/// throw `UnmimplementedError` if not implement
|
/// throw `UnmimplementedError` if not implement
|
||||||
Future<bool> toggleScreenshotWithImage() {
|
Future<bool> toggleScreenshotWithImage() {
|
||||||
throw UnimplementedError(
|
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
|
/// Return `true` if screenshot capabilities has been
|
||||||
|
|
@ -62,27 +90,31 @@ abstract class NoScreenshotPlatform extends PlatformInterface {
|
||||||
throw UnimplementedError('incrementStream has not been implemented.');
|
throw UnimplementedError('incrementStream has not been implemented.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start listening to screenshot activities
|
// Start listening to screenshot activities
|
||||||
Future<void> startScreenshotListening() {
|
Future<void> startScreenshotListening() {
|
||||||
throw UnimplementedError(
|
throw UnimplementedError(
|
||||||
'startScreenshotListening has not been implemented.');
|
'startScreenshotListening has not been implemented.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop listening to screenshot activities
|
/// Stop listening to screenshot activities
|
||||||
Future<void> stopScreenshotListening() {
|
Future<void> stopScreenshotListening() {
|
||||||
throw UnimplementedError(
|
throw UnimplementedError(
|
||||||
'stopScreenshotListening has not been implemented.');
|
'stopScreenshotListening has not been implemented.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start listening to screen recording activities
|
/// Start listening to screen recording activities
|
||||||
Future<void> startScreenRecordingListening() {
|
Future<void> startScreenRecordingListening() {
|
||||||
throw UnimplementedError(
|
throw UnimplementedError(
|
||||||
'startScreenRecordingListening has not been implemented.');
|
'startScreenRecordingListening has not been implemented.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop listening to screen recording activities
|
/// Stop listening to screen recording activities
|
||||||
Future<void> stopScreenRecordingListening() {
|
Future<void> stopScreenRecordingListening() {
|
||||||
throw UnimplementedError(
|
throw UnimplementedError(
|
||||||
'stopScreenRecordingListening has not been implemented.');
|
'stopScreenRecordingListening has not been implemented.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
210
no_screenshot/lib/no_screenshot_web.dart
Normal file
210
no_screenshot/lib/no_screenshot_web.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
no_screenshot/lib/overlay_mode.dart
Normal file
31
no_screenshot/lib/overlay_mode.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,11 +12,23 @@ class ScreenshotSnapshot {
|
||||||
final bool wasScreenshotTaken;
|
final bool wasScreenshotTaken;
|
||||||
final bool isScreenRecording;
|
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({
|
ScreenshotSnapshot({
|
||||||
required this.screenshotPath,
|
required this.screenshotPath,
|
||||||
required this.isScreenshotProtectionOn,
|
required this.isScreenshotProtectionOn,
|
||||||
required this.wasScreenshotTaken,
|
required this.wasScreenshotTaken,
|
||||||
this.isScreenRecording = false,
|
this.isScreenRecording = false,
|
||||||
|
this.timestamp = 0,
|
||||||
|
this.sourceApp = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ScreenshotSnapshot.fromMap(Map<String, dynamic> map) {
|
factory ScreenshotSnapshot.fromMap(Map<String, dynamic> map) {
|
||||||
|
|
@ -25,6 +37,8 @@ class ScreenshotSnapshot {
|
||||||
isScreenshotProtectionOn: map['is_screenshot_on'] as bool? ?? false,
|
isScreenshotProtectionOn: map['is_screenshot_on'] as bool? ?? false,
|
||||||
wasScreenshotTaken: map['was_screenshot_taken'] as bool? ?? false,
|
wasScreenshotTaken: map['was_screenshot_taken'] as bool? ?? false,
|
||||||
isScreenRecording: map['is_screen_recording'] 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,
|
'is_screenshot_on': isScreenshotProtectionOn,
|
||||||
'was_screenshot_taken': wasScreenshotTaken,
|
'was_screenshot_taken': wasScreenshotTaken,
|
||||||
'is_screen_recording': isScreenRecording,
|
'is_screen_recording': isScreenRecording,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'source_app': sourceApp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
|
|
@ -50,7 +66,9 @@ class ScreenshotSnapshot {
|
||||||
other.screenshotPath == screenshotPath &&
|
other.screenshotPath == screenshotPath &&
|
||||||
other.isScreenshotProtectionOn == isScreenshotProtectionOn &&
|
other.isScreenshotProtectionOn == isScreenshotProtectionOn &&
|
||||||
other.wasScreenshotTaken == wasScreenshotTaken &&
|
other.wasScreenshotTaken == wasScreenshotTaken &&
|
||||||
other.isScreenRecording == isScreenRecording;
|
other.isScreenRecording == isScreenRecording &&
|
||||||
|
other.timestamp == timestamp &&
|
||||||
|
other.sourceApp == sourceApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -58,6 +76,8 @@ class ScreenshotSnapshot {
|
||||||
return screenshotPath.hashCode ^
|
return screenshotPath.hashCode ^
|
||||||
isScreenshotProtectionOn.hashCode ^
|
isScreenshotProtectionOn.hashCode ^
|
||||||
wasScreenshotTaken.hashCode ^
|
wasScreenshotTaken.hashCode ^
|
||||||
isScreenRecording.hashCode;
|
isScreenRecording.hashCode ^
|
||||||
|
timestamp.hashCode ^
|
||||||
|
sourceApp.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
no_screenshot/lib/secure_navigator_observer.dart
Normal file
82
no_screenshot/lib/secure_navigator_observer.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
no_screenshot/lib/secure_widget.dart
Normal file
68
no_screenshot/lib/secure_widget.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
name: no_screenshot
|
name: no_screenshot
|
||||||
description: Flutter plugin to enable, disable, toggle or stream screenshot and screen recording activities in your application.
|
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: 0.4.0
|
version: 1.0.0
|
||||||
homepage: https://flutterplaza.com
|
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:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.10.0 <4.0.0'
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=3.38.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
plugin_platform_interface: ^2.1.8
|
plugin_platform_interface: ^2.1.8
|
||||||
|
web: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -32,3 +42,8 @@ flutter:
|
||||||
pluginClass: MacOSNoScreenshotPlugin
|
pluginClass: MacOSNoScreenshotPlugin
|
||||||
linux:
|
linux:
|
||||||
pluginClass: NoScreenshotPlugin
|
pluginClass: NoScreenshotPlugin
|
||||||
|
windows:
|
||||||
|
pluginClass: NoScreenshotPluginCApi
|
||||||
|
web:
|
||||||
|
pluginClass: NoScreenshotWeb
|
||||||
|
fileName: no_screenshot_web.dart
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:no_screenshot/constants.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_method_channel.dart';
|
||||||
|
import 'package:no_screenshot/no_screenshot_platform_interface.dart';
|
||||||
import 'package:no_screenshot/screenshot_snapshot.dart';
|
import 'package:no_screenshot/screenshot_snapshot.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -20,11 +25,11 @@ void main() {
|
||||||
const bool expected = true;
|
const bool expected = true;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == screenShotOnConst) {
|
if (methodCall.method == screenShotOnConst) {
|
||||||
return expected;
|
return expected;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.screenshotOn();
|
final result = await platform.screenshotOn();
|
||||||
expect(result, expected);
|
expect(result, expected);
|
||||||
|
|
@ -34,11 +39,11 @@ void main() {
|
||||||
const bool expected = true;
|
const bool expected = true;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == screenShotOffConst) {
|
if (methodCall.method == screenShotOffConst) {
|
||||||
return expected;
|
return expected;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.screenshotOff();
|
final result = await platform.screenshotOff();
|
||||||
expect(result, expected);
|
expect(result, expected);
|
||||||
|
|
@ -48,11 +53,11 @@ void main() {
|
||||||
const bool expected = true;
|
const bool expected = true;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == toggleScreenShotConst) {
|
if (methodCall.method == toggleScreenShotConst) {
|
||||||
return expected;
|
return expected;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.toggleScreenshot();
|
final result = await platform.toggleScreenshot();
|
||||||
expect(result, expected);
|
expect(result, expected);
|
||||||
|
|
@ -61,11 +66,11 @@ void main() {
|
||||||
test('startScreenshotListening', () async {
|
test('startScreenshotListening', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == startScreenshotListeningConst) {
|
if (methodCall.method == startScreenshotListeningConst) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
await platform.startScreenshotListening();
|
await platform.startScreenshotListening();
|
||||||
expect(true, true); // Add more specific expectations if needed
|
expect(true, true); // Add more specific expectations if needed
|
||||||
|
|
@ -74,11 +79,11 @@ void main() {
|
||||||
test('stopScreenshotListening', () async {
|
test('stopScreenshotListening', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == stopScreenshotListeningConst) {
|
if (methodCall.method == stopScreenshotListeningConst) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
await platform.stopScreenshotListening();
|
await platform.stopScreenshotListening();
|
||||||
expect(true, true); // Add more specific expectations if needed
|
expect(true, true); // Add more specific expectations if needed
|
||||||
|
|
@ -88,32 +93,122 @@ void main() {
|
||||||
const bool expected = true;
|
const bool expected = true;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == screenSetImage) {
|
if (methodCall.method == screenSetImage) {
|
||||||
return expected;
|
return expected;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.toggleScreenshotWithImage();
|
final result = await platform.toggleScreenshotWithImage();
|
||||||
expect(result, expected);
|
expect(result, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggleScreenshotWithImage returns false when channel returns null',
|
test('toggleScreenshotWithBlur', () async {
|
||||||
() async {
|
const bool expected = true;
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.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();
|
final result = await platform.toggleScreenshotWithBlur();
|
||||||
expect(result, false);
|
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 {
|
test('screenshotOn returns false when channel returns null', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.screenshotOn();
|
final result = await platform.screenshotOn();
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
|
|
@ -122,8 +217,8 @@ void main() {
|
||||||
test('screenshotOff returns false when channel returns null', () async {
|
test('screenshotOff returns false when channel returns null', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.screenshotOff();
|
final result = await platform.screenshotOff();
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
|
|
@ -132,21 +227,134 @@ void main() {
|
||||||
test('toggleScreenshot returns false when channel returns null', () async {
|
test('toggleScreenshot returns false when channel returns null', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await platform.toggleScreenshot();
|
final result = await platform.toggleScreenshot();
|
||||||
expect(result, false);
|
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 {
|
test('startScreenRecordingListening', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == startScreenRecordingListeningConst) {
|
if (methodCall.method == startScreenRecordingListeningConst) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
await platform.startScreenRecordingListening();
|
await platform.startScreenRecordingListening();
|
||||||
expect(true, true);
|
expect(true, true);
|
||||||
|
|
@ -155,15 +363,54 @@ void main() {
|
||||||
test('stopScreenRecordingListening', () async {
|
test('stopScreenRecordingListening', () async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||||
if (methodCall.method == stopScreenRecordingListeningConst) {
|
if (methodCall.method == stopScreenRecordingListeningConst) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
await platform.stopScreenRecordingListening();
|
await platform.stopScreenRecordingListening();
|
||||||
expect(true, true);
|
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', () {
|
group('ScreenshotSnapshot', () {
|
||||||
|
|
@ -283,6 +530,8 @@ void main() {
|
||||||
expect(snapshot.isScreenshotProtectionOn, false);
|
expect(snapshot.isScreenshotProtectionOn, false);
|
||||||
expect(snapshot.wasScreenshotTaken, false);
|
expect(snapshot.wasScreenshotTaken, false);
|
||||||
expect(snapshot.isScreenRecording, false);
|
expect(snapshot.isScreenRecording, false);
|
||||||
|
expect(snapshot.timestamp, 0);
|
||||||
|
expect(snapshot.sourceApp, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromMap with null values uses defaults', () {
|
test('fromMap with null values uses defaults', () {
|
||||||
|
|
@ -291,12 +540,92 @@ void main() {
|
||||||
'is_screenshot_on': null,
|
'is_screenshot_on': null,
|
||||||
'was_screenshot_taken': null,
|
'was_screenshot_taken': null,
|
||||||
'is_screen_recording': null,
|
'is_screen_recording': null,
|
||||||
|
'timestamp': null,
|
||||||
|
'source_app': null,
|
||||||
};
|
};
|
||||||
final snapshot = ScreenshotSnapshot.fromMap(map);
|
final snapshot = ScreenshotSnapshot.fromMap(map);
|
||||||
expect(snapshot.screenshotPath, '');
|
expect(snapshot.screenshotPath, '');
|
||||||
expect(snapshot.isScreenshotProtectionOn, false);
|
expect(snapshot.isScreenshotProtectionOn, false);
|
||||||
expect(snapshot.wasScreenshotTaken, false);
|
expect(snapshot.wasScreenshotTaken, false);
|
||||||
expect(snapshot.isScreenRecording, 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', () {
|
test('toString', () {
|
||||||
|
|
@ -306,8 +635,10 @@ void main() {
|
||||||
wasScreenshotTaken: true,
|
wasScreenshotTaken: true,
|
||||||
);
|
);
|
||||||
final string = snapshot.toString();
|
final string = snapshot.toString();
|
||||||
expect(string,
|
expect(
|
||||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false\n)');
|
string,
|
||||||
|
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false, \ntimestamp: 0, \nsourceApp: \n)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toString with isScreenRecording true', () {
|
test('toString with isScreenRecording true', () {
|
||||||
|
|
@ -318,8 +649,231 @@ void main() {
|
||||||
isScreenRecording: true,
|
isScreenRecording: true,
|
||||||
);
|
);
|
||||||
final string = snapshot.toString();
|
final string = snapshot.toString();
|
||||||
expect(string,
|
expect(
|
||||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true\n)');
|
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 {}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,36 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stopScreenshotListening() async {
|
Future<void> stopScreenshotListening() async {
|
||||||
return;
|
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
|
@override
|
||||||
Future<void> startScreenRecordingListening() async {
|
Future<void> startScreenRecordingListening() async {
|
||||||
return;
|
return;
|
||||||
|
|
@ -59,8 +84,10 @@ void main() {
|
||||||
|
|
||||||
group('NoScreenshotPlatform', () {
|
group('NoScreenshotPlatform', () {
|
||||||
test('default instance should be MethodChannelNoScreenshot', () {
|
test('default instance should be MethodChannelNoScreenshot', () {
|
||||||
expect(NoScreenshotPlatform.instance,
|
expect(
|
||||||
isInstanceOf<MethodChannelNoScreenshot>());
|
NoScreenshotPlatform.instance,
|
||||||
|
isInstanceOf<MethodChannelNoScreenshot>(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screenshotOff should return true when called', () async {
|
test('screenshotOff should return true when called', () async {
|
||||||
|
|
@ -75,101 +102,207 @@ void main() {
|
||||||
expect(await platform.toggleScreenshot(), isTrue);
|
expect(await platform.toggleScreenshot(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screenshotStream should not throw UnimplementedError when accessed',
|
|
||||||
() {
|
|
||||||
expect(() => platform.screenshotStream, isNot(throwsUnimplementedError));
|
|
||||||
});
|
|
||||||
test(
|
test(
|
||||||
'startScreenshotListening should not throw UnimplementedError when called',
|
'screenshotStream should not throw UnimplementedError when accessed',
|
||||||
() async {
|
() {
|
||||||
expect(platform.startScreenshotListening(), completes);
|
expect(
|
||||||
});
|
() => platform.screenshotStream,
|
||||||
|
isNot(throwsUnimplementedError),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'startScreenshotListening should not throw UnimplementedError when called',
|
||||||
|
() async {
|
||||||
|
expect(platform.startScreenshotListening(), completes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'stopScreenshotListening should not throw UnimplementedError when called',
|
'stopScreenshotListening should not throw UnimplementedError when called',
|
||||||
() async {
|
() async {
|
||||||
expect(platform.stopScreenshotListening(), completes);
|
expect(platform.stopScreenshotListening(), completes);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('toggleScreenshotWithImage should return true when called', () async {
|
test('toggleScreenshotWithImage should return true when called', () async {
|
||||||
expect(await platform.toggleScreenshotWithImage(), isTrue);
|
expect(await platform.toggleScreenshotWithImage(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError',
|
'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.toggleScreenshotWithImage(),
|
expect(
|
||||||
throwsUnimplementedError);
|
() => basePlatform.toggleScreenshotWithImage(),
|
||||||
});
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('base NoScreenshotPlatform.screenshotOff() throws UnimplementedError',
|
test('toggleScreenshotWithBlur should return true when called', () async {
|
||||||
() {
|
expect(await platform.toggleScreenshotWithBlur(), isTrue);
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
|
||||||
expect(() => basePlatform.screenshotOff(), throwsUnimplementedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('base NoScreenshotPlatform.screenshotOn() throws UnimplementedError',
|
|
||||||
() {
|
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
|
||||||
expect(() => basePlatform.screenshotOn(), throwsUnimplementedError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError',
|
'base NoScreenshotPlatform.toggleScreenshotWithBlur() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError);
|
expect(
|
||||||
});
|
() => basePlatform.toggleScreenshotWithBlur(),
|
||||||
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('base NoScreenshotPlatform.screenshotStream throws UnimplementedError',
|
test('toggleScreenshotWithColor should return true when called', () async {
|
||||||
() {
|
expect(await platform.toggleScreenshotWithColor(), isTrue);
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
|
||||||
expect(() => basePlatform.screenshotStream, throwsUnimplementedError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError',
|
'base NoScreenshotPlatform.toggleScreenshotWithColor() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.startScreenshotListening(),
|
expect(
|
||||||
throwsUnimplementedError);
|
() => basePlatform.toggleScreenshotWithColor(),
|
||||||
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('screenshotWithImage should return true when called', () async {
|
||||||
|
expect(await platform.screenshotWithImage(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError',
|
'base NoScreenshotPlatform.screenshotWithImage() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.stopScreenshotListening(),
|
expect(
|
||||||
throwsUnimplementedError);
|
() => basePlatform.screenshotWithImage(),
|
||||||
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('screenshotWithBlur should return true when called', () async {
|
||||||
|
expect(await platform.screenshotWithBlur(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'startScreenRecordingListening should not throw UnimplementedError when called',
|
'base NoScreenshotPlatform.screenshotWithBlur() throws UnimplementedError',
|
||||||
() async {
|
() {
|
||||||
expect(platform.startScreenRecordingListening(), completes);
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
|
expect(
|
||||||
|
() => basePlatform.screenshotWithBlur(),
|
||||||
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('screenshotWithColor should return true when called', () async {
|
||||||
|
expect(await platform.screenshotWithColor(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'stopScreenRecordingListening should not throw UnimplementedError when called',
|
'base NoScreenshotPlatform.screenshotWithColor() throws UnimplementedError',
|
||||||
() async {
|
() {
|
||||||
expect(platform.stopScreenRecordingListening(), completes);
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
});
|
expect(
|
||||||
|
() => basePlatform.screenshotWithColor(),
|
||||||
|
throwsUnimplementedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError',
|
'base NoScreenshotPlatform.screenshotOff() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.startScreenRecordingListening(),
|
expect(() => basePlatform.screenshotOff(), throwsUnimplementedError);
|
||||||
throwsUnimplementedError);
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError',
|
'base NoScreenshotPlatform.screenshotOn() throws UnimplementedError',
|
||||||
() {
|
() {
|
||||||
final basePlatform = BaseNoScreenshotPlatform();
|
final basePlatform = BaseNoScreenshotPlatform();
|
||||||
expect(() => basePlatform.stopScreenRecordingListening(),
|
expect(() => basePlatform.screenshotOn(), throwsUnimplementedError);
|
||||||
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ class MockNoScreenshotPlatform
|
||||||
return Future.value(true);
|
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
|
@override
|
||||||
Future<bool> toggleScreenshot() async {
|
Future<bool> toggleScreenshot() async {
|
||||||
// Mock implementation or return a fixed value
|
// Mock implementation or return a fixed value
|
||||||
|
|
@ -34,6 +44,21 @@ class MockNoScreenshotPlatform
|
||||||
@override
|
@override
|
||||||
Stream<ScreenshotSnapshot> get screenshotStream => const Stream.empty();
|
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
|
@override
|
||||||
Future<void> startScreenshotListening() {
|
Future<void> startScreenshotListening() {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|
@ -90,8 +115,10 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('screenshotStream', () async {
|
test('screenshotStream', () async {
|
||||||
expect(NoScreenshot.instance.screenshotStream,
|
expect(
|
||||||
isInstanceOf<Stream<ScreenshotSnapshot>>());
|
NoScreenshot.instance.screenshotStream,
|
||||||
|
isInstanceOf<Stream<ScreenshotSnapshot>>(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
test('startScreenshotListening', () async {
|
test('startScreenshotListening', () async {
|
||||||
expect(NoScreenshot.instance.startScreenshotListening(), completes);
|
expect(NoScreenshot.instance.startScreenshotListening(), completes);
|
||||||
|
|
@ -105,6 +132,54 @@ void main() {
|
||||||
expect(await NoScreenshot.instance.toggleScreenshotWithImage(), true);
|
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', () {
|
test('NoScreenshot equality operator', () {
|
||||||
final instance1 = NoScreenshot.instance;
|
final instance1 = NoScreenshot.instance;
|
||||||
final instance2 = NoScreenshot.instance;
|
final instance2 = NoScreenshot.instance;
|
||||||
|
|
|
||||||
131
no_screenshot/test/no_screenshot_web_test.dart
Normal file
131
no_screenshot/test/no_screenshot_web_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
240
no_screenshot/test/secure_navigator_observer_test.dart
Normal file
240
no_screenshot/test/secure_navigator_observer_test.dart
Normal 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})'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
236
no_screenshot/test/secure_widget_test.dart
Normal file
236
no_screenshot/test/secure_widget_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package gabrimatic.info.restart
|
package gabrimatic.info.restart
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
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.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
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.
|
* The main functionality is provided by the `onMethodCall` method.
|
||||||
*/
|
*/
|
||||||
class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
class RestartPlugin :
|
||||||
private lateinit var context: Context
|
FlutterPlugin,
|
||||||
|
MethodCallHandler,
|
||||||
|
ActivityAware {
|
||||||
private lateinit var channel: MethodChannel
|
private lateinit var channel: MethodChannel
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the plugin is attached to the Flutter engine.
|
* 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) {
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
context = flutterPluginBinding.applicationContext
|
|
||||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "restart")
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "restart")
|
||||||
channel.setMethodCallHandler(this)
|
channel.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
@ -41,12 +43,62 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||||
* Handles method calls from the Flutter code.
|
* Handles method calls from the Flutter code.
|
||||||
*
|
*
|
||||||
* If the method call is 'restartApp', it restarts the app and sends a successful result.
|
* 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.
|
* 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") {
|
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")
|
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 {
|
} else {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|
@ -57,23 +109,10 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||||
*
|
*
|
||||||
* It removes the handler for method calls from Flutter.
|
* 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)
|
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) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
activity = binding.activity
|
activity = binding.activity
|
||||||
}
|
}
|
||||||
|
|
@ -89,4 +128,4 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
activity = null
|
activity = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import Flutter
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class RestartAppPlugin: NSObject, FlutterPlugin {
|
|
||||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
|
||||||
let channel = FlutterMethodChannel(name: "restart", binaryMessenger: registrar.messenger())
|
|
||||||
let instance = RestartAppPlugin()
|
|
||||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
if call.method == "restartApp" {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
|
||||||
appDelegate.restartFlutterApp()
|
|
||||||
result("ok")
|
|
||||||
} else {
|
|
||||||
result(FlutterError(code: "APP_DELEGATE_NOT_FOUND", message: "Could not find AppDelegate", details: nil))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result(FlutterMethodNotImplemented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,16 +4,16 @@
|
||||||
#
|
#
|
||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'restart_app'
|
s.name = 'restart_app'
|
||||||
s.version = '0.0.1'
|
s.version = '1.7.3'
|
||||||
s.summary = 'A new Flutter project.'
|
s.summary = 'A Flutter plugin to restart the app using native APIs.'
|
||||||
s.description = <<-DESC
|
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
|
DESC
|
||||||
s.homepage = 'http://example.com'
|
s.homepage = 'https://github.com/gabrimatic/restart_app'
|
||||||
s.license = { :file => '../LICENSE' }
|
s.license = { :file => '../LICENSE' }
|
||||||
s.author = { 'Your Company' => 'email@example.com' }
|
s.author = { 'Soroush Yousefpour' => 'https://gabrimatic.info' }
|
||||||
s.source = { :path => '.' }
|
s.source = { :path => '.' }
|
||||||
s.source_files = 'Classes/**/*'
|
s.source_files = 'restart_app/Sources/restart_app/**/*'
|
||||||
s.dependency 'Flutter'
|
s.dependency 'Flutter'
|
||||||
s.platform = :ios, '11.0'
|
s.platform = :ios, '11.0'
|
||||||
|
|
||||||
|
|
|
||||||
20
restart_app/ios/restart_app/Package.swift
Normal file
20
restart_app/ios/restart_app/Package.swift
Normal 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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// `Restart` class provides a method to restart a Flutter application.
|
/// `Restart` class provides a method to restart a Flutter application.
|
||||||
|
|
@ -11,31 +9,44 @@ import 'package:flutter/services.dart';
|
||||||
class Restart {
|
class Restart {
|
||||||
/// A private constant `MethodChannel`. This channel is used to communicate with the
|
/// A private constant `MethodChannel`. This channel is used to communicate with the
|
||||||
/// platform-specific code to perform the restart operation.
|
/// 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.
|
/// Restarts the Flutter application.
|
||||||
///
|
///
|
||||||
/// The `webOrigin` parameter is optional. If it's null, the method uses the `window.origin`
|
/// The [webOrigin] parameter is optional and web-only. If null, the method
|
||||||
/// to get the site origin. This parameter should only be filled when your current origin
|
/// uses `window.origin` to reload the page. Use this when your current origin
|
||||||
/// is different than the app's origin. It defaults to null.
|
/// differs from the app's origin. Supports hash URL strategy (e.g. `'#/home'`).
|
||||||
///
|
///
|
||||||
/// The `customMessage` parameter is optional. It allows customization of the notification
|
/// The [notificationTitle] and [notificationBody] parameters are iOS-only.
|
||||||
/// message displayed on iOS when restarting the app. If not provided, a default message
|
/// On iOS, the app terminates via `exit(0)` and a local notification is shown
|
||||||
/// will be used.
|
/// 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,
|
/// The [forceKill] parameter is Android-only. When true, the old process is
|
||||||
/// and then checks the response. If the response is "ok", it returns true, signifying that
|
/// fully terminated after the new activity starts, preventing stale native
|
||||||
/// the restart operation was successful. Otherwise, it returns false.
|
/// resource locks. Defaults to false.
|
||||||
|
///
|
||||||
|
/// Returns true if the restart was initiated successfully.
|
||||||
static Future<bool> restartApp({
|
static Future<bool> restartApp({
|
||||||
String? webOrigin,
|
String? webOrigin,
|
||||||
String? notificationTitle,
|
String? notificationTitle,
|
||||||
String? notificationBody,
|
String? notificationBody,
|
||||||
|
bool forceKill = false,
|
||||||
}) async {
|
}) async {
|
||||||
final Map<String, dynamic> args = {
|
final Map<String, dynamic> args = {
|
||||||
'webOrigin': webOrigin,
|
'webOrigin': webOrigin,
|
||||||
'notificationTitle': notificationTitle,
|
'notificationTitle': notificationTitle,
|
||||||
'notificationBody': notificationBody,
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||||
// In order to *not* need this ignore, consider extracting the "web" version
|
// 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.
|
/// Handles method calls from the Flutter code.
|
||||||
///
|
///
|
||||||
/// If the method call is 'restartApp', it calls the `restart` method with the given `webOrigin`.
|
/// 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 {
|
Future<dynamic> handleMethodCall(MethodCall call) async {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case 'restartApp':
|
case 'restartApp':
|
||||||
return restart(call.arguments as String?);
|
final args = call.arguments as Map?;
|
||||||
|
final webOrigin = args?['webOrigin'] as String?;
|
||||||
|
return restart(webOrigin);
|
||||||
default:
|
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
|
/// This method replaces the current location with the given `webOrigin` (or `window.origin` if
|
||||||
/// `webOrigin` is null), effectively reloading the web app.
|
/// `webOrigin` is null), effectively reloading the web app.
|
||||||
void restart(String? webOrigin) {
|
String restart(String? webOrigin) {
|
||||||
web.window.location.replace(
|
try {
|
||||||
webOrigin ?? web.window.origin.toString(),
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
name: restart_app
|
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.
|
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
|
repository: https://github.com/gabrimatic/restart_app
|
||||||
issue_tracker: https://github.com/gabrimatic/restart_app/issues
|
issue_tracker: https://github.com/gabrimatic/restart_app/issues
|
||||||
|
topics:
|
||||||
|
- restart
|
||||||
|
- app-lifecycle
|
||||||
|
- flutter-plugin
|
||||||
|
funding:
|
||||||
|
- https://www.buymeacoffee.com/gabrimatic
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.1
|
sdk: ^3.4.0
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=3.22.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
web: ^1.0.0
|
|
||||||
plugin_platform_interface: ^2.1.8
|
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
web: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
|
@ -30,4 +37,10 @@ flutter:
|
||||||
pluginClass: RestartAppPlugin
|
pluginClass: RestartAppPlugin
|
||||||
web:
|
web:
|
||||||
pluginClass: RestartWeb
|
pluginClass: RestartWeb
|
||||||
fileName: restart_web.dart
|
fileName: restart_web.dart
|
||||||
|
linux:
|
||||||
|
pluginClass: RestartAppPlugin
|
||||||
|
macos:
|
||||||
|
pluginClass: RestartAppPlugin
|
||||||
|
windows:
|
||||||
|
pluginClass: RestartAppPluginCApi
|
||||||
|
|
|
||||||
101
restart_app/test/restart_app_test.dart
Normal file
101
restart_app/test/restart_app_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue