Skip to content
Console
Browse documentation
Guide

Android

Capture Android telemetry, remote flags, headless surveys and optional native structural replay without java.time desugaring.

On this page

The native Kotlin SDK supports Android minSdk 24 without java.time desugaring in your app. During early access, add the artifact and repository configuration supplied during onboarding.

Intake credentials

Set apiKey to a project-bound native_ingest key (ingest:write) and captureApiKey to a different mobile_capture key (analytics:write). Replace the example placeholders with your issued keys. Never bundle server, read or management credentials. Mobile capture declares a public-client origin; it does not establish trusted server authority.

Without captureApiKey, product events, identify, group and automatic lifecycle events are no-ops before identity changes or queue admission; OTLP remains available and configuration logs a warning. Malformed or shared capture keys reject configuration. Missing credentials do not erase queued product events: the flush result reports pending product positions. Consent withdrawal still purges the queue and remains in force after a key is added. Logout also refuses telemetry identity and session changes while the capture key is absent. See native intake keys for creation and verification.

Offline event delivery on Android

On Android, pending product events are bound to the capture endpoint and captureApiKey. Restarting with the same values retries the original events and IDs. Changing either value discards pending events with a warning, including a capture-key rotation within the same project. Flush and confirm success before switching or rotating. Changing only the OTLP apiKey preserves this queue.

Temporarily omitting the capture key on the same endpoint keeps pending events until that key returns; restoring a different key discards them. The first upgrade to recipient-bound persistence also discards older queues whose destination cannot be verified. Endpoint or key changes never renew withdrawn analytics consent.

Add the dependency

During early access, copy the supplied AAR into your app module's libs/anectico-sdk.aar, then add:

dependencies {
    implementation(files("libs/anectico-sdk.aar"))
    // Optional when using the Anectico OkHttp interceptor:
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

After the public Maven release, replace the local AAR with the exact published com.anectico:sdk:<version> coordinate supplied for your release.

The SDK's INTERNET permission is merged into the application manifest.

Initialize once

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Anectico.init(
            this,
            AnecticoOptions(
                apiKey = BuildConfig.ANECTICO_API_KEY,
                captureApiKey = BuildConfig.ANECTICO_CAPTURE_API_KEY,
                environment = "production",
                release = "com.acme.shop@2.4.1+241",
                dist = "241",
                enableCrashReporting = true,
                debug = BuildConfig.DEBUG,
            ),
        )
    }
}

serviceName defaults to the application ID and serviceVersion to versionName.

init reports failure rather than absorbing it — IllegalArgumentException for invalid configuration, AnecticoInitException (with the cause) for anything else that stops setup — so an app that starts is an app whose telemetry is really running. Methods called before a successful init record and send nothing: a debug build throws AnecticoNotInitializedException at the call site, and a release build logs the first one at ERROR. See the Android SDK API reference.

Use separate project-bound native_ingest and mobile_capture keys. Do not include read, management, or agent scopes in the application.

Connect identity and events

The first argument is the identifier. Use a stable id from your own system; it is stored exactly as sent. An email address sent as a property is stored as [REDACTED], so nobody can search by it or read it later. See What Anectico redacts and withholds.

Anectico.identify("user_8842", mapOf("email" to "buyer@acme.example", "plan" to "pro"))
Anectico.group("company", "acme", mapOf("plan" to "enterprise"))
Anectico.capture("checkout_started", mapOf("cart_size" to 3))
Anectico.screen("Checkout")
Anectico.captureLog(
    "checkout prepared for offline sync",
    severity = "info",
    attributes = mapOf("items" to 3),
)

// On logout:
Anectico.reset()

reset also clears global error-user context and buffered breadcrumbs, preventing the prior account's diagnostic context from attaching to the next account on a shared device.

Every captured event — not only the automatic $app_opened/$app_backgrounded lifecycle events — carries the reserved $release and $app_version properties, sourced from the same release/serviceVersion you pass to AnecticoOptions (or versionName when you don't set them explicitly). Neither is invented: an app that never configures a release sends events with neither property. See Reserved event properties.

captureLog sends OTLP logs to /v1/logs with the current customer, session and Android app, device and release context. Logs are atomically persisted before delivery, retain their occurrence time and stable anectico.log.id across process recreation, and retry network failures, HTTP 429 and HTTP 5xx responses in oldest-first order. Anectico.flush flushes both events and logs.

maxQueueSize is applied separately: the SDK can retain that many diagnostic events and that many logs, rather than splitting one count between them. The log spool also drops oldest past 10 MiB.

Send revenue

To measure money with revenue analytics, send $revenue and $currency as ordinary properties on the event for each payment:

Anectico.capture("purchase", mapOf("\$revenue" to 19.99, "\$currency" to "USD", "plan" to "pro"))

$revenue is a number in major units (19.99, not 1999), negative for a refund, with at most four decimal places; $currency is three upper-case letters. A value sent as text, with more decimal places, or with a lower-case currency is counted as invalid and left out of every figure.

In Kotlin, escape the dollar sign in the property names ("\$revenue").

Capture errors and upload mappings

try {
    checkout()
} catch (error: Exception) {
    Anectico.captureError(error, CaptureOptions(tags = mapOf("screen" to "checkout")))
}

Anectico classifies explicit captureError calls as severity error, handled, with mechanism java.caught_exception. The uncaught-exception handler classifies fatal crashes as severity fatal, unhandled, with mechanism java.uncaught_exception. Those values are SDK-owned and cannot be replaced by capture tags; unrelated application tags still appear on the occurrence.

The occurrence also includes SDK-owned app version and build, Android model and OS version, and Anectico SDK and platform context for the issue proof page. These are shared runtime descriptors, not physical-device identifiers; the SDK never collects Android ID, serial number, installation ID, or the user-assigned device name.

Fatal exceptions and failed manual error sends are spooled and retried on the next launch. Upload the exact R8 or ProGuard mapping for the captured release and dist:

anectico symbols upload-proguard mapping.txt --release "$RELEASE" --dist "$DIST"

The issue proof page identifies missing or mismatched mappings beside the raw R8 frames and shows the exact release and distribution keys to upload. An empty distribution is a real key; preserve it with --dist ''. After a mapping resolves, each recovered Original source entry remains paired with a Raw frame line containing the captured obfuscated function and location; R8 inline entries repeat that raw evidence in mapping order. The upload key requires errors:write.

Propagate to your backend

For OkHttp:

val client = OkHttpClient.Builder()
    .addInterceptor(com.anectico.sdk.okhttp.AnecticoInterceptor())
    .build()

For HttpURLConnection, call AnecticoPropagation.apply(connection). Send these headers only to trusted application services.

Verify

Identify a test user, capture one event and caught error, call Anectico.flush, and confirm the evidence shares one customer. Ask your agent to read that customer's timeline (get_person_timeline over MCP, or anectico persons timeline <customer-id>); the MCP result carries a link that opens the customer as a read-only proof page. Test a fatal crash only in a safe test build, then relaunch so the spool drains.

Delivery results

Wait for the completion or future returned by Anectico.flush or Anectico.shutdown. Inspect productEvents, nullable logs and traces independently, including all counts, warnings and unavailable reasons. Accepted intake is not stored evidence; read the test customer's timeline too. The Android delivery reference defines TelemetryFlushResult, ErrorCaptureResult, partial outcomes and retry limits.

Remote flags and headless surveys

Application reads use an explicit API endpoint and project. Provision two project-bound CLIENT_DELIVERY keys: flags:evaluate for flags and surveys:respond for survey delivery. They are separate from both intake keys. Your agent configures flags and surveys through its own MCP and CLI credentials; never put those credentials in an application. Agent evaluation uses flags:evaluate with mcp:read for MCP access. Delivery grants no definition, profile or answer reads.

Anectico.init(
    applicationContext,
    AnecticoOptions(
        apiKey = BuildConfig.ANECTICO_INGEST_KEY,
        captureApiKey = BuildConfig.ANECTICO_CAPTURE_KEY,
        endpoint = BuildConfig.ANECTICO_INGEST_ENDPOINT,
        applicationEndpoint = BuildConfig.ANECTICO_APPLICATION_ENDPOINT,
        projectID = BuildConfig.ANECTICO_PROJECT_ID,
        flagDeliveryApiKey = BuildConfig.ANECTICO_FLAG_DELIVERY_KEY,
        surveyDeliveryApiKey = BuildConfig.ANECTICO_SURVEY_DELIVERY_KEY,
        surveyScreenPaths = mapOf("Checkout" to "/checkout"),
    ),
)
Anectico.identify("user_8842")

val flags = requireNotNull(Anectico.flags)
flags.reload(listOf("new_checkout")).thenAccept { result ->
    if (result.status == ApplicationStatus.ACCEPTED) {
        val read = flags.value("new_checkout")
        val enabled = (read.decision?.value as? FlagValue.BooleanValue)?.value
        // Choose application behavior explicitly when enabled is absent.
    }
}

The two nullable controllers are configured independently. No delivery key means no controller. reload returns a CompletableFuture<FlagReloadResult>; never call get on the UI thread. Network work runs on background workers. A completion may run on the caller or worker; dispatch UI updates to your application's UI executor. A successful document contains projectID, evaluatedAt, maxAgeSeconds, decisionContextID, decisions and selected-key coverage. Each decision preserves its state, boolean or string value, payload presence, exact definitionVersion, fallback flag and input-context metadata. A present false value differs from absence; a present null payload differs from no payload.

flags.status(key) inspects without exposure; value(key, testCaseID?) and payload(key) are served reads. flags.boolean(key, fallback = false) returns BooleanFlagResult with the value, applicationFallbackUsed and the original typed read. Check read.status and read.decision.state: evaluation_error and unavailable_flag are distinct from an evaluated off value. Unknown, expired, malformed, cancelled and refused outcomes stay explicit.

Reload accepts 1–100 unique keys, ten groups, 16 KiB of targeting context, depth 16 and 250 entries per object or array. Decisions remain only in memory and expire at the original evaluation instant plus 30 seconds. There is one active flag request and at most one replacement waiting; identical work coalesces and further work returns BUSY. Retry advice prevents early network work; explicit calls during backoff return THROTTLED. Binding replacement cancels the prior wait. No local evaluation or polling occurs. FlagBootstrap(projectID, requestJson, decisionJson, evaluationGeneration?) must include the original requested keys, identity, properties and groups, match the current binding and project, and remain before its original expiry. flags.installBootstrap(bootstrap) never renews that expiry.

When collection is withdrawn or the capture key is absent, set the current application identity explicitly before requesting a decision:

flags.setEvaluationBinding("user_8842", personProperties = mapOf("plan" to "basic"))
val binding = flags.evaluationBinding() // immutable identity, properties, groups and generation
// To replace, call setEvaluationBinding again; to remove, call clearEvaluationBinding().

Without this binding the result is IDENTITY_UNAVAILABLE and no request is sent. Binding changes emit no identity event and do not change telemetry identity or session. Targeting inputs are application declarations. A shared customer identity does not imply equal assignments under different anonymous IDs or contexts. Identify admission does not prove that identity joining is complete.

Served reads admit $feature_flag_called only with consent and the matching current telemetry subject and context. Deduplication includes identity, key, value and exact definition version. Retries retain the original event, session, time and destination. A decision or local admission is not stored exposure proof; read back the original product evidence before drawing a conclusion. Public attribution of individual exposure attempts is still being completed; aggregate flush counts cannot identify a particular exposure.

Application-owned survey rendering

val surveys = requireNotNull(Anectico.surveys)
surveys.refresh("Checkout").thenAccept { result ->
    if (result.status == ApplicationStatus.ACCEPTED) {
        val offer = result.offers.firstOrNull()
        // Render offer.survey.questions in the application's own UI.
        // Only after actual display: offer?.shown()
        // After an explicit user response to a rating question named "score":
        // offer?.submit(listOf(SurveyAnswer("score", rating = 0)))
        // Or: offer?.dismiss()
    }
}

SurveyOffer exposes immutable survey.id, signed 64-bit survey.revision and typed questions. shown(id?), submit(answers, id?) and dismiss(id?) return CompletableFuture<SurveyDeliveryResult>. Accepted delivery contains SurveyReceipt(at, replayed). A receipt is accepted only after validating the full timestamp and replay status; omitted false means false. Supply all required answers. Question types are SINGLE_CHOICE, MULTIPLE_CHOICE, RATING and OPEN_TEXT. SurveyAnswer(questionId, rating?, optionIds?, text?) preserves integer zero; answer options must be distinct declared IDs. Text is normalized and limited to 2,000 characters. There are at most ten questions and twelve options per choice question.

Screen names must map to declared slash-prefixed templates of at most 512 characters, such as /checkout or /mobile/checkout. Do not map raw URLs or route arguments. Screen changes and explicit refreshes coalesce with a five-second minimum request gap. A result offers at most three surveys for this identity, screen and time. An empty accepted list differs from transport failure, an unavailable controller or SURVEY_IDENTITY_COVERAGE_UNSUPPORTED.

The first delivery freezes its ID, answers, offered revision, subject, optional source session, environment and destination. Retry the same method on the same handle after retry advice permits; replacement arguments cannot change an uncertain delivery. There is no automatic answer retry loop. Permanent refusals close the handle. Answers remain only in memory and are cleared on completion, identity change, withdrawal, shutdown or reconfiguration. The application must render again and obtain a new response after retirement. The SDK emits no duplicate survey outcome events. A receipt precedes eventual query visibility; multiple selected options are not separate responses.

Anectico.disableAnalytics() closes survey offers, purges answer and exposure memory and invalidates implicit flag work. Restoring consent does not admit earlier refused identity changes. Anectico.configureApplication(ApplicationOptions(...)) replaces application configuration; passing null retires both controllers. This validates synchronously and leaves existing telemetry destinations bound to their admitted events. Shutdown cancels application work. Logs and errors retain their independent controls.

Time values

The SDK supports Android minSdk 24 without core-library desugaring for java.time in the consuming application. This also applies to its copies bundled with React Native and Flutter. Public FlagDecisionDocument.expiresAtMs is a Long containing Unix epoch milliseconds; retry delays use milliseconds. Timestamp strings in responses and framework bridge values retain their RFC 3339 representation and fractional precision.

The source build and JVM protocol tests do not establish package publication, device behavior or stored cross-platform evidence. The Android archive remains an early-access installation.

Verify this source and deployment

Follow mobile evidence verification for exact flag versions, originating message and source IDs, aliases and per-device sessions. Decisions, exposure admission, survey receipts and stored evidence are separate checks. The native flag API has no public per-exposure receipt; original-attempt attribution remains unqualified. An ID discovered only after storage and aggregate flush counts cannot replace it.

The client preserves optional survey source sessions and recognizes SURVEY_IDENTITY_COVERAGE_UNSUPPORTED; the server propagates source sessions and refuses incomplete aliases. Independently verify stored correlation on your deployment. Earlier web or REST runs do not qualify the changed client-delivery authority. Treat an empty eligible list, transport failure, typed refusal and permanent delivery rejection separately. Retention, content policy, erasure, export and local withdrawal still apply; withdrawal does not erase data already stored. Read existing person and result proof links where supplied; flag evaluation has no proof page.

Use explicit private ingestion and application HTTPS hosts and the approved private network path. Do not expose the API publicly or put operator credentials into a build. Pin source revisions; source coordinates, package artifacts and core test results are not proof of package publication, device installation or stored cross-device qualification. React Native and Flutter have typed native delivery bridges.

An isolated API 36 x86_64 Android emulator run separately builds and installs the linked apps, verifies native HTTPS delivery and reads the resulting person timeline. True, false and variant exposures, a changed version with the same true value, survey answers and dismissals preserve original sessions and join one person across the three apps. Physical devices, other runtime configurations, public original-attempt attribution and published-package installation remain separate qualification.

Distinct decision and answer examples

Configure independent definitions before expecting enabled true, disabled false and checkout_variant equal to the string treatment for the current ID and context. They are example keys, not built-in definitions. A missing key is unavailable; a deliberately failed dependency has an evaluation-error default and no authoritative version. Explicit fallback use must stay observable rather than being mistaken for evaluated false.

flags.reload(listOf("enabled", "disabled", "checkout_variant")).thenAccept { result ->
    val enabled = flags.boolean("enabled", fallback = false)
    val disabled = flags.boolean("disabled", fallback = true)
    val variant = flags.value("checkout_variant", testCaseID = "mobile-variant")
    // Check result.status and typed read metadata; move UI work to your UI executor.
}

For an actual offered survey whose questions are plan (single choice with option pro), features (multiple choice with options speed and support), score (rating 0–10) and comment (open text), these are the four answer shapes. Read the offered questions and use what the visitor actually selected; do not submit these constants automatically:

val answers = listOf(
    SurveyAnswer("plan", optionIds = listOf("pro")),
    SurveyAnswer("features", optionIds = listOf("speed", "support")),
    SurveyAnswer("score", rating = 0),
    SurveyAnswer("comment", text = "Checkout was clear"),
)
// In the actual display callback: offer.shown()
// In the explicit user-submit callback:
val delivery = offer.submit(answers)
// Alternative user-dismiss callback, not after submitting: offer.dismiss()

A rating of zero is present. This one response generates five answer event rows: one single choice, two selected options, one rating and one text. It remains one accepted response. The application owns rendering and callbacks. Do not record shown merely because refresh returned an offer, or dismiss a survey already submitted. Retry the same uncertain operation on the same handle with its original identity, optional session, revision, answers and ID.

Native structural replay on Android

The Kotlin recorder implements native_structural version 1 (NR1). Enable it only against a deployment that serves current native capture policy, enforces independent native content storage permission and accepts the separate project-bound REPLAY_UPLOAD key with only replay:write. Existing ingestion and delivery keys cannot upload replay. No native device, playback, retention or erasure qualification follows from a JVM test or an upload receipt.

Anectico.init(applicationContext, AnecticoOptions(
    apiKey = BuildConfig.ANECTICO_INGEST_KEY,
    captureApiKey = BuildConfig.ANECTICO_CAPTURE_KEY,
    endpoint = BuildConfig.ANECTICO_CAPTURE_ORIGIN,
    nativeReplay = NativeReplayOptions(
        endpoint = BuildConfig.ANECTICO_REPLAY_ORIGIN,
        projectID = BuildConfig.ANECTICO_PROJECT_ID,
        uploadApiKey = BuildConfig.ANECTICO_REPLAY_UPLOAD_KEY,
        enabled = true, // default false; still needs current owner, consent and remote permission
        declaredLabels = mapOf("checkout.continue" to "Continue"),
    ),
))
val replay = requireNotNull(Anectico.replay)
replay.bindReplayOwner(currentApplicationUserID)
replay.grantReplayConsent() // obtain separate replay consent first, independently of analytics
replay.configureReplayView(privatePanel, "private", ReplayViewTreatment.BLOCK)
replay.configureReplayView(surveyAnswerPanel, "survey_answer", ReplayViewTreatment.MASK)
replay.configureReplayView(continueButton, "continue", actionLabelKey = "checkout.continue")
replay.refreshReplayPolicy().thenAccept { allowed ->
    if (allowed) activity.runOnUiThread {
        // Use the visible application content root, excluding system windows and keyboards.
        replay.observeReplayView(contentRoot, "checkout")
    }
}
// In existing application callbacks, after a complete frame:
continueButton.setOnClickListener { replay.recordReplayTap(continueButton) }
// A text-change callback supplies the View only, never its value or length:
// replay.recordReplayInputChange(editableView)
// replay.replayInputFocusLost(editableView) on focus loss if not using observeReplayView.
// A scroll callback may call replay.recordReplayScroll(scrollView).

The recorder reads geometry and explicit declarations, never UI text, accessibility values, images, URLs, input characters, their length, selections or toggle state. Labels come only from the local static map, intersect remote eligibility, and are dropped if source redaction changes them. Treat declarations as untrusted application evidence. Mask and block rules always win over labels and descendants. Mark intentionally submitted survey-answer regions private too.

Exact standard Android View classes are covered by source tests: FrameLayout, LinearLayout, RelativeLayout, TableLayout, TableRow, TextView, Button, ImageButton, EditText, CheckBox, RadioButton, Switch, ToggleButton, ImageView, ScrollView, HorizontalScrollView, View and Space. Custom subclasses, WebView, Compose, textures and other embedded renderers produce visible omissions. A View traversal does not qualify Compose or any bridge adapter. Screens and node keys are static template tokens, never route arguments, account IDs or incidental text. Coordinates are clipped quarter-dp units.

Observation runs at most twice per second, coalescing before traversal. Each traversal visits at most 256 nodes and depth 32, or drops the whole frame with a gap. Native events are limited to 20 per rolling second; each event is at most 65,536 bytes. Local admission freezes one chunk per atomic batch, at most 16 candidate events and one frame, below NR1's 64-event, eight-frame and 262,144-byte chunk maxima. The separate sandbox spool holds at most eight chunks and 2 MiB including envelopes, for at most 60 seconds from original admission. Flush attempts begin within two seconds; retries keep identical bytes, index, owner and recipient. Effective remote or local ceilings may lower these safety maxima. They are not measured device rates or allowances.

diagnostics() reports disabled, recording, paused or refused, bounded loss counters, queued bytes, policy revision and the latest typed upload outcome. A gap is unknown content, never an empty successful visit. HTTP success releases a chunk only after every public receipt binding matches, including the SHA-256 of the exact outgoing event array. A mismatched or incomplete receipt keeps that item for bounded retry. ACCEPTED may include server discard and proves no retained content. flushReplay() is asynchronous and separate from Anectico.flush; never block the UI on its future.

Background, source changes, owner switches, reset, policy failure or expiry, configuration retirement and shutdown invalidate old work and purge unsent replay. Reattach the current View root after these transitions and renewed permission. A valid policy renewal rotates the recording and resumes an owned observation only under its new fence and rules; closing that observation stays final. An unrelated identify attempt fences replay even when analytics rejects it; the prior telemetry identity and session stay unchanged. reset() clears replay binding and consent. Bind the next owner and explicitly renew replay consent. Binding itself sends no analytics identity event and cannot establish an anonymous-person alias link.

withdrawReplayConsent() stops recording and purges local replay; false means purge needs repair. Ordinary reconfiguration cannot renew withdrawn consent. A restart discards the prior replay spool and requires fresh binding, consent and policy. These NR1 replay rules differ from durable product event retry. No stop or local purge proves server erasure; already dispatched requests can arrive. Request and verify server erasure separately. Raw native recording content is excluded from person archives. Read stored steps and proof only where your deployment implements this format, with replay:read and replay:content:read; do not infer full coverage or retention from upload.

External adapters obtain sourcePrivacy() and apply its effective static equality rules before encoding any candidates. Submit only the closed NR1 grammar with no ordinals, using the current opaque context and the next source sequence. The snapshot contains no key or owner authority; its lifetime is fenced by the native context. Native rechecks shape, references, policy, eligible labels, whole-source masks, bounded admission and recipient ownership. An external adapter must prove its own node-specific source masking; selecting external mode does no View traversal.

A View capture whose canonical nodes equal the latest admitted frame can return ReplaySubmission.Unchanged: it adds no event, chunk or upload and consumes no rate budget. Actions keep targeting the latest admitted frame. Screen and viewport events, gaps and privacy or policy changes clear that comparison. View facts are read on main; the explicit synchronous capture waits for worker processing and durability. External asynchronous submissions may keep an open chunk for up to the scheduled 2-second persistence boundary. Replay flush, background and shutdown seal it; privacy purges drop it. A fresh process requires renewed owner, consent and policy, purges retired replay and reports unknown loss after an interrupted open chunk.