The Android SDK is written in Kotlin. Its only dependency is Kotlin coroutines, nothing from Google Play services, and the two permissions it declares are granted at install time without a prompt.
Requirements
- Android 5.0 (API 21) or newer.
- Java 17 toolchain, Android Gradle Plugin 8.11 or newer.
- A public key.
Install
dependencies {
implementation("io.fingerly:fingerly:0.1.0")
}
The SDK's manifest merges into yours. It declares INTERNET and ACCESS_NETWORK_STATE, and a <queries> list of 17 root, hooking and cloning packages it checks for. It never requests QUERY_ALL_PACKAGES, and it reports other apps only as yes or no, never by name. To leave a package out, remove its entry with tools:node="remove"; see package visibility. Its R8 rules are bundled.
Identify a device
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.fingerly.sdk.Fingerly
import io.fingerly.sdk.core.ConsentState
import io.fingerly.sdk.identify
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
class SignInViewModel(private val app: Application) : AndroidViewModel(app) {
private val fingerly = viewModelScope.async {
// consent = GRANTED only once you have consent (or another lawful basis)
Fingerly.load(app, apiKey = "fly_pk_production_…", consent = ConsentState.GRANTED)
}
fun signIn(email: String, password: String) = viewModelScope.launch {
val result = fingerly.await().identify(tag = "sign-in")
api.signIn(email, password, requestId = result.requestId)
}
}
load and identify are suspend functions. Collection runs on Dispatchers.IO. Hold one client for the life of the process. consent defaults to PENDING, which collects nothing: load with PENDING and call setConsent(ConsentState.GRANTED) from your consent tool, or pass GRANTED as here once you have consent.
Send requestId to your backend with the action, and decide there after reading the stored event with a secret key. See server-side verification.
Configuration
Fingerly.load
contextContextrequiredAny context. The SDK keeps the application context.apiKeyStringrequiredYour public key. Its prefix names the environment it submits to.endpointStringDefault""A custom API origin, to which/api/v1/ingestis appended. Empty useshttps://api.fingerly.io. Proxy integrations serve the browser SDK, not apps.scheduleScheduleOptionsDefaultbudgetMs = 1200The collection budget:budgetMs,defaultSourceTimeoutMs,concurrencyandtiers.consentConsentStateDefaultConsentState.PENDINGGRANTED,PENDINGorDENIED. Until it isGRANTED,identifyandcollectread and send nothing and throwConsentException. Change it withsetConsent()from any thread. See consent.
identify
tagString?Your own reference for this identification.tiersList<SourceTier>DefaultSourceTier.ALLSourceTier.FAST,SourceTier.DEFERRED, or both.submitBooleanDefaulttrueSetfalseto collect and compute verdicts without sending anything.
The result
IdentifyResult
requestIdStringIdentifies this identification. Send it to your server with the action it protects.visitorIdStringThe stable identifier the server resolved for this device.visitorIsNewBooleanWhether your organization is seeing this visitor for the first time.visitorConfidenceIntHow sure the identification is, from 0 to 100.identifiableBooleanfalsewhen the device gave too little to identify anyone.duplicateBooleantruewhen the server had already answered this request.stateStringenriched, orunavailablewhen the network lookup could not run.suspectScoreInt?The server's weighted score.nullwhen nothing was scored, which is not the same as0.suspectLevelString?low,mediumorhigh.triggersList<IdentifyTrigger>The signals the server scored:signal,group,weightandconfidence.verdictsVerdictsLocal, advisory verdicts computed on the device.reportSignalReportThe report that was sent.
Verdicts
| Verdict | What it means |
|---|---|
root | The device is rooted. |
emulator | The app is running in an emulator or a virtualised Android. |
appCloner | The app is running inside a cloning framework. |
instrumentation | An instrumentation toolkit is attached to the app. |
mitm | Something is intercepting the app's encrypted traffic. |
automation | A debugger, ADB or a test runner is driving the app. |
tampering | Methods are hooked or the app's signature has changed. |
farm | The device looks mass-provisioned or freshly reset. |
import io.fingerly.sdk.core.Confidence
if (result.verdicts.root.value && result.verdicts.root.confidence == Confidence.HIGH) {
// add friction here, and let your server make the final decision
}
The install ID
The SDK keeps one small record in a file in your app's noBackupFilesDir: a random install ID, when it was first seen, the launch count and the signing certificate digest. Auto Backup and device-to-device transfer never copy it, and it is removed when your app is uninstalled, so a reinstall starts with a new install ID and a new visitor ID. A record an earlier version kept in shared preferences is moved there once. It is read and written only while consent is granted.
The server recognises the device by this install ID and nothing else. The device signals are used for the verdicts and the score, never to identify a device or a user.
Deleting the stored identity
fingerly.setConsent(ConsentState.DENIED) // if nothing should be collected afterwards
val deleted = Fingerly.deleteStoredIdentity(context) // true when no record remains
Deletes the install record, for erasure requests and account deletion. It needs no client and no consent, sends nothing, and is synchronous and thread-safe; it is a small file delete, so call it off the main thread if StrictMode matters to you. The next identification made with consent starts a new install ID that nothing links to the old one. See account deletion and erasure.
Google Play
- The Data safety answers for what the SDK collects are in App Store and Google Play privacy.
- Consent defaults to
PENDING, so the SDK honours your app's consent decision, as Google Play's guidance on using SDKs safely asks. - Do not ship it in apps directed at children.
Hardware-backed attestation
On Android 7.0 (API 24) and newer, the SDK requests a one-time challenge from Fingerly and has the device keystore attest to it. The server verifies the attestation, including that it was produced for your app, and a failed check raises the tampering signal. If the challenge cannot be fetched, the SDK attests to a challenge it generates itself and identification continues; that attestation cannot prove it is fresh. There is nothing to configure. See mobile app attestation.
Errors
identify throws TransportError with status and retryable. The SDK retries IO failures, 429 and 5xx up to three attempts with one idempotency key. identify and collect throw ConsentException when consent is not GRANTED; otherwise collect() never throws.
Java
FingerlyBridge.configure(context, "fly_pk_production_…", "", "android");
FingerlyBridge.identify("sign-in", true, new BridgeCallback() {
@Override public void onSuccess(String json) { /* parse the result */ }
@Override public void onError(String code, String message) { /* code is the HTTP status */ }
});
boolean deleted = FingerlyBridge.deleteStoredIdentity(context);