[{"data":1,"prerenderedAt":212},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fsdks\u002Fandroid":3},{"page":4,"toc":201,"updated":211},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fsdks\u002Fandroid","Android","Android SDK: Identify Devices in Kotlin","Identify Android devices with the native Kotlin SDK. One dependency, two install-time permissions, hardware-backed attestation, and verdicts on the device.",[10,13,18,24,27,39,41,44,49,51,53,56,83,100,103,147,148,178,183,186,188,191,193,196],{"type":11,"text":12},"p","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.",{"type":14,"level":15,"text":16,"id":17},"heading",2,"Requirements","requirements",{"type":19,"items":20},"list",[21,22,23],"Android 5.0 (API 21) or newer.","Java 17 toolchain, Android Gradle Plugin 8.11 or newer.","A [public key](\u002Fdocs\u002Fapi-keys).",{"type":14,"level":15,"text":25,"id":26},"Install","install",{"type":28,"samples":29},"code",[30,35],{"label":31,"lang":32,"code":33,"check":34},"build.gradle.kts","kotlin","dependencies {\n    implementation(\"io.fingerly:fingerly:0.1.0\")\n}",false,{"label":36,"lang":37,"code":38},"build.gradle","groovy","dependencies {\n    implementation 'io.fingerly:fingerly:0.1.0'\n}",{"type":11,"text":40},"The SDK's manifest merges into yours. It declares `INTERNET` and `ACCESS_NETWORK_STATE`, and a small `\u003Cqueries>` list of package names it checks for. It never requests `QUERY_ALL_PACKAGES`. Its R8 rules are bundled.",{"type":14,"level":15,"text":42,"id":43},"Identify a device","identify-a-device",{"type":28,"samples":45},[46],{"label":47,"lang":32,"code":48},"SignInViewModel.kt","import android.app.Application\nimport androidx.lifecycle.AndroidViewModel\nimport androidx.lifecycle.viewModelScope\nimport io.fingerly.sdk.Fingerly\nimport io.fingerly.sdk.identify\nimport kotlinx.coroutines.async\nimport kotlinx.coroutines.launch\n\nclass SignInViewModel(private val app: Application) : AndroidViewModel(app) {\n    private val fingerly = viewModelScope.async {\n        Fingerly.load(app, apiKey = \"fly_pk_us_production_…\")\n    }\n\n    fun signIn(email: String, password: String) = viewModelScope.launch {\n        val result = fingerly.await().identify(tag = \"sign-in\")\n        api.signIn(email, password, requestId = result.requestId)\n    }\n}",{"type":11,"text":50},"`load` and `identify` are `suspend` functions. Collection runs on `Dispatchers.IO`. Hold one client for the life of the process.",{"type":11,"text":52},"Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](\u002Fdocs\u002Fserver-side-verification).",{"type":14,"level":15,"text":54,"id":55},"Configuration","configuration",{"type":57,"title":58,"fields":59},"fields","`Fingerly.load`",[60,65,69,73,78],{"name":61,"type":62,"text":63,"required":64},"context","Context","Any context. The SDK keeps the application context.",true,{"name":66,"type":67,"text":68,"required":64},"apiKey","String","Your public key. Its prefix decides the regional API.",{"name":70,"type":67,"text":71,"default":72},"endpoint","A custom API origin or first-party proxy. Empty uses the region in the key.","\"\"",{"name":74,"type":75,"text":76,"default":77},"schedule","ScheduleOptions","The collection budget: `budgetMs`, `defaultSourceTimeoutMs`, `concurrency` and `tiers`.","budgetMs = 1200",{"name":79,"type":80,"text":81,"default":82},"consent","ConsentState","`GRANTED`, `PENDING` or `DENIED`. Until it is `GRANTED`, `identify` and `collect` read and send nothing and throw `ConsentException`. Change it with `setConsent()` from any thread. See [consent](\u002Fdocs\u002Fprivacy-and-consent#consent).","ConsentState.GRANTED",{"type":57,"title":84,"fields":85},"`identify`",[86,90,95],{"name":87,"type":88,"text":89},"tag","String?","Your own reference for this identification.",{"name":91,"type":92,"text":93,"default":94},"tiers","List\u003CSourceTier>","`SourceTier.FAST`, `SourceTier.DEFERRED`, or both.","SourceTier.ALL",{"name":96,"type":97,"text":98,"default":99},"submit","Boolean","Set `false` to collect and compute verdicts without sending anything.","true",{"type":14,"level":15,"text":101,"id":102},"The result","the-result",{"type":57,"title":104,"fields":105},"`IdentifyResult`",[106,109,112,115,119,122,125,128,132,135,139,143],{"name":107,"type":67,"text":108},"requestId","Identifies this identification. Send it to your server with the action it protects.",{"name":110,"type":67,"text":111},"visitorId","The stable identifier the server resolved for this device.",{"name":113,"type":97,"text":114},"visitorIsNew","Whether your organization is seeing this visitor for the first time.",{"name":116,"type":117,"text":118},"visitorConfidence","Int","How sure the identification is, from 0 to 100.",{"name":120,"type":97,"text":121},"identifiable","`false` when the device gave too little to identify anyone.",{"name":123,"type":97,"text":124},"duplicate","`true` when the server had already answered this request.",{"name":126,"type":67,"text":127},"state","`enriched`, or `unavailable` when the network lookup could not run.",{"name":129,"type":130,"text":131},"suspectScore","Int?","The server's weighted score. `null` when nothing was scored, which is not the same as `0`.",{"name":133,"type":88,"text":134},"suspectLevel","`low`, `medium` or `high`.",{"name":136,"type":137,"text":138},"triggers","List\u003CIdentifyTrigger>","The signals the server scored: `signal`, `group`, `weight` and `confidence`.",{"name":140,"type":141,"text":142},"verdicts","Verdicts","Local, advisory verdicts computed on the device.",{"name":144,"type":145,"text":146},"report","SignalReport","The report that was sent.",{"type":14,"level":15,"text":141,"id":140},{"type":149,"columns":150,"rows":153},"table",[151,152],"Verdict","What it means",[154,157,160,163,166,169,172,175],[155,156],"`root`","The device is rooted.",[158,159],"`emulator`","The app is running in an emulator or a virtualised Android.",[161,162],"`appCloner`","The app is running inside a cloning framework.",[164,165],"`instrumentation`","An instrumentation toolkit is attached to the app.",[167,168],"`mitm`","Something is intercepting the app's encrypted traffic.",[170,171],"`automation`","A debugger, ADB or a test runner is driving the app.",[173,174],"`tampering`","Methods are hooked or the app's signature has changed.",[176,177],"`farm`","The device looks mass-provisioned or freshly reset.",{"type":28,"samples":179},[180],{"label":181,"lang":32,"code":182},"Verdicts.kt","import io.fingerly.sdk.core.Confidence\n\nif (result.verdicts.root.value && result.verdicts.root.confidence == Confidence.HIGH) {\n    \u002F\u002F add friction here, and let your server make the final decision\n}",{"type":14,"level":15,"text":184,"id":185},"Hardware-backed attestation","hardware-backed-attestation",{"type":11,"text":187},"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](\u002Fdocs\u002Fmobile-attestation).",{"type":14,"level":15,"text":189,"id":190},"Errors","errors",{"type":11,"text":192},"`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.",{"type":14,"level":15,"text":194,"id":195},"Java","java",{"type":28,"samples":197},[198],{"label":199,"lang":195,"code":200},"SignInActivity.java","FingerlyBridge.configure(context, \"fly_pk_us_production_…\", \"\", \"android\");\n\nFingerlyBridge.identify(\"sign-in\", true, new BridgeCallback() {\n    @Override public void onSuccess(String json) { \u002F* parse the result *\u002F }\n    @Override public void onError(String code, String message) { \u002F* code is the HTTP status *\u002F }\n});",[202,203,204,205,206,207,208,209,210],{"id":17,"text":16,"level":15},{"id":26,"text":25,"level":15},{"id":43,"text":42,"level":15},{"id":55,"text":54,"level":15},{"id":102,"text":101,"level":15},{"id":140,"text":141,"level":15},{"id":185,"text":184,"level":15},{"id":190,"text":189,"level":15},{"id":195,"text":194,"level":15},"2026-09-17T16:59:11.000Z",1789667797810]