AppGuard

Documentation

Getting AppGuard into an app is four steps and about ten lines. This page covers setup, the policy model, handling events, and what to check when something does not work.

Requirements

minSdk30
Android Gradle Plugin8.0 or newer
Gradle8.4 or newer, running on JDK 17 or newer
Kotlin1.9 or newer
ABIsarmeabi-v7a, arm64-v8a, x86_64

The native detection layer ships prebuilt inside the AAR, so you do not need the NDK or CMake. If your app restricts ABI splits, make sure the three above are among them — a missing .so disables native Frida detection.

Add the plugin

Apply the plugin and add the library to your application module:

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("pro.paphitis.appguard") version "0.1.0"
}

dependencies {
    implementation("pro.paphitis.appguard:android-app-guard:0.1.0")
}

The plugin and the AAR carry the same version, and must stay in step. The plugin writes check names into BuildConfig that the library parses back, and the library rejects a name it does not recognise rather than silently skipping the check.

The plugin must be applied to an application module. It configures BuildConfig on the APK that ships, so applying it to a library or JVM module fails the build with an explanatory message.

Add your licence key

A licence key names one base application ID, the environment suffixes it covers, and an expiry. The plugin verifies it during configuration — offline, with no call home — and fails the build if the variant's application ID is not covered. Request one here.

TierForTermPrice
DevelopmentEvaluation, non-production builds3 monthsFree
StartupProduction, under 10,000 users12 months€100 / year
EnterpriseProduction, 10,000 users or more12 months€1,000 / year

Per application ID, VAT included. There is no grace period — the plugin verifies offline, so an expired licence fails the build the moment it lapses. Renew before the expiry date in your key.

The plugin looks in four places, first match wins:

1. Build scriptappGuard { licenseKey = "AG1.…" }
2. Gradle propertyappguard.licenseKey=AG1.…
3. EnvironmentAPPGUARD_LICENSE_KEY=AG1.…
4. Fileappguard.license in the module or root project directory
Prefer option 2 or 3. A licence key identifies your organisation, and build.gradle.kts is usually committed. Put it in ~/.gradle/gradle.properties for developers and in a secret for CI, and it never reaches version control.

Which environments a licence covers:

Licensed base ID : com.example.app
Suffixes         : (production), .qa, .uat, .test

Builds that pass : com.example.app
                   com.example.app.qa
                   com.example.app.uat
                   com.example.app.test

Builds that fail : com.example.app.internal   ← not on the licence

The check runs per variant against the resolved application ID, so product flavours and applicationIdSuffix are both accounted for. Request every environment you ship to — adding one later means a new key.

Choose a policy per check

Each check is independently ALLOWED, WARNING or ENFORCED, set per build type. Both blocks are optional; anything you leave out takes the default in the reference table below.

appGuard {
    licenseKey = "AG1.…"
    allowedSigningCertificates = listOf("A1:B2:…")

    debug {
        // Normal on a development machine — running these would mean a
        // finding on every single launch.
        signature = SecurityPolicy.ALLOWED
        installer = SecurityPolicy.ALLOWED
        debugger  = SecurityPolicy.ALLOWED
        emulator  = SecurityPolicy.ALLOWED

        // Something genuinely wrong with the device, not the build.
        root      = SecurityPolicy.WARNING
        hooking   = SecurityPolicy.WARNING
    }

    release {
        signature = SecurityPolicy.ENFORCED
        debugger  = SecurityPolicy.ENFORCED
        emulator  = SecurityPolicy.ENFORCED
        root      = SecurityPolicy.ENFORCED
        hooking   = SecurityPolicy.ENFORCED
        installer = SecurityPolicy.ALLOWED   // opt in when you ship store-only
    }
}
An ENFORCED release build terminates on a finding. That is the point, but it makes release awkward for interactive testing. Test on debug, or drop the specific check to WARNING while you investigate.

Build types other than debug use the release block. A build type nobody configured is more likely to ship than to be a development build, and guessing wrong in that direction is the expensive mistake.

Wire up your Application

Extend SecureApplication and hand it the values the plugin put in BuildConfig. That is the whole integration — the base class runs the checks on startup and on a randomised foreground schedule.

class MyApp : SecureApplication() {

    override val securityPolicies =
        SecurityPolicies.parse(BuildConfig.APPGUARD_POLICIES)

    override val allowedSigningCertificates =
        BuildConfig.ALLOWED_SIGNING_CERTIFICATES
            .split(",")
            .filter { it.isNotBlank() }
}

Register it in your manifest:

<application android:name=".MyApp" … >

Not using the plugin? SecurityPolicies has a normal constructor — SecurityPolicies(signature = SecurityPolicy.ENFORCED, …) — and defaults to everything enforced except the installer check, so forgetting to configure it fails closed.

Handle security events

Every detection is published to securityEvents, keyed by check. Events are emitted for WARNING and ENFORCED — an enforced finding is published before the exception is thrown, so you see the reason rather than only the crash.

lifecycleScope.launch {
    (application as SecureApplication).securityEvents
        .filter { it.check == SecurityCheck.ROOT }
        .collect { event ->
            Log.w("AppGuard", "${event.policy}: ${event.message}")
            if (event.isFatal) disableSensitiveFeatures()
        }
}
The flow replays. A screen that subscribes after startup still receives findings emitted during Application.onCreate, so you do not have to race the checks to observe them.

isDeviceSecure() gives the aggregate — false once an ENFORCED check has failed. Warnings do not change it.

Screen protection

Extending SecureActivity is optional and separate from the checks. It applies FLAG_SECURE (blocking screenshots, screen recording and the recents thumbnail), filters touches that arrive while another window is overlaid — the mechanism behind tapjacking — and re-validates integrity at each lifecycle entry point.

class MainActivity : SecureActivity() {

    override fun onSecureCreate(savedInstanceState: Bundle?) {
        setContentView(R.layout.activity_main)
    }

    // Called when validation fails. Terminates the task by default.
    override fun onDeviceNotSecure() {
        showBlockedScreen()
    }
}

Override the defaults per app with a resource:

<!-- res/values/config.xml -->
<resources>
    <bool name="security_window_secure_enabled">false</bool>
    <bool name="security_touch_filtering_enabled">true</bool>
</resources>

Note onSecureCreate rather than onCreate — the base class needs to apply window flags before your content view is inflated.

Whitelist your signing certificate

The signature check compares the running APK's signing certificate against allowedSigningCertificates. Supply SHA-256 fingerprints as colon-hex or base64; both are accepted.

./gradlew :app:signingReport
With Play App Signing, list two certificates. Your upload certificate and the Play-managed distribution certificate. Listing only the upload one means every build from the store is flagged as repackaged — and because the check is ENFORCED in release, that is every user, immediately.

The check requires every signer to be whitelisted, not just one, so an attacker cannot append their certificate to the signing block while keeping yours.

R8 and ProGuard

Nothing to add. The AAR ships its own consumer rules, which keep the JNI boundary intact — the native layer resolves its bridge class by name at load time, so that one class must survive shrinking.

Everything else is deliberately left obfuscatable. Every unobfuscated symbol in a security library is a signpost telling an attacker which method to hook, so do not add broad -keep rules for AppGuard classes.

Policy reference

CheckDetectsdebug defaultrelease default
signatureRepackaged APK — signing certificate not whitelistedALLOWEDENFORCED
rootRootBeer signals, Magisk, permissive SELinuxWARNINGENFORCED
hookingFrida, Xposed, runtime instrumentationWARNINGENFORCED
debuggerJava or native debugger, debuggable flagALLOWEDENFORCED
emulatorEmulator rather than physical hardwareALLOWEDENFORCED
installerInstalled from an untrusted sourceALLOWEDALLOWED

installer is off even in release because it produces false positives for sideloaded enterprise builds and during QA. Turn it on once you ship exclusively through a store.

Troubleshooting

“variant … is not licensed”

The variant's application ID is not covered. The message lists what the licence does cover; compare it against the applicationId and any applicationIdSuffix on that build type. If the suffix is new, you need a reissued licence.

“AppGuard requires a licence key”

Nothing supplied one. The message lists all four sources — the usual cause is a key set in ~/.gradle/gradle.properties on a developer machine but not in CI.

“The AppGuard licence expired on …”

Licences are time-limited and verified offline, so there is no grace period and no way to extend one without a new key. Quote the licence id from the message when you get in touch.

Signature check fails on a store build only

Almost always Play App Signing: the APK users receive is signed by Google's distribution certificate, not your upload certificate. Add both.

Findings on every debug launch

Expected if the debug policy is stricter than the defaults. A debug build is signed with the debug keystore, is sideloaded, is often on an emulator and frequently has a debugger attached — all four are findings unless set to ALLOWED.

The app dies at startup in release with no useful stack trace

An ENFORCED finding throws DeviceIntegrityException and then kills the process shortly afterwards, so an uncaught-exception handler cannot swallow it and keep a compromised app running. Collect securityEvents to see the reason — it is published before the throw.

Ready to start?