Docs
Install Trust for Android
From nothing to a conversion that fires only for verified installs.
What it is
Install Trust is an Android SDK and a hosted service. The SDK checks each install with Google Play Integrity and tells your app a verdict. Your app fires its own conversion for a verified install, through the analytics or attribution SDK it already ships.
The console lists every install with its verdict, the reasons behind it and its history.
- The SDK fires no conversion. Your listener does. No listener, no conversion.
- It blocks nothing. It answers your app; your app decides.
- Android only. One artifact, for Kotlin and Java apps.
- No prompt. It shows the user nothing and asks for no runtime permission.
Get access
Sign in at app.adhedge.io with your email. We email a link; there is no password. Your first sign-in creates the account. Name your organization and you own it.
That gives you apps, SDK setup, Overview and Installs. Reconciliation, evidence packs, members and API tokens are opened per workspace: ask through the form.
Working with a coding agent? Add the MCP server https://api.adhedge.io/mcp. It signs you in through the browser, registers the app and returns the same lines SDK setup shows. Its setup answer ends at the manifest, so have the agent follow Code and Your conversion from this page.
Register your app
- In the console choose New app. Owners and admins can.
- Enter a name and the package name, exactly as in Play Console. It is your
applicationId. - The console opens SDK setup for the app. Under Install it shows the routing key, with the repository line, the dependency line and the manifest entry ready to copy.
The routing key names your app. It is public and ships in your APK.
Play Console and Google Cloud
Play Integrity issues each install's token under a Cloud project linked to your app. Until SDK setup has a decode path saved, no install is attested and none becomes TRUSTED.
- In Play Console, select your app. Go to App integrity, then Play Integrity API. Choose Link Cloud project and pick or create a project.
- Copy that project's number. Digits only.
- In SDK setup, under Decode, choose Your own Cloud project with Google decode and paste the number.
- In Google Cloud, enable the Play Integrity API in that project. Setup links to the page.
- In the same project, grant the role
roles/playintegrity.userto the account your Setup screen shows. Setup has a copy button for its address. - Save. Phones pick the project up with their next configuration request, within an hour.
Decode reads No decode yet until the first token is read, then the time of the last one. Choose Google decode: it is the path the service decodes.
Signing certificate
- In Play Console, open App integrity and find the app signing key certificate. Copy its SHA-256 fingerprint.
- In SDK setup, under Signing certificates, choose Add certificate and paste it. Colons and uppercase are fine.
Take the fingerprint from Play Console. SDK setup also lists certificates it has observed, and a repackaged build's certificate shows up there too.
An install signed by a certificate you have not registered carries a red flag for it. Your app reads Waiting for first install until the first token from a build Play recognizes, signed by a registered certificate.
Gradle
The SDK is on AdHedge's own Maven repository. Add it in settings.gradle.kts, beside the two your project already has. The content block scopes it: Gradle asks this repository for the io.adhedge group only.
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://maven.adhedge.io") {
content { includeGroup("io.adhedge") }
}
}
}
In settings.gradle, for the Groovy DSL:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://maven.adhedge.io")
content { includeGroup("io.adhedge") }
}
}
}
The dependency, in your app module's build.gradle.kts:
dependencies {
implementation("io.adhedge:install-trust:0.5.0")
}
Or in its build.gradle:
dependencies {
implementation 'io.adhedge:install-trust:0.5.0'
}
SDK setup gives the same line. Every published version is in the Maven metadata. The SDK's own dependencies come from google() and mavenCentral().
| Requirement | Value |
|---|---|
| minSdk | 23 |
| compileSdk | 34 or higher |
| Kotlin | 2.1 or higher for Kotlin callers. Java callers need no Kotlin compiler. |
Manifest
Add your routing key to AndroidManifest.xml, inside <application>:
<meta-data
android:name="io.adhedge.installtrust.ROUTING_KEY"
android:value="itk_..." />
Write the key as a literal android:value. An android:resource, a number or a boolean reads as no key.
What the SDK and its libraries merge into your manifest:
- Three permissions, none a runtime permission:
INTERNET,ACCESS_NETWORK_STATEand, from Google's Install Referrer library,com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE. - The
androidx.startupprovider, unexported, with one meta-data line namedio.adhedge.installtrust.core.InstallTrustInitializer. It starts the SDK when the process starts. - From the Play Integrity library: an unexported activity of Google's Play Core library, which the SDK never starts, and the Google Play services version meta-data line.
- In an app that does not already use AndroidX Core:
android:appComponentFactoryset to AndroidX Core'sCoreComponentFactory. An app that declares its own factory keeps it withtools:replace="android:appComponentFactory".
Code
There is no init call. The SDK starts itself through androidx.startup. If your app removes the startup provider, remove the SDK's meta-data line with tools:node="remove" and call InstallTrust.start(this) in Application.onCreate.
The verdict listener
Register a listener in Application.onCreate. On a final TRUSTED for a new install, fire your conversion once.
import android.app.Application
import io.adhedge.installtrust.core.InstallTrust
import io.adhedge.installtrust.core.Verdict
class App : Application() {
override fun onCreate() {
super.onCreate()
InstallTrust.addVerdictListener { verdict, isFinal ->
if (verdict != Verdict.TRUSTED || !isFinal || !InstallTrust.isNewInstall()) return@addVerdictListener
@Suppress("DEPRECATION") // the flags overload needs API 33
val installed = packageManager.getPackageInfo(packageName, 0).firstInstallTime
val prefs = getSharedPreferences("install_trust_conversion", MODE_PRIVATE)
if (prefs.getLong("fired_for_install", 0L) == installed) return@addVerdictListener
if (!prefs.edit().putLong("fired_for_install", installed).commit()) return@addVerdictListener
sendVerifiedInstall() // your analytics or attribution SDK's event call
}
}
}
- Keep the marker. A final verdict stored by an earlier launch is told again to each listener after it registers, at every launch.
- Key it to
firstInstallTime. Android restores preferences from a backup when the app is reinstalled, and the reinstall is a new install the SDK checks again. commit()before you fire. A conversion is sent at most once per install, and a crash between the two loses one where the other order could send two.
In Java:
import android.app.Application;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import io.adhedge.installtrust.core.InstallTrust;
import io.adhedge.installtrust.core.Verdict;
public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
InstallTrust.addVerdictListener((verdict, isFinal) -> {
if (verdict != Verdict.TRUSTED || !isFinal || !InstallTrust.isNewInstall()) return;
long installed;
try {
installed = getPackageManager().getPackageInfo(getPackageName(), 0).firstInstallTime;
} catch (PackageManager.NameNotFoundException e) {
return;
}
SharedPreferences prefs = getSharedPreferences("install_trust_conversion", MODE_PRIVATE);
if (prefs.getLong("fired_for_install", 0L) == installed) return;
if (!prefs.edit().putLong("fired_for_install", installed).commit()) return;
sendVerifiedInstall(); // your analytics or attribution SDK's event call
});
}
}
Verdicts
Verdict has six values. The listener's second argument, isFinal, says whether this one is final. On the device a final verdict is never replaced.
| Value | Final | Means | Your app |
|---|---|---|---|
| PROVISIONAL_TRUSTED | No | The first answer, before a Play Integrity token is decoded. | Wait. |
| PROVISIONAL_SUSPECT | No | The first answer, before a Play Integrity token is decoded. | Wait. |
| TRUSTED | Yes | Play verified the app and its license. | Fire your conversion when isNewInstall() is true. |
| UNVERIFIED | Yes | One red flag, or Play couldn't confirm it. Also what the SDK keeps when the service refuses the token request or has not answered it after seven days. | Fire nothing. |
| SUSPECT | Yes | Red flags from two different checks, on a Non-Play install or an existing user. | Fire nothing. |
| UNTRUSTED | Yes | Red flags from two different checks, on a new install from Play or an unknown source. | Fire nothing. The one value the ad gate can close for. |
The enum is closed for the 1.x line, so an exhaustive when stays safe. InstallTrust.verdict() returns the last verdict held, provisional or final, and null until the first.
The console shows three verdicts, and shows UNTRUSTED as SUSPECT with a badge. Its caption: TRUSTED: Play verified the app and its license. One in-app behavior flag never downgrades a phone Play recognized and licensed with strong device integrity. UNVERIFIED: one red flag, or Play couldn't confirm it. SUSPECT: red flags from two different checks, such as USB debugging plus no taps on day 0. Excludes Non-Play, existing users and sampled-out installs.
The console can revise an install later from its first two days of behavior. The device keeps the final verdict it was told.
isNewInstall()
True when the SDK recorded this install as new: the app had not been updated since it was installed, or had been installed no more than 48 hours before the SDK first ran. An existing user who updates into a build with the SDK still gets a verdict, and this is false for them. It is also false until the first verdict in the process that decides it. Read it inside the listener.
shouldRequestAds()
Call it before each ad request. It is local and constant time, safe on any thread. It returns false only for an install whose final verdict is UNTRUSTED under a blocking gate, a mode the service sets per app. In every other case it is true, including before the SDK starts. Read it when you need it and never cache it.
if (InstallTrust.shouldRequestAds()) {
loadAd()
}
The console states the same rule in its own words: 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.
label(name)
Counts a label on the device and sends nothing. Calling it changes nothing in the console.
Threads
- The listener runs on the SDK's own background thread, never the main thread. Touch no views there.
- Return quickly. The SDK's requests wait behind your listener. The one small
commit()above is fine; a network call belongs to your analytics SDK's own queue. - Whatever the listener throws is caught.
- A verdict already known is delivered once after you register, when the SDK's thread gets to it. That can be minutes later, while the thread waits on Play for a token.
- At most 16 listeners. Each is held until removed, so register from
Application, or remove it in the matching teardown. - One process runs the SDK: your app's default process. In any other, listeners are never called,
verdict()is null andshouldRequestAds()is true. - Every method is safe on any thread and never throws. A null argument is a silent no-op.
Your conversion
The console and these docs call the event verified_install. Use that name in every tool. With Firebase:
private fun sendVerifiedInstall() {
FirebaseAnalytics.getInstance(this).logEvent("verified_install", null)
}
With AppsFlyer, Adjust, Meta or TikTok, call that SDK's own event method with the same name. Adjust takes the event token you create for it.
- In Firebase, mark
verified_installas a key event. In your attribution tool, map it to your ad networks. - In the ad network, import it as a conversion and set it as the conversion your install campaigns optimize for.
- In SDK setup's checklist, mark Set verified_install as your primary conversion done.
Changing a campaign's conversion restarts its learning. Bidding steers from about 10 real players a day.
Timeline and failure modes
| When | What happens |
|---|---|
| First launch | The SDK asks the service for its configuration, sends one signals request and your listener hears a provisional verdict. It then asks Play for a token and sends it, and your listener hears the final verdict. When the service needs no token, the first answer is already final. |
| How long | The SDK gives no upper bound. The token step alone can wait several minutes. Never hold your UI for a verdict. |
| Offline | Nothing is decided. Requests wait on disk, at most 20 for seven days, and are sent again with a backoff from one minute to one hour, in this launch and later ones. |
| Play gives no token | The SDK asks again in later launches: three asks per install at most, inside seven days. With no token the device keeps its provisional verdict and your listener hears no final one. The console closes the install with a verdict at seven days. |
| Seven days | A token request the service refuses, or has not answered after seven days by the device's clock, ends as a final UNVERIFIED. |
| Reinstall | A new install. The SDK checks it again, and your marker lets it fire again. |
| Existing users after an update | Recorded as existing when the app was updated since its install and more than 48 hours had passed. They get a verdict, isNewInstall() is false, their touches are not observed and they are never billed. |
| Missing or malformed key | The SDK idles for the process: it collects nothing and sends nothing. No crash, and no install in the console. |
| Well-formed key the service does not know | The service answers not found and the SDK goes silent for 24 hours, across relaunches, until that time passes or the app is updated. No install in the console. |
| No decode path or no Cloud project | No token is asked for. Installs are not attested and none becomes TRUSTED. |
| SDK paused by an owner | Nothing is collected or sent while the pause stands. |
Test it
The published SDK logs nothing and has no debug flag or test mode. On the device your listener is the window. Log from it in a debug build:
InstallTrust.addVerdictListener { verdict, isFinal ->
Log.d("MyApp", "Install Trust: $verdict final=$isFinal new=${InstallTrust.isNewInstall()}")
}
- Any build. Open a build with the SDK. SDK setup's checklist marks SDK sent its first install, and the install appears on Installs. Open it to read its reasons and verdict history.
- A debug or sideloaded build shows as Unverified, with source Non-Play or Unknown. One started from Android Studio or adb is a Test install. Play did not serve that build, so Play cannot vouch for it. Non-Play installs never count in the Trusted rate or the Overview totals, and a Test install is never billed.
- Play's own answer. Publish the build to a Play testing track and install it from Play on a real device.
- Testing again. Uninstall first. If you install a build over an install older than 48 hours, the SDK records an existing user and
isNewInstall()is false.
Reconciliation
Upload any network's install report. The console compares it with the first opens the SDK saw. It is opened per workspace.
| 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
Your Play Data safety answers, with every field, its Android source and how long it is kept: data safety. The policy for the SDK and its service: SDK privacy policy.
The SDK talks to one host, i.adhedge.io, over TLS only. Add it to a network allow-list if you keep one. The Play Integrity token and the install referrer come from Google Play on the device.
One signals request is built per install. A value the Android version cannot provide, or whose read fails, is sent as null.
- App and install: install ID, Play App Set ID when your app already includes Google's App Set library, package name, signing certificate hash, first install time, last update time, app version code, SDK version, Android API level, config version, existing-user flag, attestation sample 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.
- Build values, as Android gives them to any app: device brand, device manufacturer, device model, device name, product name, hardware name, board name, chip maker, chip model, build fingerprint, build type, Android release, security patch date, processor ABIs.
- Hardware figures: screen short side and screen long side in pixels, stable density, memory rounded down to 256 MiB, processor cores, sensor type numbers.
- Always null: SIM state and phone type. The SDK reads no telephony.
- Play Integrity: at most one token per install, bound to the signals request's hash. When Play gives none, a report: the install ID, the time, and Play's error code or a cause.
- Behavior, for a new install on the day the SDK first runs and the day after: per session the number of touches, the session's length and two sums over the times between touches; once, the delay to the first touch; and whether a session began on the second day. Never coordinates, views or content.
- Advertising ID: the SDK does not read or send the advertising ID.
- Seen by our server: IP address, never stored with install data; our proxy's access log holds it 14 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.
At each start the SDK also asks the service for its configuration. That request carries the package name, the signing certificate hash, the SDK version, the API level, the version code and the existing-user flag. Every request carries your routing key.
Deletion. To delete an install's data, send us its install ID through the form. The ID is on the install's page in the console. The console has no delete button, and the SDK has no call that returns the ID on the device.
For code review
- Dependencies shipped: AndroidX Startup, Play Integrity (
com.google.android.play:integrity), Play Install Referrer (com.android.installreferrer:installreferrer) and the Kotlin standard library. No OkHttp, coroutines or serialization libraries. - App Set: the SDK ships none of Google's App Set library. It reads the App Set ID only when your app already includes that library.
- Main thread: on Android 9 and up
startreads one small preferences file once. After that the main thread only counts touches. Everything else runs on one background thread. - R8: rules ship inside the library.
- Size: the SDK's build holds it to 120 KiB and 1,500 method references after R8.
- Network: TLS to
i.adhedge.ioonly. Requests are small JSON. Failed sends wait on disk, at most 20 for seven days, and retry with backoff. - Storage: one preferences file,
io.adhedge.installtrust, and a queue in the app's no-backup storage. - License: Apache-2.0. A sources jar is published beside the artifact. Scoring runs on our side.
How touches are counted
One Application.ActivityLifecycleCallbacks registration, and a wrapper around the Window.Callback of each Activity created after the SDK starts. No View.OnTouchListener on your views, no accessibility service, no input injection. Touches in a dialog's or a popup's own window are not seen.
On the dispatch path the wrapper reads the event's action and nothing else of it. When a touch begins it reads a clock and adds to a count and two sums. Then it calls the callback that was there before, whose result and exceptions pass through unchanged. It allocates nothing per event, keeps no reference to the event, takes no lock and does no I/O.
Your stack traces through touch dispatch show one frame of that wrapper: io.adhedge.installtrust.signals.TouchHook$Callback.dispatchTouchEvent, or whatever name your R8 gives the class.
Remove it
- Point your campaigns at another conversion first. Once the listener is gone,
verified_installstops. - Delete the listener, any
shouldRequestAds()andlabel()calls, the manifest entry, the dependency and the repository block. - Ship the build. The SDK's files stay in the app's private storage until the app's data is cleared or the app is uninstalled.
What was already sent is kept for the windows on the data safety page, or until you ask for deletion.
Get help
The form is the contact. It reaches the team by email.