# Android

> Identify Android devices with the native Kotlin SDK. One dependency, two install-time permissions, hardware-backed attestation, and verdicts on the device.

Last updated: 2026-09-17

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](https://docs.fingerly.io/docs/api-keys).

## Install

```kotlin build.gradle.kts
dependencies {
    implementation("io.fingerly:fingerly:0.1.0")
}
```

```groovy build.gradle
dependencies {
    implementation 'io.fingerly:fingerly:0.1.0'
}
```

The SDK's manifest merges into yours. It declares `INTERNET` and `ACCESS_NETWORK_STATE`, and a small `<queries>` list of package names it checks for. It never requests `QUERY_ALL_PACKAGES`. Its R8 rules are bundled.

## Identify a device

```kotlin SignInViewModel.kt
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.fingerly.sdk.Fingerly
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 {
        Fingerly.load(app, apiKey = "fly_pk_us_production_…")
    }

    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.

Send `requestId` to your backend with the action, and decide there after reading the stored event with a secret key. See [server-side verification](https://docs.fingerly.io/docs/server-side-verification).

## Configuration

**`Fingerly.load`**

- `context` (Context, required): Any context. The SDK keeps the application context.
- `apiKey` (String, required): Your public key. Its prefix decides the regional API.
- `endpoint` (String, default `""`): A custom API origin or first-party proxy. Empty uses the region in the key.
- `schedule` (ScheduleOptions, default `budgetMs = 1200`): The collection budget: `budgetMs`, `defaultSourceTimeoutMs`, `concurrency` and `tiers`.
- `consent` (ConsentState, default `ConsentState.GRANTED`): `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](https://docs.fingerly.io/docs/privacy-and-consent#consent).

**`identify`**

- `tag` (String?): Your own reference for this identification.
- `tiers` (List<SourceTier>, default `SourceTier.ALL`): `SourceTier.FAST`, `SourceTier.DEFERRED`, or both.
- `submit` (Boolean, default `true`): Set `false` to collect and compute verdicts without sending anything.

## The result

**`IdentifyResult`**

- `requestId` (String): Identifies this identification. Send it to your server with the action it protects.
- `visitorId` (String): The stable identifier the server resolved for this device.
- `visitorIsNew` (Boolean): Whether your organization is seeing this visitor for the first time.
- `visitorConfidence` (Int): How sure the identification is, from 0 to 100.
- `identifiable` (Boolean): `false` when the device gave too little to identify anyone.
- `duplicate` (Boolean): `true` when the server had already answered this request.
- `state` (String): `enriched`, or `unavailable` when the network lookup could not run.
- `suspectScore` (Int?): The server's weighted score. `null` when nothing was scored, which is not the same as `0`.
- `suspectLevel` (String?): `low`, `medium` or `high`.
- `triggers` (List<IdentifyTrigger>): The signals the server scored: `signal`, `group`, `weight` and `confidence`.
- `verdicts` (Verdicts): Local, advisory verdicts computed on the device.
- `report` (SignalReport): The 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. |

```kotlin Verdicts.kt
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
}
```

## 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](https://docs.fingerly.io/docs/mobile-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

```java SignInActivity.java
FingerlyBridge.configure(context, "fly_pk_us_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 */ }
});
```
