diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d6a41e..905b0a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.3.0] - 2026-05-16
+
+### Added
+
+- Added `ReleaseEmulatorGuardActivity` for Android apps that need non-debuggable release APKs blocked before Flutter starts on emulators.
+- Added optional Android manifest metadata for native release-emulator blocker title, subtitle, message, and button text.
+- Added package consumer keep rules for the Android release-emulator guard and detector classes, plus R8 warning suppression for annotation-only Tink references from `androidx.security:security-crypto`.
+- Documented Android launch-guard setup, Android install-vs-launch enforcement, and iOS simulator release tooling prevention.
+- Added iOS screen connect/disconnect observers so capture state is refreshed when mirrored or external screens are attached or removed.
+- Added broader iOS jailbreak and runtime-tampering indicators for Frida/Gadget artifacts, injected DYLD environment variables, suspicious runtime classes, and known instrumentation dylibs.
+
+### Changed
+
+- Android release-emulator launch blocking is package-owned; host apps only need a launcher manifest handoff.
+- Android guarded-screen overlay hardening now reports `supportsOverlayHardening: false` after `setHideOverlayWindows(...)` is unavailable.
+- Android security signal refresh is isolated from detector exceptions and executor shutdown races.
+- Android root `su` probing now has a timeout and cleans up its spawned process.
+- iOS live capture detection now checks all connected screens instead of only `UIScreen.main`.
+- iOS secure-storage writes now update existing Keychain items in place before adding new items.
+
+### Fixed
+
+- Fixed Android plugin registration so `Window.setHideOverlayWindows(...)` `SecurityException`s do not prevent `flutter_defender` registration.
+- Fixed Android screenshot callback registration/unregistration so platform callback failures do not crash plugin setup or teardown.
+- Fixed stale UI-thread protection updates from applying after an activity detach/rebind.
+- Fixed Android release guard target-activity failures to show a native configuration error instead of crashing.
+- Fixed iOS secure-storage writes so a failed add cannot remove an existing value first.
+
+### Tests
+
+- Verified `flutter analyze`, `flutter test`, Android release APK build, iOS simulator debug build, and Android release emulator launch blocking.
+
## [0.2.4] - 2026-05-12
### Fixed
diff --git a/README.md b/README.md
index 607078a..cb6a6e3 100644
--- a/README.md
+++ b/README.md
@@ -23,9 +23,63 @@ There is no route-observer setup. A guarded screen protects itself before the se
```yaml
dependencies:
- flutter_defender: ^0.2.4
+ flutter_defender: ^0.3.0
```
+### Android release emulator launch block
+
+`enableEmulatorDetectionRelease` blocks guarded Flutter screens in release
+builds. If you need the stricter policy where a release APK is blocked before
+Flutter starts, make the package guard activity your Android launcher and point
+it at your real Flutter activity:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+No Gradle change is required. Debug and profile builds remain runnable on
+emulators; non-debuggable release-like builds are blocked at launch when an
+emulator is detected. Android can still install a release APK on a compatible
+emulator, so this is launch-time enforcement rather than install prevention. If
+your manifest does not already define it, add
+`xmlns:tools="http://schemas.android.com/tools"` to the root `` tag.
+If `TARGET_ACTIVITY` is wrong, the native guard shows a configuration error and
+logs the missing activity instead of crashing.
+
## Quick Start
Initialize once before `runApp`:
@@ -193,10 +247,10 @@ await FlutterDefender.instance.init(
| --- | --- | --- |
| Secure screenshots / recents | Yes, via `FLAG_SECURE` | No direct equivalent |
| Screenshot event | Android 14+ screenshot callback | Post-capture notification only |
-| Live capture / mirroring detection | Limited | Yes, via `UIScreen.isCaptured` |
+| Live capture / mirroring detection | Limited | Yes, across connected screens via `UIScreen.isCaptured` |
| Conceal on focus loss (`inactive`) | Lifecycle-driven concealment | Yes, hides guarded content immediately |
| Overlay protection | Mitigation-based hardening | Not supported |
-| Emulator / simulator release block | Yes | Yes |
+| Emulator / simulator release block | Guarded screens; optional native launch guard | Flutter/Xcode tooling blocks release simulator builds |
| Root / jailbreak detection | Yes (best-effort indicators) | Yes (best-effort indicators) |
| Proxy / VPN detection | Yes | Yes |
| Basic RASP (debugger / hooking) | Yes | Yes |
@@ -206,7 +260,7 @@ Important limitations:
- **Android overlay defense is mitigation-based.** The plugin hardens guarded screens and reports obscured-touch violations; it does not claim perfect detection of every hostile overlay.
- **iOS screenshot detection is after capture.** The system screenshot has already happened when the notification arrives.
- **iOS uses privacy concealment, not hostile-overlay detection.** Guarded content is hidden when the app becomes inactive, such as during Control Center, Notification Center, Siri, calls, or app-switcher transitions.
-- **Release-only emulator/simulator blocking** applies on guarded screens when `enableEmulatorDetectionRelease` is enabled.
+- **Release-only emulator/simulator blocking** applies on guarded screens when `enableEmulatorDetectionRelease` is enabled. On Android, the optional package launcher guard blocks release-like emulator launches before Flutter starts. On iOS, `flutter build ios --simulator --release` is already rejected by Flutter/Xcode tooling.
## Background Timeout Behavior
@@ -259,6 +313,8 @@ flutter run
```bash
flutter analyze
flutter test
+cd example && flutter build apk --release
+cd example && flutter build ios --simulator --debug --no-pub
cd example && flutter test
flutter pub publish --dry-run
```
@@ -270,7 +326,7 @@ This repository includes GitHub Actions for CI and publishing:
- Pull requests run package and example analysis plus tests.
- Pushes to `main` / `master` rerun those checks, verify that `pubspec.yaml`
contains a version higher than the previous branch tip, and then create a
- matching Git tag such as `v0.2.1`.
+ matching Git tag such as `v0.3.0`.
- Pushing that tag triggers the publish workflow, which runs a final
`flutter pub publish --dry-run` and then publishes to pub.dev.
diff --git a/android/build.gradle b/android/build.gradle
index d256549..0a4e2ad 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -45,6 +45,7 @@ android {
defaultConfig {
minSdk = 24
+ consumerProguardFiles "consumer-rules.pro"
}
dependencies {
diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro
new file mode 100644
index 0000000..061318c
--- /dev/null
+++ b/android/consumer-rules.pro
@@ -0,0 +1,11 @@
+-keep class aleem.flutter.defender.ReleaseEmulatorGuardActivity { *; }
+-keep class aleem.flutter.defender.EmulatorDetector { *; }
+
+# androidx.security:security-crypto pulls Tink, which references compile-time
+# annotation packages that are not needed at runtime.
+-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
+-dontwarn com.google.errorprone.annotations.CheckReturnValue
+-dontwarn com.google.errorprone.annotations.Immutable
+-dontwarn com.google.errorprone.annotations.RestrictedApi
+-dontwarn javax.annotation.Nullable
+-dontwarn javax.annotation.concurrent.GuardedBy
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 1965a68..bed61df 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -1,3 +1,8 @@
+
+
+
diff --git a/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt b/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt
index 2e40adb..12bd41d 100644
--- a/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt
+++ b/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.Debug
import java.io.File
import java.net.NetworkInterface
+import java.util.concurrent.TimeUnit
internal class AdvancedSecurityDetector(private val context: Context) {
fun collectSignals(): AdvancedSecuritySignals {
@@ -60,12 +61,15 @@ internal class AdvancedSecurityDetector(private val context: Context) {
}
private fun canExecuteSu(): Boolean {
+ var process: Process? = null
return try {
- val process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
- val output = process.inputStream.bufferedReader().use { it.readText() }
- output.isNotBlank()
+ process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
+ val completed = process.waitFor(2, TimeUnit.SECONDS)
+ completed && process.inputStream.bufferedReader().use { it.readText() }.isNotBlank()
} catch (_: Throwable) {
false
+ } finally {
+ process?.destroy()
}
}
diff --git a/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt b/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt
index c98475b..8b5ac07 100644
--- a/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt
+++ b/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt
@@ -2,7 +2,7 @@ package aleem.flutter.defender
import android.os.Build
-internal object EmulatorDetector {
+object EmulatorDetector {
fun isEmulator(): Boolean {
return Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
diff --git a/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt b/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt
index 3cff661..8e95b68 100644
--- a/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt
+++ b/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt
@@ -2,6 +2,7 @@ package aleem.flutter.defender
import android.app.Activity
import android.os.Build
+import android.util.Log
import android.view.ViewTreeObserver
import android.view.Window
import android.view.WindowManager
@@ -10,8 +11,13 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
+ private companion object {
+ const val TAG = "FlutterDefender"
+ }
+
private var activity: Activity? = null
private var flutterApi: DefenderFlutterApi? = null
private var snapshotStore: LifecycleSnapshotStore? = null
@@ -23,6 +29,7 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
private var windowCallbackWrapper: OverlayAwareWindowCallback? = null
private var secureActive = false
private var overlayHardeningActive = false
+ private var hideOverlayWindowsAvailable = true
@Volatile
private var advancedSecuritySignalsCache = AdvancedSecuritySignals(
rootedOrJailbroken = false,
@@ -84,7 +91,8 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
isForeground = currentForegroundState(),
isScreenCaptured = false,
isEmulator = EmulatorDetector.isEmulator(),
- supportsOverlayHardening = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ supportsOverlayHardening = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ hideOverlayWindowsAvailable
)
}
@@ -146,13 +154,25 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
private fun applyProtectionState() {
val activeActivity = activity ?: return
activeActivity.runOnUiThread {
+ if (activity !== activeActivity) {
+ return@runOnUiThread
+ }
if (secureActive) {
activeActivity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
activeActivity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- activeActivity.window.setHideOverlayWindows(overlayHardeningActive)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hideOverlayWindowsAvailable) {
+ try {
+ activeActivity.window.setHideOverlayWindows(overlayHardeningActive)
+ } catch (error: SecurityException) {
+ hideOverlayWindowsAvailable = false
+ Log.w(
+ TAG,
+ "System overlay hiding is unavailable; continuing with fallback overlay hardening.",
+ error
+ )
+ }
}
activeActivity.window.decorView.setFilterTouchesRecursively(overlayHardeningActive)
updateWindowCallback(activeActivity.window)
@@ -193,15 +213,24 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
return
}
val callback = Activity.ScreenCaptureCallback { flutterApi?.onScreenshotDetected {} }
- activeActivity.registerScreenCaptureCallback(activeActivity.mainExecutor, callback)
- screenCaptureCallbackHandle = callback
+ try {
+ activeActivity.registerScreenCaptureCallback(activeActivity.mainExecutor, callback)
+ screenCaptureCallbackHandle = callback
+ } catch (error: RuntimeException) {
+ screenCaptureCallbackHandle = null
+ Log.w(TAG, "Screen-capture callback registration failed; continuing without callback.", error)
+ }
}
private fun unregisterScreenCaptureCallback() {
val activeActivity = activity ?: return
val callback = screenCaptureCallbackHandle as? Activity.ScreenCaptureCallback ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- activeActivity.unregisterScreenCaptureCallback(callback)
+ try {
+ activeActivity.unregisterScreenCaptureCallback(callback)
+ } catch (error: RuntimeException) {
+ Log.w(TAG, "Screen-capture callback unregistration failed.", error)
+ }
}
screenCaptureCallbackHandle = null
}
@@ -242,8 +271,16 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi {
private fun scheduleSecurityRefresh() {
val detector = advancedSecurityDetector ?: return
val executor = detectorExecutor ?: return
- executor.execute {
- advancedSecuritySignalsCache = detector.collectSignals()
+ try {
+ executor.execute {
+ try {
+ advancedSecuritySignalsCache = detector.collectSignals()
+ } catch (error: Throwable) {
+ Log.w(TAG, "Advanced security signal refresh failed.", error)
+ }
+ }
+ } catch (error: RejectedExecutionException) {
+ Log.w(TAG, "Advanced security signal refresh was rejected.", error)
}
}
}
diff --git a/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt b/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt
new file mode 100644
index 0000000..d697e60
--- /dev/null
+++ b/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt
@@ -0,0 +1,282 @@
+package aleem.flutter.defender
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.Gravity
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+
+class ReleaseEmulatorGuardActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (shouldBlockReleaseEmulator()) {
+ Log.w(TAG, "Blocking non-debuggable emulator launch.")
+ showReleaseEmulatorBlocker()
+ return
+ }
+ forwardToTargetActivity()
+ }
+
+ private fun shouldBlockReleaseEmulator(): Boolean {
+ return isReleaseLikeBuild() && EmulatorDetector.isEmulator()
+ }
+
+ private fun isReleaseLikeBuild(): Boolean {
+ return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
+ }
+
+ private fun targetActivityName(): String {
+ val configured = activityMetadata().getString(META_TARGET_ACTIVITY)
+ if (!configured.isNullOrBlank()) {
+ return if (configured.startsWith(".")) {
+ "$packageName$configured"
+ } else {
+ configured
+ }
+ }
+ return "$packageName.MainActivity"
+ }
+
+ private fun forwardToTargetActivity() {
+ val targetActivity = targetActivityName()
+ try {
+ Log.d(TAG, "Forwarding launcher to $targetActivity.")
+ startActivity(Intent().setClassName(packageName, targetActivity))
+ finish()
+ overridePendingTransition(0, 0)
+ } catch (error: ActivityNotFoundException) {
+ Log.e(TAG, "Configured target activity was not found: $targetActivity", error)
+ showConfigurationError(targetActivity)
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun activityMetadata(): Bundle {
+ return try {
+ val activityInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getActivityInfo(
+ componentName,
+ PackageManager.ComponentInfoFlags.of(PackageManager.GET_META_DATA.toLong())
+ )
+ } else {
+ packageManager.getActivityInfo(componentName, PackageManager.GET_META_DATA)
+ }
+ activityInfo.metaData ?: Bundle.EMPTY
+ } catch (error: PackageManager.NameNotFoundException) {
+ Log.w(TAG, "Release emulator guard metadata was not found.", error)
+ Bundle.EMPTY
+ }
+ }
+
+ private fun showReleaseEmulatorBlocker() {
+ val metadata = activityMetadata()
+ val titleText = metadataText(metadata, META_BLOCK_TITLE, DEFAULT_BLOCK_TITLE)
+ val subtitleText = metadataText(metadata, META_BLOCK_SUBTITLE, DEFAULT_BLOCK_SUBTITLE)
+ val messageText = metadataText(metadata, META_BLOCK_MESSAGE, DEFAULT_BLOCK_MESSAGE)
+ val buttonText = metadataText(metadata, META_BLOCK_BUTTON, DEFAULT_BLOCK_BUTTON)
+
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setBackgroundColor(Color.parseColor("#F4F7FB"))
+ setPadding(dp(24), dp(24), dp(24), dp(24))
+ }
+
+ val card = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(dp(28), dp(32), dp(28), dp(28))
+ elevation = dp(8).toFloat()
+
+ background = GradientDrawable().apply {
+ cornerRadius = dp(24).toFloat()
+ setColor(Color.WHITE)
+ }
+ }
+
+ val icon = TextView(this).apply {
+ text = "!"
+ textSize = 54f
+ setTextColor(Color.parseColor("#DC2626"))
+ setTypeface(null, Typeface.BOLD)
+ gravity = Gravity.CENTER
+ setPadding(0, 0, 0, dp(14))
+ }
+
+ val title = TextView(this).apply {
+ text = titleText
+ setTextColor(Color.parseColor("#0F172A"))
+ setTypeface(null, Typeface.BOLD)
+ gravity = Gravity.CENTER
+ textSize = 24f
+ }
+
+ val subtitle = TextView(this).apply {
+ text = subtitleText
+ setTextColor(Color.parseColor("#64748B"))
+ gravity = Gravity.CENTER
+ textSize = 15f
+ setPadding(0, dp(8), 0, dp(22))
+ }
+
+ val message = TextView(this).apply {
+ text = messageText
+
+ setTextColor(Color.parseColor("#334155"))
+ gravity = Gravity.CENTER
+ textSize = 17f
+ setLineSpacing(0f, 1.3f)
+ setPadding(0, 0, 0, dp(28))
+ }
+
+ val closeButton = Button(this).apply {
+ text = buttonText
+ isAllCaps = false
+ textSize = 16f
+ setTextColor(Color.WHITE)
+
+ background = GradientDrawable().apply {
+ cornerRadius = dp(14).toFloat()
+ setColor(Color.parseColor("#2563EB"))
+ }
+
+ setPadding(dp(20), dp(14), dp(20), dp(14))
+
+ setOnClickListener {
+ finishAndRemoveTask()
+ }
+ }
+
+ card.addView(icon)
+
+ card.addView(
+ title,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ card.addView(
+ subtitle,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ card.addView(
+ message,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ card.addView(
+ closeButton,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ root.addView(
+ card,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+
+ setContentView(root)
+ }
+
+ private fun showConfigurationError(targetActivity: String) {
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setBackgroundColor(Color.WHITE)
+ setPadding(dp(24), dp(24), dp(24), dp(24))
+ }
+ val title = TextView(this).apply {
+ text = "Launch configuration error"
+ setTextColor(Color.parseColor("#991B1B"))
+ setTypeface(null, Typeface.BOLD)
+ gravity = Gravity.CENTER
+ textSize = 22f
+ }
+ val message = TextView(this).apply {
+ text = "flutter_defender could not open $targetActivity.\nCheck aleem.flutter.defender.TARGET_ACTIVITY in AndroidManifest.xml."
+ setTextColor(Color.parseColor("#334155"))
+ gravity = Gravity.CENTER
+ textSize = 16f
+ setLineSpacing(0f, 1.25f)
+ setPadding(0, dp(18), 0, dp(26))
+ }
+ val closeButton = Button(this).apply {
+ text = "Close App"
+ isAllCaps = false
+ setOnClickListener { finishAndRemoveTask() }
+ }
+ root.addView(
+ title,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+ root.addView(
+ message,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+ root.addView(
+ closeButton,
+ LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ )
+ setContentView(root)
+ }
+
+ private fun metadataText(metadata: Bundle, key: String, fallback: String): String {
+ return metadata.getString(key)?.takeIf { it.isNotBlank() } ?: fallback
+ }
+
+ private fun dp(value: Int): Int {
+ return (value * resources.displayMetrics.density).toInt()
+ }
+
+ private companion object {
+ const val TAG = "FlutterDefenderGuard"
+ const val META_TARGET_ACTIVITY = "aleem.flutter.defender.TARGET_ACTIVITY"
+ const val META_BLOCK_TITLE = "aleem.flutter.defender.BLOCK_TITLE"
+ const val META_BLOCK_SUBTITLE = "aleem.flutter.defender.BLOCK_SUBTITLE"
+ const val META_BLOCK_MESSAGE = "aleem.flutter.defender.BLOCK_MESSAGE"
+ const val META_BLOCK_BUTTON = "aleem.flutter.defender.BLOCK_BUTTON"
+ const val DEFAULT_BLOCK_TITLE = "Unsupported Device"
+ const val DEFAULT_BLOCK_SUBTITLE = "Security protection is enabled"
+ const val DEFAULT_BLOCK_BUTTON = "Close App"
+ val DEFAULT_BLOCK_MESSAGE = """
+ This release build cannot run on emulators.
+ Please use a physical Android device.
+
+ لا يمكن تشغيل هذا الإصدار على المحاكي.
+ يرجى استخدام جهاز حقيقي.
+ """.trimIndent()
+ }
+}
diff --git a/example/README.md b/example/README.md
index 72e4f8d..c925005 100644
--- a/example/README.md
+++ b/example/README.md
@@ -9,6 +9,7 @@ This example is a manual feature lab for the plugin. It demonstrates:
- advanced-layer toggles for root/jailbreak, proxy/VPN, and RASP checks
- secure storage helper demo flow
- release emulator or simulator blocking on guarded routes
+- optional Android native release-emulator launch blocking through `ReleaseEmulatorGuardActivity`
## Run
@@ -59,4 +60,6 @@ RUN_IOS_INTEGRATION=0 ./tool/run_tests.sh
6. Sign in, open **Authenticated Area**, background for less than 20 seconds, then repeat for more than 20 seconds and confirm logout is requested.
7. Open **Custom Blocking Screen Demo** and verify the underlying route is not tappable while blocked.
8. Apply the advanced security profiles and validate expected blocking behavior on matching device/network conditions.
-9. Build a **release** on an emulator or simulator and verify guarded routes are blocked there while debug remains usable.
+9. On Android, configure `ReleaseEmulatorGuardActivity` as the launcher, build a release APK, install it on an emulator, and verify launch is blocked before Flutter screens load.
+10. On Android, run debug/profile builds on an emulator and verify they remain usable.
+11. On iOS, run `flutter build ios --simulator --release` and verify Flutter rejects the build with `Release mode is not supported for simulators.`
diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/example/ios/Flutter/AppFrameworkInfo.plist
+++ b/example/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index 634db94..808cd23 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -20,7 +20,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
- flutter_defender: 7e55a34fe841275f58a880fe36b7bffcfaf3f48c
+ flutter_defender: bf0b8324f4507a2eda3f83a7ad3c14b7bd1566aa
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift
index 6266644..c30b367 100644
--- a/example/ios/Runner/AppDelegate.swift
+++ b/example/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist
index 62b054b..a9260bd 100644
--- a/example/ios/Runner/Info.plist
+++ b/example/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -12,6 +14,13 @@
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
+ CFBundleLocalizations
+
+ en
+ es
+ fr
+ ar
+
CFBundleName
flutter_defender_example
CFBundlePackageType
@@ -22,15 +31,31 @@
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
- CFBundleLocalizations
-
- en
- es
- fr
- ar
-
LSRequiresIPhoneOS
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -48,9 +73,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/ios/Classes/FlutterDefenderPlugin.swift b/ios/Classes/FlutterDefenderPlugin.swift
index 682210b..172d5b0 100644
--- a/ios/Classes/FlutterDefenderPlugin.swift
+++ b/ios/Classes/FlutterDefenderPlugin.swift
@@ -1,5 +1,6 @@
import Darwin
import Flutter
+import MachO
import Security
import UIKit
@@ -37,12 +38,20 @@ private final class IosAdvancedSecurityDetector {
#else
let suspiciousPaths = [
"/Applications/Cydia.app",
+ "/Applications/Sileo.app",
+ "/Applications/Zebra.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
+ "/Library/PreferenceLoader/PreferenceLoader.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/",
"/private/var/stash",
+ "/private/var/jb",
+ "/var/jb",
+ "/usr/lib/libjailbreak.dylib",
+ "/usr/lib/libsubstitute.dylib",
+ "/usr/lib/substrate",
]
if suspiciousPaths.contains(where: { FileManager.default.fileExists(atPath: $0) }) {
return true
@@ -82,7 +91,7 @@ private final class IosAdvancedSecurityDetector {
return false
}
let http = (settings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.boolValue ?? false
- let https = (settings[kCFNetworkProxiesHTTPSEnable as String] as? NSNumber)?.boolValue ?? false
+ let https = (settings["HTTPSEnable"] as? NSNumber)?.boolValue ?? false
return http || https
}
@@ -113,7 +122,50 @@ private final class IosAdvancedSecurityDetector {
}
private func isHookingDetected() -> Bool {
- let suspicious = ["frida", "substrate", "cycript", "xposed", "libhook"]
+ hasSuspiciousDyldEnvironment() ||
+ hasSuspiciousRuntimeClass() ||
+ hasSuspiciousLoadedImage() ||
+ hasSuspiciousInstrumentationPath()
+ }
+
+ private func hasSuspiciousDyldEnvironment() -> Bool {
+ let environment = ProcessInfo.processInfo.environment
+ let suspiciousKeys = [
+ "DYLD_INSERT_LIBRARIES",
+ "DYLD_LIBRARY_PATH",
+ "DYLD_FRAMEWORK_PATH",
+ ]
+ return suspiciousKeys.contains { key in
+ environment[key]?.isEmpty == false
+ }
+ }
+
+ private func hasSuspiciousRuntimeClass() -> Bool {
+ let suspiciousClasses = [
+ "FridaGadget",
+ "FridaScriptEngine",
+ "CydiaSubstrate",
+ "SubstrateLoader",
+ "SubstrateBootstrap",
+ "MSHookFunction",
+ "CaptainHook",
+ "CYListenServer",
+ ]
+ return suspiciousClasses.contains { NSClassFromString($0) != nil }
+ }
+
+ private func hasSuspiciousLoadedImage() -> Bool {
+ let suspicious = [
+ "frida",
+ "gadget",
+ "substrate",
+ "substitute",
+ "libhooker",
+ "cycript",
+ "sslkill",
+ "flex",
+ "libcolorpicker",
+ ]
for index in 0..<_dyld_image_count() {
guard let rawName = _dyld_get_image_name(index) else {
continue
@@ -125,6 +177,20 @@ private final class IosAdvancedSecurityDetector {
}
return false
}
+
+ private func hasSuspiciousInstrumentationPath() -> Bool {
+ let suspiciousPaths = [
+ "/usr/sbin/frida-server",
+ "/usr/bin/frida-server",
+ "/usr/lib/frida/frida-agent.dylib",
+ "/usr/lib/frida/frida-gadget.dylib",
+ "/Library/MobileSubstrate/DynamicLibraries/FridaGadget.dylib",
+ "/Library/MobileSubstrate/DynamicLibraries/SSLKillSwitch2.dylib",
+ "/Library/MobileSubstrate/DynamicLibraries/FLEX.dylib",
+ "/Library/MobileSubstrate/DynamicLibraries/RevealServer.dylib",
+ ]
+ return suspiciousPaths.contains { FileManager.default.fileExists(atPath: $0) }
+ }
}
private final class IosSecureStorageHelper {
@@ -132,16 +198,22 @@ private final class IosSecureStorageHelper {
func write(key: String, value: String) throws {
guard let data = value.data(using: .utf8) else {
- throw FlutterError(code: "storage_encoding_error", message: "Failed to encode secure value.", details: nil)
+ throw PigeonError(code: "storage_encoding_error", message: "Failed to encode secure value.", details: nil)
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
- let deleteStatus = SecItemDelete(query as CFDictionary)
- if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound {
- throw FlutterError(code: "storage_delete_error", message: "Failed to delete existing keychain item.", details: deleteStatus)
+ let update: [String: Any] = [
+ kSecValueData as String: data,
+ ]
+ let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
+ if updateStatus == errSecSuccess {
+ return
+ }
+ if updateStatus != errSecItemNotFound {
+ throw PigeonError(code: "storage_write_error", message: "Failed to update keychain item.", details: Int(updateStatus))
}
let add: [String: Any] = [
@@ -153,7 +225,7 @@ private final class IosSecureStorageHelper {
]
let addStatus = SecItemAdd(add as CFDictionary, nil)
if addStatus != errSecSuccess {
- throw FlutterError(code: "storage_write_error", message: "Failed to write keychain item.", details: addStatus)
+ throw PigeonError(code: "storage_write_error", message: "Failed to write keychain item.", details: Int(addStatus))
}
}
@@ -173,7 +245,7 @@ private final class IosSecureStorageHelper {
guard status == errSecSuccess,
let data = item as? Data
else {
- throw FlutterError(code: "storage_read_error", message: "Failed to read keychain item.", details: status)
+ throw PigeonError(code: "storage_read_error", message: "Failed to read keychain item.", details: Int(status))
}
return String(data: data, encoding: .utf8)
}
@@ -186,7 +258,7 @@ private final class IosSecureStorageHelper {
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
- throw FlutterError(code: "storage_delete_error", message: "Failed to delete keychain item.", details: status)
+ throw PigeonError(code: "storage_delete_error", message: "Failed to delete keychain item.", details: Int(status))
}
}
@@ -197,7 +269,7 @@ private final class IosSecureStorageHelper {
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
- throw FlutterError(code: "storage_clear_error", message: "Failed to clear keychain items.", details: status)
+ throw PigeonError(code: "storage_clear_error", message: "Failed to clear keychain items.", details: Int(status))
}
}
}
@@ -226,6 +298,8 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA
private var screenshotObserver: NSObjectProtocol?
private var captureObserver: NSObjectProtocol?
+ private var screenConnectObserver: NSObjectProtocol?
+ private var screenDisconnectObserver: NSObjectProtocol?
private var didBecomeActiveObserver: NSObjectProtocol?
private var willResignActiveObserver: NSObjectProtocol?
@@ -238,7 +312,7 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA
},
screenCaptureProvider: @escaping FlutterDefenderScreenCaptureProvider = {
if #available(iOS 11.0, *) {
- return UIScreen.main.isCaptured
+ return UIScreen.screens.contains { $0.isCaptured }
}
return false
},
@@ -275,6 +349,12 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA
if let captureObserver {
notificationCenter.removeObserver(captureObserver)
}
+ if let screenConnectObserver {
+ notificationCenter.removeObserver(screenConnectObserver)
+ }
+ if let screenDisconnectObserver {
+ notificationCenter.removeObserver(screenDisconnectObserver)
+ }
if let didBecomeActiveObserver {
notificationCenter.removeObserver(didBecomeActiveObserver)
}
@@ -364,8 +444,21 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA
object: nil,
queue: .main
) { [weak self] _ in
- guard let self else { return }
- self.flutterApi.onScreenCaptureChanged(active: self.screenCaptureProvider()) { _ in }
+ self?.emitScreenCaptureState()
+ }
+ screenConnectObserver = notificationCenter.addObserver(
+ forName: UIScreen.didConnectNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.emitScreenCaptureState()
+ }
+ screenDisconnectObserver = notificationCenter.addObserver(
+ forName: UIScreen.didDisconnectNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.emitScreenCaptureState()
}
}
@@ -387,6 +480,10 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA
}
}
+ private func emitScreenCaptureState() {
+ flutterApi.onScreenCaptureChanged(active: screenCaptureProvider()) { _ in }
+ }
+
private func scheduleSecurityRefresh() {
detectorQueue.async { [weak self] in
guard let self else { return }
diff --git a/pubspec.yaml b/pubspec.yaml
index 0eba8a0..0efd422 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: flutter_defender
-description: "A comprehensive Flutter security plugin that protects against overlay attacks, task hijacking, screenshot capture, background session abuse, an emulator execution."
-version: 0.2.4
-homepage: https://github.com/aleemElmozogi/flutter_defender
+description: "A comprehensive Flutter security plugin that protects against overlay attacks, task hijacking, screenshot capture, background session abuse, and emulator execution."
+version: 0.3.0
+homepage: https://pub.dev/packages/flutter_defender
repository: https://github.com/aleemElmozogi/flutter_defender
license: Apache-2.0