Mobile SDK

Native Android client that collects a broad set of device signals in your app and seals them into an encrypted envelope. Your app forwards the payload to your backend, which sends it to SignalGate exactly like a web event.

Quick Start

The SignalGate plugin for Claude Code wires the backend half — the server side that receives this payload — in minutes.

The plugin analyzes your backend repo, detects the stack, confirms placement with you, and writes the SDK client wiring and log() calls — review-first: it shows every diff, never commits, and never reads your key. It handles the backend half only; the Android capture on this page is the other half, added by hand.

/plugin marketplace add SignalGate/signalgate-claude-plugin
/plugin install signalgate@signalgate

Then, inside the backend repo you want to integrate:

/signalgate:integrate

Plugin source & README on GitHub

Then add the in-app capture below by hand.

Install

Two lines in your app-module Gradle file — the SDK from Maven Central, plus coroutines on your compile classpath: get() is a suspend function, so your own launch { } needs them to compile.

// app/build.gradle.kts
dependencies {
    implementation("ai.signalgate:android-sdk:0.1.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}

Floors: minSdk 23, compileSdk 35, JVM target 11. Licensed Apache-2.0. One note: Android Studio's Download sources does not yield usable sources — the Javadoc JAR and this page are the reference, and the public API is fully documented here.

Client setup

Build ONE client per process — Application scope — and reuse it. A second client pays for a second warm-up and gains nothing.

import ai.signalgate.android.SignalGate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

// Build ONE client per process (Application scope); reuse it.
val signalGate = SignalGate.Builder(context)   // context stored as applicationContext
    .key(BuildConfig.SIGNALGATE_TENANT_KEY)     // REQUIRED; blank throws IllegalArgumentException
    .cacheTtlMs(30 * 60 * 1000L)                // optional; default 30 min; <= 0 disables cache
    .debug(false)                               // optional; default false
    .build()

// Optional. Pre-initializes the slower collectors so the first real get() is
// not the one that pays for them. Idempotent.
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
    signalGate.start()
}

Builder options

OptionTypeDefaultPurpose
keyString— (required)Your SignalGate public key. Required — a blank value throws IllegalArgumentException at build().
cacheTtlMsLong30 * 60 * 1000L (30 min)How long a collection is reused before a fresh one is taken; <= 0 disables the cache. A fresh nonce and timestamp are minted on every get() even on a cache hit.
debugBooleanfalseAttaches the flat diagnostic view to results in debug builds. No effect in a release build.
The context you pass is stored as applicationContext, so passing an Activity leaks nothing. The optional start() warm-up is idempotent — safe to skip, safe to call twice.

Capture at the funnel point

Call get() at the funnel point — a login attempt, a checkout, a signup. It is a suspend function and dispatches its own work to Dispatchers.IO, so calling from a main-thread coroutine is safe.

lifecycleScope.launch {
    val result = signalGate.get()   // suspend fun; dispatches to Dispatchers.IO itself
    val payload = result.payload    // EncryptedPayload(v, encrypted, timestamp, nonce)

    // Forward to YOUR backend. The SDK sends nothing anywhere.
    myApi.submitDeviceSignals(payload.toJson())
}
get() throws only on a genuine crypto or configuration failure. There is no plaintext fallback by design: a failure is visible, never a silently degraded payload.

Payload shape

get() returns a FingerprintResult; its payload field is the four-field sealed envelope you forward.

data class EncryptedPayload(
    val v: Int,            // 2
    val encrypted: String, // base64 of the sealed envelope
    val timestamp: Long,   // epoch millis; equals the inner payload's ts
    val nonce: String,     // single-use
) {
    fun toJson(): String
}
Your backend forwards it in the payload field of /v0/log or /v0/check exactly as it does for web traffic — no server-side work specific to mobile.

Payload handling

Two things every integration must respect — both enforced server-side.

Never send the same payload twice

Each envelope may be submitted once; a resent envelope is rejected as a replay (422 REQUEST_REJECTED). Call get() again instead: it is cheap on a cache hit, and it mints a fresh nonce and timestamp every time.

Never persist the payload

No SharedPreferences, no file, no serialization path — the SDK's own cache is in-memory only and never holds the envelope. A payload you stored and replayed later is a payload the backend drops.

Permissions

The SDK manifest declares exactly two permissions. Both are normal-level, install-time: auto-granted, no runtime prompt, no user-visible dialog.

PermissionLevelPurpose
INTERNETnormal, install-timeDeclared, unused in v1 — reserved for a future transport so an already-integrated app never needs a manifest change.
ACCESS_NETWORK_STATEnormal, install-timePowers a small number of network-reachability signals. Removable — see the opt-out below.

Removing ACCESS_NETWORK_STATE (opt-out)

If your security review objects to ACCESS_NETWORK_STATE, strip it in your own app manifest — the manifest merger removes it and the SDK keeps working, at the cost of only the network-reachability signals it powers (no crash, no error, no malformed payload):

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"
                     tools:node="remove" />
</manifest>
Anything declared in the SDK manifest is inherited by your app and appears in your Play Data Safety form. A full per-signal disclosure for your privacy or legal review is available from us on request.

No network calls

get() returns the envelope and stops — the SDK performs zero network I/O of its own.

No transport in the SDK

There is no HTTP client, no endpoint constant, no retry queue and no send() on the public API. Nothing leaves the device through the SDK.

Your backend stays the integration point

The app forwards payload.toJson() to your own backend, which calls /v0/log or /v0/check — exactly as browser integrations do. The server side is covered on the Backend SDK page.

No check(), no verdict in the app

v1 collects, seals and returns. Verdicts are served to your backend, never to the device.

Debug builds

Debug builds can inspect the collected signals; release builds cannot, by construction.

data class FingerprintResult(
    val payload: EncryptedPayload,      // the only field populated in a release build
    val flat: Map<String, Any>? = null, // debug builds only
)
A release build retains no collected signals at all — the diagnostic view is not present in the release artifact.

Continue reading