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
|
||||
lottie: 4f1a5a52bdf1e1c1e12fa97c96174dcb05419e19
|
||||
mutex: 84ca903a3ac863735e3228c75a212133621f680f
|
||||
no_screenshot: 9ca2a492ff12e5179583a1fa015bf0843382b866
|
||||
no_screenshot: 299c2d1d70448f23f1e9835a18585edfc9dbe6fa
|
||||
optional: 71c638891ce4f2aff35c7387727989f31f9d877d
|
||||
photo_view: a13ca2fc387a3fb1276126959e092c44d0029987
|
||||
pointycastle: bbd8569f68a7fccbdf0b92d0b44a9219c126c8dd
|
||||
qr: 7b1e9665ca976f484e7975356cf26fc7a0ccf02e
|
||||
qr_flutter: d5e7206396105d643113618290bbcc755d05f492
|
||||
restart_app: 12339f63bf8e9631e619c4f9f6b4e013fa324715
|
||||
restart_app: 66897cb67e235bab85421647bfae036acb4438cb
|
||||
x25519: ecb1d357714537bba6e276ef45f093846d4beaee
|
||||
|
|
|
|||
|
|
@ -5,13 +5,24 @@ import android.app.Application
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.database.ContentObserver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.renderscript.Allocation
|
||||
import android.renderscript.Element
|
||||
import android.renderscript.RenderScript
|
||||
import android.renderscript.ScriptIntrinsicBlur
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowManager.LayoutParams
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
|
|
@ -35,7 +46,16 @@ const val SCREENSHOT_PATH = "screenshot_path"
|
|||
const val PREF_KEY_SCREENSHOT = "is_screenshot_on"
|
||||
const val SCREENSHOT_TAKEN = "was_screenshot_taken"
|
||||
const val SET_IMAGE_CONST = "toggleScreenshotWithImage"
|
||||
const val SET_BLUR_CONST = "toggleScreenshotWithBlur"
|
||||
const val SET_COLOR_CONST = "toggleScreenshotWithColor"
|
||||
const val ENABLE_IMAGE_CONST = "screenshotWithImage"
|
||||
const val ENABLE_BLUR_CONST = "screenshotWithBlur"
|
||||
const val ENABLE_COLOR_CONST = "screenshotWithColor"
|
||||
const val PREF_KEY_IMAGE_OVERLAY = "is_image_overlay_mode_enabled"
|
||||
const val PREF_KEY_BLUR_OVERLAY = "is_blur_overlay_mode_enabled"
|
||||
const val PREF_KEY_COLOR_OVERLAY = "is_color_overlay_mode_enabled"
|
||||
const val PREF_KEY_BLUR_RADIUS = "blur_radius"
|
||||
const val PREF_KEY_COLOR_VALUE = "color_value"
|
||||
const val IS_SCREEN_RECORDING = "is_screen_recording"
|
||||
const val START_SCREEN_RECORDING_LISTENING_CONST = "startScreenRecordingListening"
|
||||
const val STOP_SCREEN_RECORDING_LISTENING_CONST = "stopScreenRecordingListening"
|
||||
|
|
@ -57,11 +77,18 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
private var lastSharedPreferencesState: String = ""
|
||||
private var hasSharedPreferencesChanged: Boolean = false
|
||||
private var isImageOverlayModeEnabled: Boolean = false
|
||||
private var isBlurOverlayModeEnabled: Boolean = false
|
||||
private var isColorOverlayModeEnabled: Boolean = false
|
||||
private var overlayImageView: ImageView? = null
|
||||
private var overlayBlurView: View? = null
|
||||
private var overlayColorView: View? = null
|
||||
private var blurRadius: Float = 30f
|
||||
private var colorValue: Int = 0xFF000000.toInt()
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var isScreenRecording: Boolean = false
|
||||
private var isRecordingListening: Boolean = false
|
||||
private var screenCaptureCallback: Any? = null
|
||||
private var screenRecordingCallback: Any? = null
|
||||
|
||||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = flutterPluginBinding.applicationContext
|
||||
|
|
@ -87,13 +114,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
activity = binding.activity
|
||||
restoreScreenshotState()
|
||||
if (isRecordingListening) {
|
||||
registerScreenCaptureCallback()
|
||||
registerScreenRecordingCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
unregisterScreenCaptureCallback()
|
||||
unregisterScreenRecordingCallbacks()
|
||||
removeImageOverlay()
|
||||
removeBlurOverlay()
|
||||
removeColorOverlay()
|
||||
activity = null
|
||||
}
|
||||
|
||||
|
|
@ -101,13 +130,15 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
activity = binding.activity
|
||||
restoreScreenshotState()
|
||||
if (isRecordingListening) {
|
||||
registerScreenCaptureCallback()
|
||||
registerScreenRecordingCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
unregisterScreenCaptureCallback()
|
||||
unregisterScreenRecordingCallbacks()
|
||||
removeImageOverlay()
|
||||
removeBlurOverlay()
|
||||
removeColorOverlay()
|
||||
activity = null
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +171,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
result.success(toggleScreenshotWithImage())
|
||||
}
|
||||
|
||||
SET_BLUR_CONST -> {
|
||||
val radius = (call.argument<Double>("radius") ?: 30.0).toFloat()
|
||||
result.success(toggleScreenshotWithBlur(radius))
|
||||
}
|
||||
|
||||
SET_COLOR_CONST -> {
|
||||
val color = call.argument<Int>("color") ?: 0xFF000000.toInt()
|
||||
result.success(toggleScreenshotWithColor(color))
|
||||
}
|
||||
|
||||
ENABLE_IMAGE_CONST -> {
|
||||
result.success(enableImageOverlay())
|
||||
}
|
||||
|
||||
ENABLE_BLUR_CONST -> {
|
||||
val radius = (call.argument<Double>("radius") ?: 30.0).toFloat()
|
||||
result.success(enableBlurOverlay(radius))
|
||||
}
|
||||
|
||||
ENABLE_COLOR_CONST -> {
|
||||
val color = call.argument<Int>("color") ?: 0xFF000000.toInt()
|
||||
result.success(enableColorOverlay(color))
|
||||
}
|
||||
|
||||
START_SCREEN_RECORDING_LISTENING_CONST -> {
|
||||
startRecordingListening()
|
||||
result.success("Recording listening started")
|
||||
|
|
@ -171,7 +226,12 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
if (act == activity && isImageOverlayModeEnabled) {
|
||||
act.window?.clearFlags(LayoutParams.FLAG_SECURE)
|
||||
showImageOverlay(act)
|
||||
|
||||
} else if (act == activity && isBlurOverlayModeEnabled) {
|
||||
act.window?.clearFlags(LayoutParams.FLAG_SECURE)
|
||||
showBlurOverlay(act)
|
||||
} else if (act == activity && isColorOverlayModeEnabled) {
|
||||
act.window?.clearFlags(LayoutParams.FLAG_SECURE)
|
||||
showColorOverlay(act)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +239,12 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
if (act == activity && isImageOverlayModeEnabled) {
|
||||
removeImageOverlay()
|
||||
act.window?.addFlags(LayoutParams.FLAG_SECURE)
|
||||
} else if (act == activity && isBlurOverlayModeEnabled) {
|
||||
removeBlurOverlay()
|
||||
act.window?.addFlags(LayoutParams.FLAG_SECURE)
|
||||
} else if (act == activity && isColorOverlayModeEnabled) {
|
||||
removeColorOverlay()
|
||||
act.window?.addFlags(LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +254,10 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
override fun onActivitySaveInstanceState(act: Activity, outState: Bundle) {
|
||||
if (act == activity && isImageOverlayModeEnabled) {
|
||||
showImageOverlay(act)
|
||||
} else if (act == activity && isBlurOverlayModeEnabled) {
|
||||
showBlurOverlay(act)
|
||||
} else if (act == activity && isColorOverlayModeEnabled) {
|
||||
showColorOverlay(act)
|
||||
}
|
||||
}
|
||||
override fun onActivityDestroyed(act: Activity) {}
|
||||
|
|
@ -238,6 +308,17 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
saveImageOverlayState(isImageOverlayModeEnabled)
|
||||
|
||||
if (isImageOverlayModeEnabled) {
|
||||
// Deactivate blur and color modes if active (mutual exclusivity)
|
||||
if (isBlurOverlayModeEnabled) {
|
||||
isBlurOverlayModeEnabled = false
|
||||
saveBlurOverlayState(false)
|
||||
removeBlurOverlay()
|
||||
}
|
||||
if (isColorOverlayModeEnabled) {
|
||||
isColorOverlayModeEnabled = false
|
||||
saveColorOverlayState(false)
|
||||
removeColorOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
} else {
|
||||
screenshotOn()
|
||||
|
|
@ -247,31 +328,301 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
return isImageOverlayModeEnabled
|
||||
}
|
||||
|
||||
private fun toggleScreenshotWithBlur(radius: Float): Boolean {
|
||||
isBlurOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_BLUR_OVERLAY, false)
|
||||
blurRadius = radius
|
||||
saveBlurOverlayState(isBlurOverlayModeEnabled)
|
||||
saveBlurRadius(radius)
|
||||
|
||||
if (isBlurOverlayModeEnabled) {
|
||||
// Deactivate image and color modes if active (mutual exclusivity)
|
||||
if (isImageOverlayModeEnabled) {
|
||||
isImageOverlayModeEnabled = false
|
||||
saveImageOverlayState(false)
|
||||
removeImageOverlay()
|
||||
}
|
||||
if (isColorOverlayModeEnabled) {
|
||||
isColorOverlayModeEnabled = false
|
||||
saveColorOverlayState(false)
|
||||
removeColorOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
} else {
|
||||
screenshotOn()
|
||||
removeBlurOverlay()
|
||||
}
|
||||
updateSharedPreferencesState("")
|
||||
return isBlurOverlayModeEnabled
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun showBlurOverlay(activity: Activity) {
|
||||
if (overlayBlurView != null) return
|
||||
val decorView = activity.window?.decorView ?: return
|
||||
val radius = blurRadius.coerceAtLeast(0.1f)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
// API 31+: GPU blur via RenderEffect on decorView — supports any radius
|
||||
activity.runOnUiThread {
|
||||
decorView.setRenderEffect(
|
||||
RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
|
||||
)
|
||||
overlayBlurView = decorView
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= 17) {
|
||||
// API 17–30: Capture bitmap, blur with RenderScript (max 25f)
|
||||
activity.runOnUiThread {
|
||||
val width = decorView.width
|
||||
val height = decorView.height
|
||||
if (width <= 0 || height <= 0) return@runOnUiThread
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
decorView.draw(canvas)
|
||||
|
||||
val rs = RenderScript.create(activity)
|
||||
val input = Allocation.createFromBitmap(rs, bitmap)
|
||||
val output = Allocation.createTyped(rs, input.type)
|
||||
val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
|
||||
script.setRadius(radius.coerceAtMost(25f))
|
||||
script.setInput(input)
|
||||
script.forEach(output)
|
||||
output.copyTo(bitmap)
|
||||
script.destroy()
|
||||
input.destroy()
|
||||
output.destroy()
|
||||
rs.destroy()
|
||||
|
||||
val imageView = ImageView(activity).apply {
|
||||
setImageBitmap(bitmap)
|
||||
scaleType = ImageView.ScaleType.FIT_XY
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
(decorView as? ViewGroup)?.addView(imageView)
|
||||
overlayBlurView = imageView
|
||||
}
|
||||
}
|
||||
// API <17: FLAG_SECURE alone prevents app switcher preview; no blur needed.
|
||||
}
|
||||
|
||||
private fun removeBlurOverlay() {
|
||||
val blurView = overlayBlurView ?: return
|
||||
val act = activity
|
||||
if (act != null) {
|
||||
act.runOnUiThread {
|
||||
if (Build.VERSION.SDK_INT >= 31 && blurView === act.window?.decorView) {
|
||||
blurView.setRenderEffect(null)
|
||||
} else {
|
||||
(blurView.parent as? ViewGroup)?.removeView(blurView)
|
||||
}
|
||||
overlayBlurView = null
|
||||
}
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
blurView.setRenderEffect(null)
|
||||
} else {
|
||||
(blurView.parent as? ViewGroup)?.removeView(blurView)
|
||||
}
|
||||
overlayBlurView = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleScreenshotWithColor(color: Int): Boolean {
|
||||
isColorOverlayModeEnabled = !preferences.getBoolean(PREF_KEY_COLOR_OVERLAY, false)
|
||||
colorValue = color
|
||||
saveColorOverlayState(isColorOverlayModeEnabled)
|
||||
saveColorValue(color)
|
||||
|
||||
if (isColorOverlayModeEnabled) {
|
||||
// Deactivate image and blur modes if active (mutual exclusivity)
|
||||
if (isImageOverlayModeEnabled) {
|
||||
isImageOverlayModeEnabled = false
|
||||
saveImageOverlayState(false)
|
||||
removeImageOverlay()
|
||||
}
|
||||
if (isBlurOverlayModeEnabled) {
|
||||
isBlurOverlayModeEnabled = false
|
||||
saveBlurOverlayState(false)
|
||||
removeBlurOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
} else {
|
||||
screenshotOn()
|
||||
removeColorOverlay()
|
||||
}
|
||||
updateSharedPreferencesState("")
|
||||
return isColorOverlayModeEnabled
|
||||
}
|
||||
|
||||
private fun enableImageOverlay(): Boolean {
|
||||
isImageOverlayModeEnabled = true
|
||||
saveImageOverlayState(true)
|
||||
if (isBlurOverlayModeEnabled) {
|
||||
isBlurOverlayModeEnabled = false
|
||||
saveBlurOverlayState(false)
|
||||
removeBlurOverlay()
|
||||
}
|
||||
if (isColorOverlayModeEnabled) {
|
||||
isColorOverlayModeEnabled = false
|
||||
saveColorOverlayState(false)
|
||||
removeColorOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
updateSharedPreferencesState("")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enableBlurOverlay(radius: Float): Boolean {
|
||||
isBlurOverlayModeEnabled = true
|
||||
blurRadius = radius
|
||||
saveBlurOverlayState(true)
|
||||
saveBlurRadius(radius)
|
||||
if (isImageOverlayModeEnabled) {
|
||||
isImageOverlayModeEnabled = false
|
||||
saveImageOverlayState(false)
|
||||
removeImageOverlay()
|
||||
}
|
||||
if (isColorOverlayModeEnabled) {
|
||||
isColorOverlayModeEnabled = false
|
||||
saveColorOverlayState(false)
|
||||
removeColorOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
updateSharedPreferencesState("")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enableColorOverlay(color: Int): Boolean {
|
||||
isColorOverlayModeEnabled = true
|
||||
colorValue = color
|
||||
saveColorOverlayState(true)
|
||||
saveColorValue(color)
|
||||
if (isImageOverlayModeEnabled) {
|
||||
isImageOverlayModeEnabled = false
|
||||
saveImageOverlayState(false)
|
||||
removeImageOverlay()
|
||||
}
|
||||
if (isBlurOverlayModeEnabled) {
|
||||
isBlurOverlayModeEnabled = false
|
||||
saveBlurOverlayState(false)
|
||||
removeBlurOverlay()
|
||||
}
|
||||
screenshotOff()
|
||||
updateSharedPreferencesState("")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showColorOverlay(activity: Activity) {
|
||||
if (overlayColorView != null) return
|
||||
val decorView = activity.window?.decorView ?: return
|
||||
activity.runOnUiThread {
|
||||
val colorView = View(activity).apply {
|
||||
setBackgroundColor(colorValue)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
(decorView as? ViewGroup)?.addView(colorView)
|
||||
overlayColorView = colorView
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeColorOverlay() {
|
||||
val colorView = overlayColorView ?: return
|
||||
val act = activity
|
||||
if (act != null) {
|
||||
act.runOnUiThread {
|
||||
(colorView.parent as? ViewGroup)?.removeView(colorView)
|
||||
overlayColorView = null
|
||||
}
|
||||
} else {
|
||||
(colorView.parent as? ViewGroup)?.removeView(colorView)
|
||||
overlayColorView = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screen Recording Detection ─────────────────────────────────────
|
||||
//
|
||||
// API 35+ (Android 15): WindowManager.addScreenRecordingCallback
|
||||
// → true start/stop detection via SCREEN_RECORDING_STATE_VISIBLE / _NOT_VISIBLE
|
||||
// API 34 (Android 14): Activity.ScreenCaptureCallback
|
||||
// → fires on screen capture (start only, no stop event)
|
||||
// API <34: graceful no-op
|
||||
|
||||
private fun startRecordingListening() {
|
||||
if (isRecordingListening) return
|
||||
isRecordingListening = true
|
||||
registerScreenCaptureCallback()
|
||||
registerScreenRecordingCallbacks()
|
||||
updateSharedPreferencesState("")
|
||||
}
|
||||
|
||||
private fun stopRecordingListening() {
|
||||
if (!isRecordingListening) return
|
||||
isRecordingListening = false
|
||||
unregisterScreenCaptureCallback()
|
||||
unregisterScreenRecordingCallbacks()
|
||||
isScreenRecording = false
|
||||
updateSharedPreferencesState("")
|
||||
}
|
||||
|
||||
private fun registerScreenRecordingCallbacks() {
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
registerScreenRecordingCallback()
|
||||
} else if (Build.VERSION.SDK_INT >= 34) {
|
||||
registerScreenCaptureCallback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterScreenRecordingCallbacks() {
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
unregisterScreenRecordingCallback()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
unregisterScreenCaptureCallback()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NewApi")
|
||||
private fun registerScreenRecordingCallback() {
|
||||
val act = activity ?: return
|
||||
if (screenRecordingCallback != null) return
|
||||
|
||||
val callback = java.util.function.Consumer<Int> { state ->
|
||||
val wasRecording = isScreenRecording
|
||||
isScreenRecording = (state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE)
|
||||
if (isScreenRecording != wasRecording) {
|
||||
updateSharedPreferencesState("", System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
val initialState = act.windowManager.addScreenRecordingCallback(act.mainExecutor, callback)
|
||||
screenRecordingCallback = callback
|
||||
// Process initial state
|
||||
val wasRecording = isScreenRecording
|
||||
isScreenRecording = (initialState == WindowManager.SCREEN_RECORDING_STATE_VISIBLE)
|
||||
if (isScreenRecording != wasRecording) {
|
||||
updateSharedPreferencesState("", System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NewApi", "UNCHECKED_CAST")
|
||||
private fun unregisterScreenRecordingCallback() {
|
||||
val act = activity ?: return
|
||||
val callback = screenRecordingCallback as? java.util.function.Consumer<Int> ?: return
|
||||
act.windowManager.removeScreenRecordingCallback(callback)
|
||||
screenRecordingCallback = null
|
||||
}
|
||||
|
||||
private fun registerScreenCaptureCallback() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val act = activity ?: return
|
||||
if (screenCaptureCallback != null) return
|
||||
|
||||
val callback = Activity.ScreenCaptureCallback {
|
||||
isScreenRecording = true
|
||||
updateSharedPreferencesState("")
|
||||
updateSharedPreferencesState("", System.currentTimeMillis())
|
||||
}
|
||||
act.registerScreenCaptureCallback(act.mainExecutor, callback)
|
||||
screenCaptureCallback = callback
|
||||
|
|
@ -279,7 +630,7 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
}
|
||||
|
||||
private fun unregisterScreenCaptureCallback() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val act = activity ?: return
|
||||
val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback ?: return
|
||||
act.unregisterScreenCaptureCallback(callback)
|
||||
|
|
@ -296,7 +647,30 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
.contains(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString())
|
||||
) {
|
||||
Log.d("ScreenshotProtection", "Screenshot detected")
|
||||
updateSharedPreferencesState(it.path ?: "")
|
||||
var timestampMs = System.currentTimeMillis()
|
||||
var displayName = ""
|
||||
try {
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
MediaStore.Images.Media.DISPLAY_NAME
|
||||
)
|
||||
context.contentResolver.query(it, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val dateIdx = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED)
|
||||
if (dateIdx >= 0) {
|
||||
val dateAdded = cursor.getLong(dateIdx)
|
||||
if (dateAdded > 0) timestampMs = dateAdded * 1000
|
||||
}
|
||||
val nameIdx = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
if (nameIdx >= 0) {
|
||||
displayName = cursor.getString(nameIdx) ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Query may fail due to permissions; fall back to defaults.
|
||||
}
|
||||
updateSharedPreferencesState(it.path ?: "", timestampMs, displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -355,14 +729,44 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
}
|
||||
}
|
||||
|
||||
private fun saveBlurOverlayState(enabled: Boolean) {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
preferences.edit().putBoolean(PREF_KEY_BLUR_OVERLAY, enabled).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBlurRadius(radius: Float) {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
preferences.edit().putFloat(PREF_KEY_BLUR_RADIUS, radius).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveColorOverlayState(enabled: Boolean) {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
preferences.edit().putBoolean(PREF_KEY_COLOR_OVERLAY, enabled).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveColorValue(color: Int) {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
preferences.edit().putInt(PREF_KEY_COLOR_VALUE, color).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreScreenshotState() {
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
val isSecure = preferences.getBoolean(PREF_KEY_SCREENSHOT, false)
|
||||
val overlayEnabled = preferences.getBoolean(PREF_KEY_IMAGE_OVERLAY, false)
|
||||
val blurEnabled = preferences.getBoolean(PREF_KEY_BLUR_OVERLAY, false)
|
||||
val colorEnabled = preferences.getBoolean(PREF_KEY_COLOR_OVERLAY, false)
|
||||
isImageOverlayModeEnabled = overlayEnabled
|
||||
isBlurOverlayModeEnabled = blurEnabled
|
||||
isColorOverlayModeEnabled = colorEnabled
|
||||
blurRadius = preferences.getFloat(PREF_KEY_BLUR_RADIUS, 30f)
|
||||
colorValue = preferences.getInt(PREF_KEY_COLOR_VALUE, 0xFF000000.toInt())
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (isImageOverlayModeEnabled || isSecure) {
|
||||
if (isImageOverlayModeEnabled || isBlurOverlayModeEnabled || isColorOverlayModeEnabled || isSecure) {
|
||||
screenshotOff()
|
||||
} else {
|
||||
screenshotOn()
|
||||
|
|
@ -371,7 +775,7 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateSharedPreferencesState(screenshotData: String) {
|
||||
private fun updateSharedPreferencesState(screenshotData: String, timestampMs: Long = 0L, sourceApp: String = "") {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
val isSecure =
|
||||
(activity?.window?.attributes?.flags ?: 0) and LayoutParams.FLAG_SECURE != 0
|
||||
|
|
@ -380,7 +784,9 @@ class NoScreenshotPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Activ
|
|||
PREF_KEY_SCREENSHOT to isSecure,
|
||||
SCREENSHOT_PATH to screenshotData,
|
||||
SCREENSHOT_TAKEN to screenshotData.isNotEmpty(),
|
||||
IS_SCREEN_RECORDING to isScreenRecording
|
||||
IS_SCREEN_RECORDING to isScreenRecording,
|
||||
"timestamp" to timestampMs,
|
||||
"source_app" to sourceApp
|
||||
)
|
||||
)
|
||||
if (lastSharedPreferencesState != jsonString) {
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
s.name = 'no_screenshot'
|
||||
s.version = '0.3.2-beta.3'
|
||||
s.version = '0.10.0'
|
||||
s.summary = 'Flutter plugin to enable, disable or toggle screenshot support in your application.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
|
|
@ -13,12 +13,12 @@ A new Flutter plugin project.
|
|||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'FlutterPlaza' => 'dev@flutterplaza.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.source_files = 'no_screenshot/Sources/no_screenshot/**/*.swift', 'Classes/**/*.{h,m}'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '10.0'
|
||||
s.platform = :ios, '13.0'
|
||||
s.resource_bundles = { 'no_screenshot_privacy' => ['no_screenshot/Sources/no_screenshot/PrivacyInfo.xcprivacy'] }
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
# Updated swift_version to a single version as an array is not supported for this attribute
|
||||
s.swift_version = "5.0"
|
||||
end
|
||||
|
|
|
|||
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 screenShotOffConst = "screenshotOff";
|
||||
const screenSetImage = "toggleScreenshotWithImage";
|
||||
const screenSetBlur = "toggleScreenshotWithBlur";
|
||||
const screenSetColor = "toggleScreenshotWithColor";
|
||||
const screenEnableImage = "screenshotWithImage";
|
||||
const screenEnableBlur = "screenshotWithBlur";
|
||||
const screenEnableColor = "screenshotWithColor";
|
||||
const toggleScreenShotConst = "toggleScreenshot";
|
||||
const startScreenshotListeningConst = 'startScreenshotListening';
|
||||
const stopScreenshotListeningConst = 'stopScreenshotListening';
|
||||
|
|
|
|||
|
|
@ -1,18 +1,81 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:no_screenshot/screenshot_snapshot.dart';
|
||||
|
||||
import 'no_screenshot_platform_interface.dart';
|
||||
|
||||
/// Callback type for screenshot and recording events.
|
||||
typedef ScreenshotEventCallback = void Function(ScreenshotSnapshot snapshot);
|
||||
|
||||
/// A class that provides a platform-agnostic way to disable screenshots.
|
||||
///
|
||||
class NoScreenshot implements NoScreenshotPlatform {
|
||||
final _instancePlatform = NoScreenshotPlatform.instance;
|
||||
NoScreenshotPlatform get _instancePlatform => NoScreenshotPlatform.instance;
|
||||
NoScreenshot._();
|
||||
|
||||
@Deprecated(
|
||||
"Using this may cause issue\nUse instance directly\ne.g: 'NoScreenshot.instance.screenshotOff()'")
|
||||
"Using this may cause issue\nUse instance directly\ne.g: 'NoScreenshot.instance.screenshotOff()'",
|
||||
)
|
||||
NoScreenshot();
|
||||
|
||||
static NoScreenshot get instance => NoScreenshot._();
|
||||
static final NoScreenshot instance = NoScreenshot._();
|
||||
|
||||
// ── Granular Callbacks (P15) ────────────────────────────────────────
|
||||
|
||||
/// Called when a screenshot is detected.
|
||||
ScreenshotEventCallback? onScreenshotDetected;
|
||||
|
||||
/// Called when screen recording starts.
|
||||
ScreenshotEventCallback? onScreenRecordingStarted;
|
||||
|
||||
/// Called when screen recording stops.
|
||||
ScreenshotEventCallback? onScreenRecordingStopped;
|
||||
|
||||
StreamSubscription<ScreenshotSnapshot>? _callbackSubscription;
|
||||
bool _wasRecording = false;
|
||||
|
||||
/// Starts dispatching events to [onScreenshotDetected],
|
||||
/// [onScreenRecordingStarted], and [onScreenRecordingStopped].
|
||||
///
|
||||
/// Listens to [screenshotStream] internally. Call [stopCallbacks] or
|
||||
/// [removeAllCallbacks] to cancel.
|
||||
void startCallbacks() {
|
||||
if (_callbackSubscription != null) return;
|
||||
_callbackSubscription = screenshotStream.listen(_dispatchCallbacks);
|
||||
}
|
||||
|
||||
/// Stops dispatching events but keeps callback assignments.
|
||||
void stopCallbacks() {
|
||||
_callbackSubscription?.cancel();
|
||||
_callbackSubscription = null;
|
||||
}
|
||||
|
||||
/// Stops dispatching and clears all callback assignments.
|
||||
void removeAllCallbacks() {
|
||||
stopCallbacks();
|
||||
onScreenshotDetected = null;
|
||||
onScreenRecordingStarted = null;
|
||||
onScreenRecordingStopped = null;
|
||||
_wasRecording = false;
|
||||
}
|
||||
|
||||
/// Whether callbacks are currently being dispatched.
|
||||
bool get hasActiveCallbacks => _callbackSubscription != null;
|
||||
|
||||
void _dispatchCallbacks(ScreenshotSnapshot snapshot) {
|
||||
if (snapshot.wasScreenshotTaken) {
|
||||
onScreenshotDetected?.call(snapshot);
|
||||
}
|
||||
if (!_wasRecording && snapshot.isScreenRecording) {
|
||||
onScreenRecordingStarted?.call(snapshot);
|
||||
}
|
||||
if (_wasRecording && !snapshot.isScreenRecording) {
|
||||
onScreenRecordingStopped?.call(snapshot);
|
||||
}
|
||||
_wasRecording = snapshot.isScreenRecording;
|
||||
}
|
||||
|
||||
// ── Platform delegation ─────────────────────────────────────────────
|
||||
|
||||
/// Return `true` if screenshot capabilities has been
|
||||
/// successfully disabled or is currently disabled and `false` otherwise.
|
||||
|
|
@ -37,6 +100,34 @@ class NoScreenshot implements NoScreenshotPlatform {
|
|||
return _instancePlatform.toggleScreenshotWithImage();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) {
|
||||
return _instancePlatform.toggleScreenshotWithBlur(blurRadius: blurRadius);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) {
|
||||
return _instancePlatform.toggleScreenshotWithColor(color: color);
|
||||
}
|
||||
|
||||
/// Always enables image overlay mode (idempotent — safe to call repeatedly).
|
||||
@override
|
||||
Future<bool> screenshotWithImage() {
|
||||
return _instancePlatform.screenshotWithImage();
|
||||
}
|
||||
|
||||
/// Always enables blur overlay mode (idempotent — safe to call repeatedly).
|
||||
@override
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) {
|
||||
return _instancePlatform.screenshotWithBlur(blurRadius: blurRadius);
|
||||
}
|
||||
|
||||
/// Always enables color overlay mode (idempotent — safe to call repeatedly).
|
||||
@override
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) {
|
||||
return _instancePlatform.screenshotWithColor(color: color);
|
||||
}
|
||||
|
||||
/// Return `true` if screenshot capabilities has been
|
||||
/// successfully toggle from it previous state and `false` if the attempt
|
||||
/// to toggle failed.
|
||||
|
|
|
|||
|
|
@ -15,16 +15,22 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
|
|||
@visibleForTesting
|
||||
final eventChannel = const EventChannel(screenshotEventChannel);
|
||||
|
||||
Stream<ScreenshotSnapshot>? _cachedStream;
|
||||
|
||||
@override
|
||||
Stream<ScreenshotSnapshot> get screenshotStream {
|
||||
return eventChannel.receiveBroadcastStream().map((event) =>
|
||||
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>));
|
||||
_cachedStream ??= eventChannel.receiveBroadcastStream().map(
|
||||
(event) =>
|
||||
ScreenshotSnapshot.fromMap(jsonDecode(event) as Map<String, dynamic>),
|
||||
);
|
||||
return _cachedStream!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshot() async {
|
||||
final result =
|
||||
await methodChannel.invokeMethod<bool>(toggleScreenShotConst);
|
||||
final result = await methodChannel.invokeMethod<bool>(
|
||||
toggleScreenShotConst,
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +52,44 @@ class MethodChannelNoScreenshot extends NoScreenshotPlatform {
|
|||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
final result = await methodChannel.invokeMethod<bool>(screenSetBlur, {
|
||||
'radius': blurRadius,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
|
||||
final result = await methodChannel.invokeMethod<bool>(screenSetColor, {
|
||||
'color': color,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithImage() async {
|
||||
final result = await methodChannel.invokeMethod<bool>(screenEnableImage);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
final result = await methodChannel.invokeMethod<bool>(screenEnableBlur, {
|
||||
'radius': blurRadius,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
|
||||
final result = await methodChannel.invokeMethod<bool>(screenEnableColor, {
|
||||
'color': color,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startScreenshotListening() {
|
||||
return methodChannel.invokeMethod<void>(startScreenshotListeningConst);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,35 @@ abstract class NoScreenshotPlatform extends PlatformInterface {
|
|||
/// throw `UnmimplementedError` if not implement
|
||||
Future<bool> toggleScreenshotWithImage() {
|
||||
throw UnimplementedError(
|
||||
'toggleScreenshotWithImage() has not been implemented.');
|
||||
'toggleScreenshotWithImage() has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) {
|
||||
throw UnimplementedError(
|
||||
'toggleScreenshotWithBlur() has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) {
|
||||
throw UnimplementedError(
|
||||
'toggleScreenshotWithColor() has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Always enables image overlay mode (idempotent — safe to call repeatedly).
|
||||
Future<bool> screenshotWithImage() {
|
||||
throw UnimplementedError('screenshotWithImage() has not been implemented.');
|
||||
}
|
||||
|
||||
/// Always enables blur overlay mode (idempotent — safe to call repeatedly).
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) {
|
||||
throw UnimplementedError('screenshotWithBlur() has not been implemented.');
|
||||
}
|
||||
|
||||
/// Always enables color overlay mode (idempotent — safe to call repeatedly).
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) {
|
||||
throw UnimplementedError('screenshotWithColor() has not been implemented.');
|
||||
}
|
||||
|
||||
/// Return `true` if screenshot capabilities has been
|
||||
|
|
@ -62,27 +90,31 @@ abstract class NoScreenshotPlatform extends PlatformInterface {
|
|||
throw UnimplementedError('incrementStream has not been implemented.');
|
||||
}
|
||||
|
||||
// Start listening to screenshot activities
|
||||
// Start listening to screenshot activities
|
||||
Future<void> startScreenshotListening() {
|
||||
throw UnimplementedError(
|
||||
'startScreenshotListening has not been implemented.');
|
||||
'startScreenshotListening has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop listening to screenshot activities
|
||||
Future<void> stopScreenshotListening() {
|
||||
throw UnimplementedError(
|
||||
'stopScreenshotListening has not been implemented.');
|
||||
'stopScreenshotListening has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Start listening to screen recording activities
|
||||
Future<void> startScreenRecordingListening() {
|
||||
throw UnimplementedError(
|
||||
'startScreenRecordingListening has not been implemented.');
|
||||
'startScreenRecordingListening has not been implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop listening to screen recording activities
|
||||
Future<void> stopScreenRecordingListening() {
|
||||
throw UnimplementedError(
|
||||
'stopScreenRecordingListening has not been implemented.');
|
||||
'stopScreenRecordingListening has not been implemented.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 isScreenRecording;
|
||||
|
||||
/// Milliseconds since epoch when the event was detected.
|
||||
///
|
||||
/// `0` means unknown (e.g. the native platform did not provide timing data).
|
||||
final int timestamp;
|
||||
|
||||
/// Human-readable name of the application that triggered the event.
|
||||
///
|
||||
/// Empty string means unknown or not applicable.
|
||||
final String sourceApp;
|
||||
|
||||
ScreenshotSnapshot({
|
||||
required this.screenshotPath,
|
||||
required this.isScreenshotProtectionOn,
|
||||
required this.wasScreenshotTaken,
|
||||
this.isScreenRecording = false,
|
||||
this.timestamp = 0,
|
||||
this.sourceApp = '',
|
||||
});
|
||||
|
||||
factory ScreenshotSnapshot.fromMap(Map<String, dynamic> map) {
|
||||
|
|
@ -25,6 +37,8 @@ class ScreenshotSnapshot {
|
|||
isScreenshotProtectionOn: map['is_screenshot_on'] as bool? ?? false,
|
||||
wasScreenshotTaken: map['was_screenshot_taken'] as bool? ?? false,
|
||||
isScreenRecording: map['is_screen_recording'] as bool? ?? false,
|
||||
timestamp: map['timestamp'] as int? ?? 0,
|
||||
sourceApp: map['source_app'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -34,12 +48,14 @@ class ScreenshotSnapshot {
|
|||
'is_screenshot_on': isScreenshotProtectionOn,
|
||||
'was_screenshot_taken': wasScreenshotTaken,
|
||||
'is_screen_recording': isScreenRecording,
|
||||
'timestamp': timestamp,
|
||||
'source_app': sourceApp,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken, \nisScreenRecording: $isScreenRecording\n)';
|
||||
return 'ScreenshotSnapshot(\nscreenshotPath: $screenshotPath, \nisScreenshotProtectionOn: $isScreenshotProtectionOn, \nwasScreenshotTaken: $wasScreenshotTaken, \nisScreenRecording: $isScreenRecording, \ntimestamp: $timestamp, \nsourceApp: $sourceApp\n)';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -50,7 +66,9 @@ class ScreenshotSnapshot {
|
|||
other.screenshotPath == screenshotPath &&
|
||||
other.isScreenshotProtectionOn == isScreenshotProtectionOn &&
|
||||
other.wasScreenshotTaken == wasScreenshotTaken &&
|
||||
other.isScreenRecording == isScreenRecording;
|
||||
other.isScreenRecording == isScreenRecording &&
|
||||
other.timestamp == timestamp &&
|
||||
other.sourceApp == sourceApp;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -58,6 +76,8 @@ class ScreenshotSnapshot {
|
|||
return screenshotPath.hashCode ^
|
||||
isScreenshotProtectionOn.hashCode ^
|
||||
wasScreenshotTaken.hashCode ^
|
||||
isScreenRecording.hashCode;
|
||||
isScreenRecording.hashCode ^
|
||||
timestamp.hashCode ^
|
||||
sourceApp.hashCode;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
description: Flutter plugin to enable, disable, toggle or stream screenshot and screen recording activities in your application.
|
||||
version: 0.4.0
|
||||
description: Flutter plugin to prevent screenshots, detect screen recording, and show blur/color/image overlays in the app switcher on Android, iOS, macOS, Linux, Windows, and Web.
|
||||
version: 1.0.0
|
||||
homepage: https://flutterplaza.com
|
||||
repository: https://github.com/FlutterPlaza/no_screenshot/releases/tag/v0.4.0
|
||||
repository: https://github.com/FlutterPlaza/no_screenshot
|
||||
|
||||
topics:
|
||||
- screenshot
|
||||
- security
|
||||
- privacy
|
||||
- screen-capture
|
||||
- app-switcher
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: ">=1.17.0"
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
flutter: ">=3.38.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.1.8
|
||||
web: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -32,3 +42,8 @@ flutter:
|
|||
pluginClass: MacOSNoScreenshotPlugin
|
||||
linux:
|
||||
pluginClass: NoScreenshotPlugin
|
||||
windows:
|
||||
pluginClass: NoScreenshotPluginCApi
|
||||
web:
|
||||
pluginClass: NoScreenshotWeb
|
||||
fileName: no_screenshot_web.dart
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:no_screenshot/constants.dart';
|
||||
import 'package:no_screenshot/no_screenshot.dart';
|
||||
import 'package:no_screenshot/no_screenshot_method_channel.dart';
|
||||
import 'package:no_screenshot/no_screenshot_platform_interface.dart';
|
||||
import 'package:no_screenshot/screenshot_snapshot.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -20,11 +25,11 @@ void main() {
|
|||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenShotOnConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == screenShotOnConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotOn();
|
||||
expect(result, expected);
|
||||
|
|
@ -34,11 +39,11 @@ void main() {
|
|||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenShotOffConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == screenShotOffConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotOff();
|
||||
expect(result, expected);
|
||||
|
|
@ -48,11 +53,11 @@ void main() {
|
|||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == toggleScreenShotConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == toggleScreenShotConst) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshot();
|
||||
expect(result, expected);
|
||||
|
|
@ -61,11 +66,11 @@ void main() {
|
|||
test('startScreenshotListening', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == startScreenshotListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == startScreenshotListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await platform.startScreenshotListening();
|
||||
expect(true, true); // Add more specific expectations if needed
|
||||
|
|
@ -74,11 +79,11 @@ void main() {
|
|||
test('stopScreenshotListening', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == stopScreenshotListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == stopScreenshotListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await platform.stopScreenshotListening();
|
||||
expect(true, true); // Add more specific expectations if needed
|
||||
|
|
@ -88,32 +93,122 @@ void main() {
|
|||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenSetImage) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == screenSetImage) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithImage();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithImage returns false when channel returns null',
|
||||
() async {
|
||||
test('toggleScreenshotWithBlur', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == screenSetBlur) {
|
||||
expect(methodCall.arguments, {'radius': 30.0});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithImage();
|
||||
expect(result, false);
|
||||
final result = await platform.toggleScreenshotWithBlur();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithBlur with custom radius', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenSetBlur) {
|
||||
expect(methodCall.arguments, {'radius': 50.0});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithBlur(blurRadius: 50.0);
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test(
|
||||
'toggleScreenshotWithBlur returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithBlur();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('toggleScreenshotWithColor', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenSetColor) {
|
||||
expect(methodCall.arguments, {'color': 0xFF000000});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithColor();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithColor with custom color', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenSetColor) {
|
||||
expect(methodCall.arguments, {'color': 0xFFFF0000});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithColor(
|
||||
color: 0xFFFF0000,
|
||||
);
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test(
|
||||
'toggleScreenshotWithColor returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithColor();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'toggleScreenshotWithImage returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshotWithImage();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotOn returns false when channel returns null', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotOn();
|
||||
expect(result, false);
|
||||
|
|
@ -122,8 +217,8 @@ void main() {
|
|||
test('screenshotOff returns false when channel returns null', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotOff();
|
||||
expect(result, false);
|
||||
|
|
@ -132,21 +227,134 @@ void main() {
|
|||
test('toggleScreenshot returns false when channel returns null', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.toggleScreenshot();
|
||||
expect(result, false);
|
||||
});
|
||||
|
||||
test('screenshotWithImage', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenEnableImage) {
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithImage();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test(
|
||||
'screenshotWithImage returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithImage();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotWithBlur', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenEnableBlur) {
|
||||
expect(methodCall.arguments, {'radius': 30.0});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithBlur();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test('screenshotWithBlur with custom radius', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenEnableBlur) {
|
||||
expect(methodCall.arguments, {'radius': 50.0});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithBlur(blurRadius: 50.0);
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test(
|
||||
'screenshotWithBlur returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithBlur();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotWithColor', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenEnableColor) {
|
||||
expect(methodCall.arguments, {'color': 0xFF000000});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithColor();
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test('screenshotWithColor with custom color', () async {
|
||||
const bool expected = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == screenEnableColor) {
|
||||
expect(methodCall.arguments, {'color': 0xFFFF0000});
|
||||
return expected;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithColor(color: 0xFFFF0000);
|
||||
expect(result, expected);
|
||||
});
|
||||
|
||||
test(
|
||||
'screenshotWithColor returns false when channel returns null',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await platform.screenshotWithColor();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('startScreenRecordingListening', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == startScreenRecordingListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == startScreenRecordingListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await platform.startScreenRecordingListening();
|
||||
expect(true, true);
|
||||
|
|
@ -155,15 +363,54 @@ void main() {
|
|||
test('stopScreenRecordingListening', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
|
||||
if (methodCall.method == stopScreenRecordingListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (methodCall.method == stopScreenRecordingListeningConst) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await platform.stopScreenRecordingListening();
|
||||
expect(true, true);
|
||||
});
|
||||
|
||||
test(
|
||||
'screenshotStream returns a stream that emits ScreenshotSnapshot',
|
||||
() async {
|
||||
final snapshotMap = {
|
||||
'screenshot_path': '/test/path',
|
||||
'is_screenshot_on': true,
|
||||
'was_screenshot_taken': true,
|
||||
'is_screen_recording': false,
|
||||
'timestamp': 0,
|
||||
'source_app': '',
|
||||
};
|
||||
final encoded = jsonEncode(snapshotMap);
|
||||
|
||||
// Mock the event channel by handling the underlying method channel
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockStreamHandler(
|
||||
platform.eventChannel,
|
||||
MockStreamHandler.inline(
|
||||
onListen: (arguments, events) {
|
||||
events.success(encoded);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final stream = platform.screenshotStream;
|
||||
final snapshot = await stream.first;
|
||||
|
||||
expect(snapshot.screenshotPath, '/test/path');
|
||||
expect(snapshot.isScreenshotProtectionOn, true);
|
||||
expect(snapshot.wasScreenshotTaken, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotStream caches and returns the same stream instance', () {
|
||||
final stream1 = platform.screenshotStream;
|
||||
final stream2 = platform.screenshotStream;
|
||||
expect(identical(stream1, stream2), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('ScreenshotSnapshot', () {
|
||||
|
|
@ -283,6 +530,8 @@ void main() {
|
|||
expect(snapshot.isScreenshotProtectionOn, false);
|
||||
expect(snapshot.wasScreenshotTaken, false);
|
||||
expect(snapshot.isScreenRecording, false);
|
||||
expect(snapshot.timestamp, 0);
|
||||
expect(snapshot.sourceApp, '');
|
||||
});
|
||||
|
||||
test('fromMap with null values uses defaults', () {
|
||||
|
|
@ -291,12 +540,92 @@ void main() {
|
|||
'is_screenshot_on': null,
|
||||
'was_screenshot_taken': null,
|
||||
'is_screen_recording': null,
|
||||
'timestamp': null,
|
||||
'source_app': null,
|
||||
};
|
||||
final snapshot = ScreenshotSnapshot.fromMap(map);
|
||||
expect(snapshot.screenshotPath, '');
|
||||
expect(snapshot.isScreenshotProtectionOn, false);
|
||||
expect(snapshot.wasScreenshotTaken, false);
|
||||
expect(snapshot.isScreenRecording, false);
|
||||
expect(snapshot.timestamp, 0);
|
||||
expect(snapshot.sourceApp, '');
|
||||
});
|
||||
|
||||
test('fromMap with metadata', () {
|
||||
final map = {
|
||||
'screenshot_path': '/example/path',
|
||||
'is_screenshot_on': true,
|
||||
'was_screenshot_taken': true,
|
||||
'is_screen_recording': false,
|
||||
'timestamp': 1700000000000,
|
||||
'source_app': 'screencaptureui',
|
||||
};
|
||||
final snapshot = ScreenshotSnapshot.fromMap(map);
|
||||
expect(snapshot.screenshotPath, '/example/path');
|
||||
expect(snapshot.isScreenshotProtectionOn, true);
|
||||
expect(snapshot.wasScreenshotTaken, true);
|
||||
expect(snapshot.timestamp, 1700000000000);
|
||||
expect(snapshot.sourceApp, 'screencaptureui');
|
||||
});
|
||||
|
||||
test('fromMap without metadata defaults timestamp and sourceApp', () {
|
||||
final map = {
|
||||
'screenshot_path': '/example/path',
|
||||
'is_screenshot_on': true,
|
||||
'was_screenshot_taken': true,
|
||||
};
|
||||
final snapshot = ScreenshotSnapshot.fromMap(map);
|
||||
expect(snapshot.timestamp, 0);
|
||||
expect(snapshot.sourceApp, '');
|
||||
});
|
||||
|
||||
test('toMap includes metadata', () {
|
||||
final snapshot = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000000,
|
||||
sourceApp: 'GNOME Screenshot',
|
||||
);
|
||||
final map = snapshot.toMap();
|
||||
expect(map['timestamp'], 1700000000000);
|
||||
expect(map['source_app'], 'GNOME Screenshot');
|
||||
});
|
||||
|
||||
test('equality with metadata', () {
|
||||
final snapshot1 = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000000,
|
||||
sourceApp: 'screencaptureui',
|
||||
);
|
||||
final snapshot2 = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000000,
|
||||
sourceApp: 'screencaptureui',
|
||||
);
|
||||
final snapshot3 = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000001,
|
||||
sourceApp: 'screencaptureui',
|
||||
);
|
||||
final snapshot4 = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000000,
|
||||
sourceApp: 'different_app',
|
||||
);
|
||||
|
||||
expect(snapshot1 == snapshot2, true);
|
||||
expect(snapshot1 == snapshot3, false);
|
||||
expect(snapshot1 == snapshot4, false);
|
||||
});
|
||||
|
||||
test('toString', () {
|
||||
|
|
@ -306,8 +635,10 @@ void main() {
|
|||
wasScreenshotTaken: true,
|
||||
);
|
||||
final string = snapshot.toString();
|
||||
expect(string,
|
||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false\n)');
|
||||
expect(
|
||||
string,
|
||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: false, \ntimestamp: 0, \nsourceApp: \n)',
|
||||
);
|
||||
});
|
||||
|
||||
test('toString with isScreenRecording true', () {
|
||||
|
|
@ -318,8 +649,231 @@ void main() {
|
|||
isScreenRecording: true,
|
||||
);
|
||||
final string = snapshot.toString();
|
||||
expect(string,
|
||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true\n)');
|
||||
expect(
|
||||
string,
|
||||
'ScreenshotSnapshot(\nscreenshotPath: /example/path, \nisScreenshotProtectionOn: true, \nwasScreenshotTaken: true, \nisScreenRecording: true, \ntimestamp: 0, \nsourceApp: \n)',
|
||||
);
|
||||
});
|
||||
|
||||
test('toString with metadata', () {
|
||||
final snapshot = ScreenshotSnapshot(
|
||||
screenshotPath: '/example/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
timestamp: 1700000000000,
|
||||
sourceApp: 'screencaptureui',
|
||||
);
|
||||
final string = snapshot.toString();
|
||||
expect(string, contains('timestamp: 1700000000000'));
|
||||
expect(string, contains('sourceApp: screencaptureui'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Granular Callbacks (P15)', () {
|
||||
late StreamController<ScreenshotSnapshot> controller;
|
||||
late _MockNoScreenshotPlatform mockPlatform;
|
||||
late NoScreenshot noScreenshot;
|
||||
|
||||
setUp(() {
|
||||
controller = StreamController<ScreenshotSnapshot>.broadcast();
|
||||
mockPlatform = _MockNoScreenshotPlatform(controller.stream);
|
||||
NoScreenshotPlatform.instance = mockPlatform;
|
||||
// Create a fresh instance for each test to avoid shared state.
|
||||
noScreenshot = NoScreenshot.instance;
|
||||
noScreenshot.removeAllCallbacks();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
noScreenshot.removeAllCallbacks();
|
||||
controller.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'onScreenshotDetected fires when wasScreenshotTaken is true',
|
||||
() async {
|
||||
final detected = <ScreenshotSnapshot>[];
|
||||
noScreenshot.onScreenshotDetected = detected.add;
|
||||
noScreenshot.startCallbacks();
|
||||
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(detected, hasLength(1));
|
||||
expect(detected.first.wasScreenshotTaken, true);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'onScreenshotDetected does NOT fire when wasScreenshotTaken is false',
|
||||
() async {
|
||||
final detected = <ScreenshotSnapshot>[];
|
||||
noScreenshot.onScreenshotDetected = detected.add;
|
||||
noScreenshot.startCallbacks();
|
||||
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: false,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(detected, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('onScreenRecordingStarted fires on false→true transition', () async {
|
||||
final started = <ScreenshotSnapshot>[];
|
||||
noScreenshot.onScreenRecordingStarted = started.add;
|
||||
noScreenshot.startCallbacks();
|
||||
|
||||
// Initial state: not recording → recording starts
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: false,
|
||||
isScreenRecording: true,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(started, hasLength(1));
|
||||
expect(started.first.isScreenRecording, true);
|
||||
});
|
||||
|
||||
test('onScreenRecordingStopped fires on true→false transition', () async {
|
||||
final stopped = <ScreenshotSnapshot>[];
|
||||
noScreenshot.onScreenRecordingStopped = stopped.add;
|
||||
noScreenshot.startCallbacks();
|
||||
|
||||
// First: recording starts
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: false,
|
||||
isScreenRecording: true,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
// Then: recording stops
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: false,
|
||||
isScreenRecording: false,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(stopped, hasLength(1));
|
||||
expect(stopped.first.isScreenRecording, false);
|
||||
});
|
||||
|
||||
test(
|
||||
'removeAllCallbacks clears all callbacks and stops subscription',
|
||||
() async {
|
||||
final detected = <ScreenshotSnapshot>[];
|
||||
noScreenshot.onScreenshotDetected = detected.add;
|
||||
noScreenshot.startCallbacks();
|
||||
expect(noScreenshot.hasActiveCallbacks, true);
|
||||
|
||||
noScreenshot.removeAllCallbacks();
|
||||
expect(noScreenshot.hasActiveCallbacks, false);
|
||||
expect(noScreenshot.onScreenshotDetected, isNull);
|
||||
expect(noScreenshot.onScreenRecordingStarted, isNull);
|
||||
expect(noScreenshot.onScreenRecordingStopped, isNull);
|
||||
|
||||
// Events after removal should not fire
|
||||
controller.add(
|
||||
ScreenshotSnapshot(
|
||||
screenshotPath: '/path',
|
||||
isScreenshotProtectionOn: true,
|
||||
wasScreenshotTaken: true,
|
||||
),
|
||||
);
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(detected, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('hasActiveCallbacks reflects subscription state', () {
|
||||
expect(noScreenshot.hasActiveCallbacks, false);
|
||||
|
||||
noScreenshot.onScreenshotDetected = (_) {};
|
||||
noScreenshot.startCallbacks();
|
||||
expect(noScreenshot.hasActiveCallbacks, true);
|
||||
|
||||
noScreenshot.stopCallbacks();
|
||||
expect(noScreenshot.hasActiveCallbacks, false);
|
||||
});
|
||||
|
||||
test('startCallbacks is idempotent', () {
|
||||
noScreenshot.onScreenshotDetected = (_) {};
|
||||
noScreenshot.startCallbacks();
|
||||
noScreenshot.startCallbacks(); // second call should be no-op
|
||||
expect(noScreenshot.hasActiveCallbacks, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockNoScreenshotPlatform extends NoScreenshotPlatform {
|
||||
final Stream<ScreenshotSnapshot> _stream;
|
||||
|
||||
_MockNoScreenshotPlatform(this._stream);
|
||||
|
||||
@override
|
||||
Stream<ScreenshotSnapshot> get screenshotStream => _stream;
|
||||
|
||||
@override
|
||||
Future<bool> screenshotOff() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> screenshotOn() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshot() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithImage() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async =>
|
||||
true;
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async =>
|
||||
true;
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithImage() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) async => true;
|
||||
|
||||
@override
|
||||
Future<void> startScreenshotListening() async {}
|
||||
|
||||
@override
|
||||
Future<void> stopScreenshotListening() async {}
|
||||
|
||||
@override
|
||||
Future<void> startScreenRecordingListening() async {}
|
||||
|
||||
@override
|
||||
Future<void> stopScreenRecordingListening() async {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,36 @@ class MockNoScreenshotPlatform extends NoScreenshotPlatform {
|
|||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopScreenshotListening() async {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithImage() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startScreenRecordingListening() async {
|
||||
return;
|
||||
|
|
@ -59,8 +84,10 @@ void main() {
|
|||
|
||||
group('NoScreenshotPlatform', () {
|
||||
test('default instance should be MethodChannelNoScreenshot', () {
|
||||
expect(NoScreenshotPlatform.instance,
|
||||
isInstanceOf<MethodChannelNoScreenshot>());
|
||||
expect(
|
||||
NoScreenshotPlatform.instance,
|
||||
isInstanceOf<MethodChannelNoScreenshot>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('screenshotOff should return true when called', () async {
|
||||
|
|
@ -75,101 +102,207 @@ void main() {
|
|||
expect(await platform.toggleScreenshot(), isTrue);
|
||||
});
|
||||
|
||||
test('screenshotStream should not throw UnimplementedError when accessed',
|
||||
() {
|
||||
expect(() => platform.screenshotStream, isNot(throwsUnimplementedError));
|
||||
});
|
||||
test(
|
||||
'startScreenshotListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.startScreenshotListening(), completes);
|
||||
});
|
||||
'screenshotStream should not throw UnimplementedError when accessed',
|
||||
() {
|
||||
expect(
|
||||
() => platform.screenshotStream,
|
||||
isNot(throwsUnimplementedError),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'startScreenshotListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.startScreenshotListening(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'stopScreenshotListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.stopScreenshotListening(), completes);
|
||||
});
|
||||
'stopScreenshotListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.stopScreenshotListening(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test('toggleScreenshotWithImage should return true when called', () async {
|
||||
expect(await platform.toggleScreenshotWithImage(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.toggleScreenshotWithImage(),
|
||||
throwsUnimplementedError);
|
||||
});
|
||||
'base NoScreenshotPlatform.toggleScreenshotWithImage() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.toggleScreenshotWithImage(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('base NoScreenshotPlatform.screenshotOff() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotOff(), throwsUnimplementedError);
|
||||
});
|
||||
|
||||
test('base NoScreenshotPlatform.screenshotOn() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotOn(), throwsUnimplementedError);
|
||||
test('toggleScreenshotWithBlur should return true when called', () async {
|
||||
expect(await platform.toggleScreenshotWithBlur(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError);
|
||||
});
|
||||
'base NoScreenshotPlatform.toggleScreenshotWithBlur() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.toggleScreenshotWithBlur(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('base NoScreenshotPlatform.screenshotStream throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotStream, throwsUnimplementedError);
|
||||
test('toggleScreenshotWithColor should return true when called', () async {
|
||||
expect(await platform.toggleScreenshotWithColor(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.startScreenshotListening(),
|
||||
throwsUnimplementedError);
|
||||
'base NoScreenshotPlatform.toggleScreenshotWithColor() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.toggleScreenshotWithColor(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotWithImage should return true when called', () async {
|
||||
expect(await platform.screenshotWithImage(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.stopScreenshotListening(),
|
||||
throwsUnimplementedError);
|
||||
'base NoScreenshotPlatform.screenshotWithImage() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.screenshotWithImage(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotWithBlur should return true when called', () async {
|
||||
expect(await platform.screenshotWithBlur(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'startScreenRecordingListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.startScreenRecordingListening(), completes);
|
||||
'base NoScreenshotPlatform.screenshotWithBlur() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.screenshotWithBlur(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('screenshotWithColor should return true when called', () async {
|
||||
expect(await platform.screenshotWithColor(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'stopScreenRecordingListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.stopScreenRecordingListening(), completes);
|
||||
});
|
||||
'base NoScreenshotPlatform.screenshotWithColor() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.screenshotWithColor(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.startScreenRecordingListening(),
|
||||
throwsUnimplementedError);
|
||||
});
|
||||
'base NoScreenshotPlatform.screenshotOff() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotOff(), throwsUnimplementedError);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.stopScreenRecordingListening(),
|
||||
throwsUnimplementedError);
|
||||
});
|
||||
'base NoScreenshotPlatform.screenshotOn() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotOn(), throwsUnimplementedError);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.toggleScreenshot() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.toggleScreenshot(), throwsUnimplementedError);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.screenshotStream throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(() => basePlatform.screenshotStream, throwsUnimplementedError);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.startScreenshotListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.startScreenshotListening(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.stopScreenshotListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.stopScreenshotListening(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'startScreenRecordingListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.startScreenRecordingListening(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'stopScreenRecordingListening should not throw UnimplementedError when called',
|
||||
() async {
|
||||
expect(platform.stopScreenRecordingListening(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.startScreenRecordingListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.startScreenRecordingListening(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'base NoScreenshotPlatform.stopScreenRecordingListening() throws UnimplementedError',
|
||||
() {
|
||||
final basePlatform = BaseNoScreenshotPlatform();
|
||||
expect(
|
||||
() => basePlatform.stopScreenRecordingListening(),
|
||||
throwsUnimplementedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ class MockNoScreenshotPlatform
|
|||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshotWithColor({int color = 0xFF000000}) async {
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> toggleScreenshot() async {
|
||||
// Mock implementation or return a fixed value
|
||||
|
|
@ -34,6 +44,21 @@ class MockNoScreenshotPlatform
|
|||
@override
|
||||
Stream<ScreenshotSnapshot> get screenshotStream => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithImage() async {
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithBlur({double blurRadius = 30.0}) async {
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> screenshotWithColor({int color = 0xFF000000}) async {
|
||||
return Future.value(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startScreenshotListening() {
|
||||
return Future.value();
|
||||
|
|
@ -90,8 +115,10 @@ void main() {
|
|||
});
|
||||
|
||||
test('screenshotStream', () async {
|
||||
expect(NoScreenshot.instance.screenshotStream,
|
||||
isInstanceOf<Stream<ScreenshotSnapshot>>());
|
||||
expect(
|
||||
NoScreenshot.instance.screenshotStream,
|
||||
isInstanceOf<Stream<ScreenshotSnapshot>>(),
|
||||
);
|
||||
});
|
||||
test('startScreenshotListening', () async {
|
||||
expect(NoScreenshot.instance.startScreenshotListening(), completes);
|
||||
|
|
@ -105,6 +132,54 @@ void main() {
|
|||
expect(await NoScreenshot.instance.toggleScreenshotWithImage(), true);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithBlur', () async {
|
||||
expect(await NoScreenshot.instance.toggleScreenshotWithBlur(), true);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithBlur with custom radius', () async {
|
||||
expect(
|
||||
await NoScreenshot.instance.toggleScreenshotWithBlur(blurRadius: 50.0),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithColor', () async {
|
||||
expect(await NoScreenshot.instance.toggleScreenshotWithColor(), true);
|
||||
});
|
||||
|
||||
test('toggleScreenshotWithColor with custom color', () async {
|
||||
expect(
|
||||
await NoScreenshot.instance.toggleScreenshotWithColor(color: 0xFFFF0000),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('screenshotWithImage', () async {
|
||||
expect(await NoScreenshot.instance.screenshotWithImage(), true);
|
||||
});
|
||||
|
||||
test('screenshotWithBlur', () async {
|
||||
expect(await NoScreenshot.instance.screenshotWithBlur(), true);
|
||||
});
|
||||
|
||||
test('screenshotWithBlur with custom radius', () async {
|
||||
expect(
|
||||
await NoScreenshot.instance.screenshotWithBlur(blurRadius: 50.0),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('screenshotWithColor', () async {
|
||||
expect(await NoScreenshot.instance.screenshotWithColor(), true);
|
||||
});
|
||||
|
||||
test('screenshotWithColor with custom color', () async {
|
||||
expect(
|
||||
await NoScreenshot.instance.screenshotWithColor(color: 0xFFFF0000),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('NoScreenshot equality operator', () {
|
||||
final instance1 = NoScreenshot.instance;
|
||||
final instance2 = NoScreenshot.instance;
|
||||
|
|
|
|||
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
|
||||
|
||||
Copyright (c) 2021 Hossein Yousefpour
|
||||
Copyright (c) 2021-2026 Soroush Yousefpour
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package gabrimatic.info.restart
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.NonNull
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
|
|
@ -20,19 +22,19 @@ import io.flutter.plugin.common.MethodChannel.Result
|
|||
*
|
||||
* The main functionality is provided by the `onMethodCall` method.
|
||||
*/
|
||||
class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||
private lateinit var context: Context
|
||||
class RestartPlugin :
|
||||
FlutterPlugin,
|
||||
MethodCallHandler,
|
||||
ActivityAware {
|
||||
private lateinit var channel: MethodChannel
|
||||
private var activity: Activity? = null
|
||||
|
||||
/**
|
||||
* Called when the plugin is attached to the Flutter engine.
|
||||
*
|
||||
* It initializes the `context` with the application context and
|
||||
* sets this plugin instance as the handler for method calls from Flutter.
|
||||
* Sets this plugin instance as the handler for method calls from Flutter.
|
||||
*/
|
||||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = flutterPluginBinding.applicationContext
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "restart")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
|
@ -41,12 +43,62 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
|||
* Handles method calls from the Flutter code.
|
||||
*
|
||||
* If the method call is 'restartApp', it restarts the app and sends a successful result.
|
||||
* The result is sent before the restart is triggered so the Flutter engine has time to
|
||||
* deliver it across the platform channel. Without this delay, finishAffinity() can tear
|
||||
* down the engine mid-delivery, causing a FlutterJNI detached error.
|
||||
*
|
||||
* When forceKill is true, the process is terminated immediately after the new activity
|
||||
* launches, ensuring a clean cold restart with no stale native resources. A longer delay
|
||||
* gives the new activity time to initialize before the current process exits.
|
||||
*
|
||||
* For any other method call, it sends a 'not implemented' result.
|
||||
*/
|
||||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
|
||||
override fun onMethodCall(
|
||||
call: MethodCall,
|
||||
result: Result,
|
||||
) {
|
||||
if (call.method == "restartApp") {
|
||||
restartApp()
|
||||
val forceKill = call.argument<Boolean>("forceKill") ?: false
|
||||
val currentActivity = activity
|
||||
|
||||
if (currentActivity == null) {
|
||||
result.error("RESTART_FAILED", "No activity available", null)
|
||||
return
|
||||
}
|
||||
|
||||
val pm = currentActivity.packageManager
|
||||
val pkg = currentActivity.packageName
|
||||
|
||||
// Try the standard launcher intent first, then fall back to the leanback
|
||||
// launcher used by Android TV and Fire TV devices (API 21+).
|
||||
var intent = pm.getLaunchIntentForPackage(pkg)
|
||||
if (intent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
intent = pm.getLeanbackLaunchIntentForPackage(pkg)
|
||||
}
|
||||
|
||||
if (intent == null) {
|
||||
result.error("RESTART_FAILED", "No launchable activity found for $pkg", null)
|
||||
return
|
||||
}
|
||||
|
||||
result.success("ok")
|
||||
|
||||
// Delay the destructive operations so the platform channel result can be delivered
|
||||
// to the Dart side before the Flutter engine is torn down.
|
||||
val delay = if (forceKill) 300L else 100L
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
try {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
currentActivity.startActivity(intent)
|
||||
if (forceKill) {
|
||||
Runtime.getRuntime().exit(0)
|
||||
} else {
|
||||
currentActivity.finishAffinity()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RestartPlugin", "Restart failed: ${e.message}", e)
|
||||
}
|
||||
}, delay)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
|
|
@ -57,23 +109,10 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
|||
*
|
||||
* It removes the handler for method calls from Flutter.
|
||||
*/
|
||||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the application.
|
||||
*/
|
||||
private fun restartApp() {
|
||||
activity?.let { currentActivity ->
|
||||
val intent =
|
||||
currentActivity.packageManager.getLaunchIntentForPackage(currentActivity.packageName)
|
||||
intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
currentActivity.startActivity(intent)
|
||||
currentActivity.finishAffinity()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
|
@ -89,4 +128,4 @@ class RestartPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
|||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
s.name = 'restart_app'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter project.'
|
||||
s.version = '1.7.3'
|
||||
s.summary = 'A Flutter plugin to restart the app using native APIs.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter project.
|
||||
A Flutter plugin that helps you to restart the whole Flutter app with a single function call by using native APIs.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.homepage = 'https://github.com/gabrimatic/restart_app'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.author = { 'Soroush Yousefpour' => 'https://gabrimatic.info' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.source_files = 'restart_app/Sources/restart_app/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '11.0'
|
||||
|
||||
|
|
|
|||
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';
|
||||
|
||||
/// `Restart` class provides a method to restart a Flutter application.
|
||||
|
|
@ -11,31 +9,44 @@ import 'package:flutter/services.dart';
|
|||
class Restart {
|
||||
/// A private constant `MethodChannel`. This channel is used to communicate with the
|
||||
/// platform-specific code to perform the restart operation.
|
||||
static const MethodChannel _channel = const MethodChannel('restart');
|
||||
static const MethodChannel _channel = MethodChannel('restart');
|
||||
|
||||
/// Restarts the Flutter application.
|
||||
///
|
||||
/// The `webOrigin` parameter is optional. If it's null, the method uses the `window.origin`
|
||||
/// to get the site origin. This parameter should only be filled when your current origin
|
||||
/// is different than the app's origin. It defaults to null.
|
||||
/// The [webOrigin] parameter is optional and web-only. If null, the method
|
||||
/// uses `window.origin` to reload the page. Use this when your current origin
|
||||
/// differs from the app's origin. Supports hash URL strategy (e.g. `'#/home'`).
|
||||
///
|
||||
/// The `customMessage` parameter is optional. It allows customization of the notification
|
||||
/// message displayed on iOS when restarting the app. If not provided, a default message
|
||||
/// will be used.
|
||||
/// The [notificationTitle] and [notificationBody] parameters are iOS-only.
|
||||
/// On iOS, the app terminates via `exit(0)` and a local notification is shown
|
||||
/// to let the user reopen it. These parameters customize that notification's
|
||||
/// content. Notification permission must be granted before calling this method
|
||||
/// on iOS. Note: Apple's App Store guidelines prohibit calling `exit()` in
|
||||
/// most circumstances; use this on iOS only when the tradeoff is acceptable
|
||||
/// for your use case.
|
||||
///
|
||||
/// This method communicates with the platform-specific code to perform the restart operation,
|
||||
/// and then checks the response. If the response is "ok", it returns true, signifying that
|
||||
/// the restart operation was successful. Otherwise, it returns false.
|
||||
/// The [forceKill] parameter is Android-only. When true, the old process is
|
||||
/// fully terminated after the new activity starts, preventing stale native
|
||||
/// resource locks. Defaults to false.
|
||||
///
|
||||
/// Returns true if the restart was initiated successfully.
|
||||
static Future<bool> restartApp({
|
||||
String? webOrigin,
|
||||
String? notificationTitle,
|
||||
String? notificationBody,
|
||||
bool forceKill = false,
|
||||
}) async {
|
||||
final Map<String, dynamic> args = {
|
||||
'webOrigin': webOrigin,
|
||||
'notificationTitle': notificationTitle,
|
||||
'notificationBody': notificationBody,
|
||||
'forceKill': forceKill,
|
||||
};
|
||||
return (await _channel.invokeMethod('restartApp', args)) == "ok";
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('restartApp', args);
|
||||
return result == 'ok';
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
// In order to *not* need this ignore, consider extracting the "web" version
|
||||
|
|
@ -33,13 +31,18 @@ class RestartWeb {
|
|||
/// Handles method calls from the Flutter code.
|
||||
///
|
||||
/// If the method call is 'restartApp', it calls the `restart` method with the given `webOrigin`.
|
||||
/// Otherwise, it returns 'false' to signify that the method call was not recognized.
|
||||
/// Otherwise, throws a [PlatformException] for unrecognized method calls.
|
||||
Future<dynamic> handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'restartApp':
|
||||
return restart(call.arguments as String?);
|
||||
final args = call.arguments as Map?;
|
||||
final webOrigin = args?['webOrigin'] as String?;
|
||||
return restart(webOrigin);
|
||||
default:
|
||||
return 'false';
|
||||
throw PlatformException(
|
||||
code: 'Unimplemented',
|
||||
message: '${call.method} is not implemented on the web platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,9 +54,31 @@ class RestartWeb {
|
|||
///
|
||||
/// This method replaces the current location with the given `webOrigin` (or `window.origin` if
|
||||
/// `webOrigin` is null), effectively reloading the web app.
|
||||
void restart(String? webOrigin) {
|
||||
web.window.location.replace(
|
||||
webOrigin ?? web.window.origin.toString(),
|
||||
);
|
||||
String restart(String? webOrigin) {
|
||||
try {
|
||||
final origin =
|
||||
(webOrigin != null && webOrigin.isNotEmpty) ? webOrigin : null;
|
||||
if (origin != null && origin.startsWith('#')) {
|
||||
web.window.location.hash = origin;
|
||||
web.window.location.reload();
|
||||
} else if (origin != null) {
|
||||
web.window.location.replace(origin);
|
||||
} else {
|
||||
// window.origin returns the literal string "null" in sandboxed iframes,
|
||||
// so we avoid passing it to replace() and fall back to a simple reload.
|
||||
final windowOrigin = web.window.origin.toString();
|
||||
if (windowOrigin.isNotEmpty && windowOrigin != 'null') {
|
||||
web.window.location.replace(windowOrigin);
|
||||
} else {
|
||||
web.window.location.reload();
|
||||
}
|
||||
}
|
||||
return 'ok';
|
||||
} catch (e) {
|
||||
throw PlatformException(
|
||||
code: 'RESTART_FAILED',
|
||||
message: 'Failed to reload the page: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
name: restart_app
|
||||
description: A Flutter plugin that helps you to restart the whole Flutter app with a single function call by using native APIs.
|
||||
version: 1.3.2
|
||||
version: 1.7.3
|
||||
homepage: https://gabrimatic.info
|
||||
repository: https://github.com/gabrimatic/restart_app
|
||||
issue_tracker: https://github.com/gabrimatic/restart_app/issues
|
||||
topics:
|
||||
- restart
|
||||
- app-lifecycle
|
||||
- flutter-plugin
|
||||
funding:
|
||||
- https://www.buymeacoffee.com/gabrimatic
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.1
|
||||
flutter: ">=1.17.0"
|
||||
sdk: ^3.4.0
|
||||
flutter: ">=3.22.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
web: ^1.0.0
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
web: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
|
|
@ -30,4 +37,10 @@ flutter:
|
|||
pluginClass: RestartAppPlugin
|
||||
web:
|
||||
pluginClass: RestartWeb
|
||||
fileName: restart_web.dart
|
||||
fileName: restart_web.dart
|
||||
linux:
|
||||
pluginClass: RestartAppPlugin
|
||||
macos:
|
||||
pluginClass: RestartAppPlugin
|
||||
windows:
|
||||
pluginClass: RestartAppPluginCApi
|
||||
|
|
|
|||
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