Docs
Install Trust for Android
Install
Three edits: the repository, the dependency and the routing key.
settings.gradle.kts: Repository published at release.
app/build.gradle.kts
dependencies {
implementation("io.adhedge:install-trust:1.0.0")
}
AndroidManifest.xml, with the routing key from SDK setup. It is public and ships in your APK.
<application>
<meta-data
android:name="io.adhedge.installtrust.ROUTING_KEY"
android:value="YOUR_ROUTING_KEY" />
</application>
If the key is missing or wrong, no installs reach your app in the console.
No init call. Your coding agent can add it for you. Then link a Cloud project to your app in Play Console. We walk you through it.
Verify on a dev phone
Open any build with the SDK. Setup marks SDK seen, and the install shows on Installs within minutes.
A debug or sideloaded build shows as UNVERIFIED with PI_UNRECOGNIZED_VERSION, with source Non-Play or Unknown. Non-Play installs never count in the Trusted rate or the Overview totals. Reasons to expect:
PI_UNRECOGNIZED_VERSIONPlay doesn't recognize the app binary.PI_UNLICENSEDNo Play license for the install.INSTALLER_NOT_PLAYNot installed by Play.CERT_UNKNOWNSigned with your debug or upload key.VERSION_NOT_PLAY_SERVEDA build Play isn't serving.ADB_ENABLEDUSB debugging on.
Verdicts
| Verdict | Meaning | Billed |
|---|---|---|
| TRUSTED | Play verified the app and its license. Behavior flags alone never downgrade a phone with strong integrity. | Yes |
| UNVERIFIED | One red flag, or Play couldn't confirm it. | No |
| SUSPECT | Red flags from two different checks. | No |
Verdicts update as behavior comes in. Rooting alone never blocks an install. Play Games on PC is never blocked.
Reason codes
Red flags from two different checks make a SUSPECT. ROOT_HEURISTIC and PI_NO_DEVICE_INTEGRITY count as one check. CERT_UNKNOWN is its own check, so an unregistered signing key on a build Play didn't recognize counts as two.
| Code | Meaning | Negative |
|---|---|---|
| PI_NO_DEVICE_INTEGRITY | No device integrity from Play. | Yes |
| PI_VIRTUAL_DEVICE | Play Games on PC. | No |
| PI_UNRECOGNIZED_VERSION | Play doesn't recognize the app binary. | Yes |
| PI_UNLICENSED | No Play license for the install. | Yes |
| PI_ACCESS_RISK_CONTROLLING | Another app can control the screen. | Yes |
| INSTALLER_NOT_PLAY | Not installed by Play. | Yes |
| CERT_UNKNOWN | Unknown signing certificate. | Yes |
| VERSION_NOT_PLAY_SERVED | A build Play isn't serving. | Yes |
| ADB_ENABLED | USB debugging on. | Yes |
| TEST_KEYS | Test-keys system build. | Yes |
| EMULATOR_HEURISTIC | Looks like an emulator. | Yes |
| ROOT_HEURISTIC | Signs of root. | Yes |
| NO_INTERACTION_D0 | No taps on install day. | Yes |
| LOW_INPUT_ENTROPY | Tap timing less varied than your app's baseline. Needs 20 taps. | Yes |
| REINSTALL_90D | Reinstall within 90 days. | Yes |
| LEGACY_COHORT | Installed before the SDK. | No |
| SAMPLED_OUT | Not sampled for attestation. | No |
| LABEL_REAL_USER | Your app labeled it real_user. Never used in the verdict. | No |
LOW_INPUT_ENTROPY fires only with at least 20 install-day taps and a tap interval variance below your app's baseline. The baseline is the 5th percentile of that variance over the prior 28 days of installs with 20 or more taps, and needs 100 such installs.
One in-app behavior code (LOW_INPUT_ENTROPY or NO_INTERACTION_D0) never moves an install off TRUSTED when Play recognized the app, the install is licensed and the device has strong integrity. The code stays listed.
Conversion events
verified_installfires for new TRUSTED installs.unverified_installfires for the rest. Blocking mode turns it off.- Existing users fire neither.
- Tagged mode: your app fires both into Firebase, AppsFlyer, Adjust, Meta or TikTok, whichever it has. Set
verified_installas your primary conversion. - Blocking mode: our server sends
verified_installafter verifying the install, so a modified app can't fake it. Server paths exist for Firebase / GA4, AppsFlyer and Adjust, none tested on a live campaign yet. Meta and TikTok have no server path, so blocking sends them no conversions.
Integration status
| Tool | Tagged mode | Blocking mode needs | State |
|---|---|---|---|
| Firebase / GA4 | App fires | Firebase app ID and GA4 API secret. Firebase has seen the device. Sent within 48 hours. | Not tested on a live campaign yet |
| AppsFlyer | App fires | S2S token. Your app's advertising ID for Google Ads postbacks. | Not tested on a live campaign yet |
| Adjust | App fires, with event tokens | S2S token, event tokens, your app's advertising ID and the device IP. | Not tested on a live campaign yet |
| Meta | App fires | Blocking won't send conversions to Meta. | No server path |
| TikTok | App fires | Blocking won't send conversions to TikTok. | No server path |
The advertising ID is sent only if your app already declares it, and never when the user deleted it or limited ad tracking. Our server forwards it to your account and stores none of it.
Label real users
Call InstallTrust.label("real_user") when a user hits a milestone only people reach, like finishing onboarding.
Labels grade the Ad protection checks. They never change a verdict.
Ad request gate
On Pro, call InstallTrust.shouldRequestAds() before each ad load call. It answers on the device, instantly.
Gated: new installs from Play or Unknown with a final SUSPECT verdict, which takes red flags from two different checks. Rooting and no device integrity are one check. An unregistered signing key is its own check, so a build Play didn't recognize signed with an unregistered key counts as two. Never Non-Play, existing users or pending installs. Phones pick up changes within an hour.
- Who gets false: a new install from Play or Unknown with a final SUSPECT verdict, which takes red flags from two different checks, while blocking is on. Pending installs get true.
- How it answers: the device answers from its cached config, with no network call. The SDK refreshes that config within an hour, so when a verdict upgrades, ads come back after the next refresh.
- AdMob: call it right before
AdView.loadAd(),InterstitialAd.load(),RewardedAd.load()andAppOpenAd.load(). Once per ad request. - AppLovin MAX: call it right before
MaxAdView.loadAd(),MaxInterstitialAd.loadAd()andMaxRewardedAd.loadAd(). A gated install skips the whole mediation waterfall.
if (InstallTrust.shouldRequestAds()) {
interstitialAd.loadAd()
}
The gate is part of blocking mode. One switch on Ad protection turns on both: verified_install sent from our server, and shouldRequestAds() gating. There is no separate ad gate switch. Turn blocking off anytime; devices pick it up within an hour.
Reconciliation
Upload any network's install report. We compare it with the first opens the SDK saw.
| Column | Accepted headers | Required |
|---|---|---|
| Date | Day, Date, Event date | Yes |
| Installs | Installs, App installs, Conversions, Conv., All conv. | Yes |
| Campaign | Campaign, Campaign name, Source / medium | No |
| Cost | Any header starting with Cost | No |
What the SDK sends
The install record below is sent once per install. A field is empty when the Android version can't provide it. No permissions beyond network access. The behavior group is the exception: it is not sent with the install record, and it is described in full under Behavior.
- App and install: install ID, Play App Set ID, package name, signing certificate hash, first install time, last update time, app version code, SDK version, Android API level, config version, existing-user flag, request hash.
- Install facts: installer package, package that started the install, install source type, Play Install Referrer string, referrer click time, install start time, app version at install, referrer availability, debuggable build flag, USB debugging setting, test-keys build, work profile.
- Device checks: emulator build markers, root markers, network transport type, VPN active, proxy set, battery level, charging state, time since boot, sensor count, SIM state, phone type.
- Play Integrity: one token per install.
- Behavior: tap counts, session length, first-touch delay, and whether the app was opened again the day after install. No coordinates or screen content. Batched and posted separately from the install record. Every field is in the table below.
- Advertising ID, blocking mode with AppsFlyer or Adjust only: sent once per verified install when your app already declares it. Our server forwards it to your own account and stores none of it.
- Seen by our server: IP address, kept 30 days. Network prefix, kept 13 months, and the country derived from the IP address. Network operator, kept 13 months: the network the request came from and whether it is a hosting provider. The SDK sends none of these.
Behavior
Batched on device and posted separately from the install record: at most 20 events, held at most 7 days, sent on the SDK's background thread. Kept 90 days. Play purposes: Fraud prevention, security, and compliance; Analytics. The collector runs only while tier 4 is on in the app's SDK config.
| Field | What it is | Android source | Sent |
|---|---|---|---|
| Taps in a session | How many touches the session had, as a number. On SESSION_END. No coordinates, no view, no content. | Window.Callback, ACTION_DOWN count | Per session |
| Session length | How long the session lasted, in milliseconds. On SESSION_END. The variance of tap intervals is derived on our side from these pairs. | ActivityLifecycleCallbacks | Per session |
| First-touch delay | Milliseconds from process start to the first touch after install. On FIRST_INTERACTION. | SystemClock.elapsedRealtime | Once |
| Next-day return | That the app was opened again on the day after the install day. On D1_RETURN. One flag. No session count. | Install day + 1, on device | Once |
| Event time | The device clock when the event happened, epoch milliseconds. Our server also records when it arrived. | System.currentTimeMillis | Per event |
| Conversion adapter result | Which adapter fired your conversion and whether the vendor SDK accepted the call. On ADAPTER_FIRED. | Adapter return value | Per fire |
| Late referrer | The Play Install Referrer fields above, when Play returned them after the install record was already sent. On REFERRER_LATE. | InstallReferrerClient | At most once |
Nothing else rides on these events. There is no coordinate, no view ID, no screen name, no text, and no free-text field of any kind.
How taps are counted, and what it costs
The SDK registers one Application.ActivityLifecycleCallbacks and, as each Activity is created, wraps that Activity's Window.Callback. There is no View.OnTouchListener on your views, no accessibility service, and no input injection.
On the dispatch path the wrapper does two things per event: increments an int when the action is ACTION_DOWN, then calls through to the callback that was there before. It allocates nothing per event, reads no field of the MotionEvent besides the action, holds no reference to it after the call returns, and touches no disk or network. Batching, storage and sending happen on the SDK's single background thread, never in dispatch.
A measured per-event figure on reference devices is not published yet; that benchmark is the open item on this path. If your app needs one before code review, ask us and we will run it against your build. Under React Native and Flutter the same native wrapper does the counting, and neither wrapper adds anything on the JavaScript or Dart side.
Each field with its Android source, and what we never collect: data safety.
For code review
- minSdk 23. The Play Integrity library requires it.
- Dependencies: Play Integrity (
com.google.android.play:integrity), Play App Set (com.google.android.gms:play-services-appset), Play Install Referrer (com.android.installreferrer:installreferrer) and AndroidX Startup. No OkHttp, coroutines or serialization libraries. - Starts through AndroidX Startup, with no init call. To start it yourself, remove its initializer in your manifest and call
InstallTrust.start(context). - The main thread reads one stored value at launch, and counts taps in a wrapped
Window.Callback: one int increment and one delegated call per event, no allocation, no I/O. Everything else runs on one background thread. Full mechanism and cost: How taps are counted. - Permissions:
INTERNETandACCESS_NETWORK_STATE. No runtime permissions. - R8 rules ship inside the library. Nothing to add to your ProGuard file.
- Size budget: 120 KB after R8, checked in CI.
- Network: TLS to the AdHedge API only. Requests are small JSON. Failed sends wait on disk, up to 20 for 7 days, and retry with backoff.
License
The SDK, its wrappers, and the wire schema are Apache-2.0. Scoring runs on our side.