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.
Evaluation identity and consent
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.