Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 61 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<activity
android:name="aleem.flutter.defender.ReleaseEmulatorGuardActivity"
android:exported="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
tools:replace="android:exported">
<meta-data
android:name="aleem.flutter.defender.TARGET_ACTIVITY"
android:value=".MainActivity" />
<!-- Optional text overrides:
<meta-data
android:name="aleem.flutter.defender.BLOCK_TITLE"
android:value="Unsupported device" />
<meta-data
android:name="aleem.flutter.defender.BLOCK_SUBTITLE"
android:value="Security protection is enabled" />
<meta-data
android:name="aleem.flutter.defender.BLOCK_MESSAGE"
android:value="This release build cannot run on emulators." />
<meta-data
android:name="aleem.flutter.defender.BLOCK_BUTTON"
android:value="Close app" />
-->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- Keep your existing MainActivity settings, but remove MAIN/LAUNCHER from it. -->
<activity
android:name=".MainActivity"
android:exported="false"
android:theme="@style/LaunchTheme" />
```

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 `<manifest>` 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`:
Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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
```
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ android {

defaultConfig {
minSdk = 24
consumerProguardFiles "consumer-rules.pro"
}

dependencies {
Expand Down
11 changes: 11 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="aleem.flutter.defender">
<application>
<activity
android:name=".ReleaseEmulatorGuardActivity"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}
}
Loading
Loading