The Android SDK
The official Android SDK for events, identity, push and in-app messages.
The Android SDK records events, resolves identity, receives push notifications and draws in-app messages. It is three Gradle modules with a sample app that calls them exactly the way your app would.
How it reaches your build
The SDK ships as source, from the repository, rather than from a package repository. You add it once and then declare the ordinary coordinate:
implementation("net.segmentic:segmentic-android:0.1.0")
Two routes get you there and both are written out in full, with every command, under adding it to your app: a composite build with includeBuild, or a local publish with publishToMavenLocal. Pick the first if the SDK repository sits beside your own, the second if it does not.
Everything below describes code that has run, on an Android 15 emulator rather than on a diagram: a real Firebase token registered through POST /v1/devices, a message delivered by FCM v1 with the app both in the foreground and in the background, and an in-app banner drawn and then stopped by its own frequency cap.
Three modules
| Module | What it is | Where it is tested |
|---|---|---|
segmentic-core | Pure Kotlin, without a single Android import. The queue, the backoff, the wire format, the in-app message rules, the tri-state permissions | A plain JVM, in milliseconds, with no emulator |
segmentic-android | The thin Android layer: where files live, what the device reports, which thread the work runs on, and the in-app renderer | A device or an emulator |
sample | An app that calls the SDK exactly as a customer would. Never published | An emulator |
The reason for the split is written in settings.gradle.kts itself: every rule that can be got wrong lives in the first module and is tested on a plain JVM. A rule that can only be checked with an emulator is a rule that gets checked less often.
You declare only segmentic-android. segmentic-core arrives with it, because the Android module's POM declares the dependency.
Adding it to your app
Route one: a composite build
If the SDK repository sits next to your own, this is the simplest path and needs no publishing at all. Gradle substitutes the coordinate with the local project.
includeBuild("../segmentic/sdk/android")
dependencies {
implementation("net.segmentic:segmentic-android:0.1.0")
}
Route two: a local publish
Run this once in the SDK repository:
cd segmentic/sdk/android
./gradlew :segmentic-core:publishToMavenLocal :segmentic-android:publishToMavenLocal
Four files land in ~/.m2/repository/net/segmentic/. Then add mavenLocal() in your own app:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}
dependencies {
implementation("net.segmentic:segmentic-android:0.1.0")
}
Both modules also publish a sources.jar, on purpose: the comments inside segmentic-core explain why the queue sheds from the front and why a 4xx is dropped, and somebody debugging their own integration at two in the morning should be able to read that in their IDE rather than guess it.
When a repository does exist, this is the publish command, and nothing else in the code changes:
./gradlew publish \
-PsegmenticRepoUrl=https://example.invalid/maven \
-PsegmenticRepoUser=... \
-PsegmenticRepoPassword=...
No dependencies, and what that buys
segmentic-core declares no production dependencies. Zero. No JSON library, no HTTP client. segmentic-android declares exactly one, and it is segmentic-core.
One point the SDK's own README does not make, and this page has to: both published POMs declare org.jetbrains.kotlin:kotlin-stdlib:2.0.21 at compile scope. So "no third-party dependency" is true, and "no dependencies at all" is not literally true. Every Kotlin app already resolves the Kotlin standard library.
What it buys is one specific thing: the SDK cannot pick a library version on your behalf. A library that drags in a JSON parser or an HTTP client can collide with the version your own app already uses, and then you are the one debugging our dependency tree.
There are no resources inside the library either: buildConfig = false and androidResources = false. That is why the close button on an in-app message is the character × rather than an icon; an icon would have been the first resource to enter your APK.
consumer-rules.pro is deliberately empty of rules. Nothing is reflected over and nothing is loaded by name, so R8 is free to shrink and rename all of it. The file exists so that stays a recorded decision rather than something somebody has to re-derive later.
Measured sizes, from a real publishToMavenLocal:
| File | Bytes |
|---|---|
segmentic-android-0.1.0.aar | 31730 |
segmentic-android-0.1.0-sources.jar | 6705 |
segmentic-core-0.1.0.jar | 54948 |
segmentic-core-0.1.0-sources.jar | 19996 |
The AAR holds five entries: AndroidManifest.xml, classes.jar, an empty R.txt, proguard.txt and one metadata file. No resources, exactly as the build file promises.
The number we do not have: the SDK's real cost inside your app, meaning its method count or its dex delta after R8. No such measurement exists in the repository. We have the AAR and jar sizes, and those are what is written above.
Platform floor: minSdk = 24 (Android 7), compileSdk = 35, Java 17 for source and target. Going lower would mean carrying desugaring rules into your build for a share of devices that is now under one percent.
What it adds to your manifest
Two things, and nothing else.
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<package android:name="com.google.android.gms" />
</queries>
INTERNET is a normal permission and prompts the user for nothing.
ACCESS_NETWORK_STATE is deliberately not declared. It would let us report whether the connection is cellular or wifi, which is a nice column and not worth quietly adding a permission to somebody else's manifest. The code reads it only when your own app already holds it, and in that case context.network is attached to events. If you do not hold it, the key is simply never sent.
The queries block is needed because Android 11 hides other packages. Without it, the Play Services probe raises NameNotFoundException on a phone that does have Play Services, we report has_gms=false for it, and every push for that device is routed away from FCM for no reason.
Initialisation
Once, in Application.onCreate:
package com.example.shop
import android.app.Application
import net.segmentic.sdk.SegmenticOptions
import net.segmentic.sdk.android.Segmentic
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Segmentic.init(
this,
SegmenticOptions(
writeKey = "wk_seg_...",
apiHost = "https://in.segmentic.net",
),
)
}
}
And in your own manifest:
<application android:name=".MyApp">
</application>
Three things that cost time if you do not know them:
Pass the Application, not an Activity. The code takes context.applicationContext, but it needs the Application itself to follow which Activity is in front. If what you passed is not an Application, one warning line goes to logcat and in-app messages can no longer be drawn. Everything else keeps working.
A second init is ignored. The method is @Synchronized and the second call only logs "init called twice, ignoring the second call". This differs from the web SDK, where a second init closes the previous client and replaces it.
init does one small read on the calling thread, to load the queue left over from last time. That is why its place is Application.onCreate: a few milliseconds there is normal, and the alternative, a racy getter, is worse.
Options
SegmenticOptions is a data class in segmentic-core. Every default is the web SDK's default, on purpose: a customer running both should not see two different batching stories in the same dashboard.
| Option | Type | Default | Meaning |
|---|---|---|---|
writeKey | String | none, required | The write key from the panel. Public by design, it may ship in the APK |
apiHost | String | none, required | The collector base URL, for example https://in.segmentic.net |
batchSize | Int | 20 | Send as soon as this many messages are buffered |
flushIntervalMs | Long | 10_000 | The longest gap between two sends |
maxQueueSize | Int | 500 | How many messages may wait on disk |
maxRetries | Int | 10 | How many consecutive failures before the retry cadence stops growing |
autoContext | Boolean | true | Attach app, OS, screen, locale and timezone to every message |
sessionTimeoutMs | Long | 30 * 60_000 | The idle gap after which the next event starts a new session |
debug | Boolean | false | Log to logcat under the tag segmentic |
This is the only place the SDK throws. The SegmenticOptions constructor raises IllegalArgumentException on a blank writeKey or a blank apiHost. That is deliberate and it happens when you construct the object, not later inside a track(). No other method throws under any circumstances.
Values that cannot be honoured are brought into range before anything else runs:
| Field | Coerced to |
|---|---|
apiHost | trailing / stripped |
batchSize | between 1 and 1000 |
flushIntervalMs | at least 1000 |
maxQueueSize | at least batchSize |
maxRetries | between 1 and 100 |
sessionTimeoutMs | at least 1000 |
The maxQueueSize floor is not cosmetic. At least one full batch has to fit, or a queue that is already full could never assemble a send and the buffer would drain only by dropping.
Three options the web SDK has and this one does not: autoPageView (an app has no page), respectDoNotTrack (Android has no such signal) and onsite. In-app messages are always on and are evaluated on every screen().
Every public method
Segmentic is a Kotlin object and every method is @JvmStatic, so Java callers see ordinary statics.
val isInitialised: Boolean
fun init(context: Context, options: SegmenticOptions)
fun track(event: String, properties: Map<String, Any?>? = null)
fun screen(name: String, properties: Map<String, Any?>? = null)
fun identify(userId: String, traits: Map<String, Any?>? = null)
fun alias(previousId: String)
fun reset()
fun optOut()
fun optIn()
fun isOptedOut(): Boolean
fun registerDevice(tokens: Map<String, String>, hasGms: Boolean? = null)
fun dismissOnsite()
fun flush()
fun stats(): SegmenticStats?
fun anonymousId(): String?
fun userId(): String?
fun shutdown()
A complete example, the way a retail app calls it:
import net.segmentic.sdk.android.Segmentic
// A screen view, which is also the moment an in-app message is decided
Segmentic.screen("cart", mapOf("items" to 3))
Segmentic.track(
"product_viewed",
mapOf("product_id" to "DK-991", "price" to 18_500_000, "currency" to "IRR"),
)
// After sign-in: an alias is queued automatically and the anonymous history joins this user
Segmentic.identify(
"u_123",
mapOf("email" to "ali@example.com", "city" to "شیراز"),
)
Segmentic.track("order_completed", mapOf("revenue" to 2_500_000, "currency" to "IRR"))
// Sign-out
Segmentic.reset()
Behaviours worth knowing:
- A blank name is ignored, not thrown.
track(""),screen("")andidentify("")write one log line and return. An analytics call must never be the reason a customer's checkout page breaks. - The first
identifyqueues analiasmessage ahead of itself. Only when theuserIddiffers from the one already stored. Without it every event from before the first sign-in belongs to a stranger, and any funnel crossing the login boundary reports the wrong number for ever after. The detail is in identity. resetmints a newanonymousId, clears theuserIdand drops the session. On a shared phone the next person's purchase must not be credited to the one who just left. It does not clear the "has launched before" flag: signing out does not make somebody a new user, and a first-launch campaign must not reappear after every sign-out.flush()returns nothing. It hands the work to the SDK's network thread and returns immediately. This differs from the web SDK, which returns aPromise. If you need to know what happened, readstats().- Any method before
initwrites oneLog.wand returns. Nothing throws and nothing is buffered. shutdown()is not for an app. It shuts both executors down and nulls the client. It exists for a customer's own instrumented tests: an app that is being killed does not need to tidy up, and the queue is already on disk.
stats() returns these nine fields, and null before init:
| Field | Type | What it is |
|---|---|---|
queued | Int | How many messages are waiting on disk right now |
sent | Long | How many messages the collector has accepted |
dropped | Long | How many were dropped, by a full queue, a full disk, or a permanent server refusal |
consecutiveFailures | Int | Consecutive failures |
optedOut | Boolean | Whether collection is stopped |
durableStorage | Boolean | Always true on Android, because FileStore is used |
anonymousId | String | The current anonymous id |
userId | String? | The signed-in user, or null |
devicePending | Boolean | A device registration has not landed yet and is being retried |
Threads
This is the part a customer feels.
track,screen,identifyand the rest return immediately. The disk write happens on a thread calledsegmentic-work, so no analytics call is ever an I/O call on the main thread.- Network is a second thread,
segmentic-net. A slow or absent collector cannot make atrack()wait behind a socket. - Both executors are single-threaded daemons at
Thread.MIN_PRIORITY. Daemons, because our timer must never be the reason a process stays alive. - The flush timer is a
scheduleWithFixedDelayatflushIntervalMs, andinitfires one flush immediately so anything left over from last time goes out at once. A pending device registration is retried in the same pass. - When the queue reaches
batchSize, the send starts at once rather than waiting out the interval.
There is exactly one place an exception is swallowed, and it is deliberate: everything that runs on the SDK's threads is inside a try/catch that logs at error level. Those threads live inside the customer's process, and an uncaught throwable on a background thread takes their whole app down. Doing that over a failed analytics write would be indefensible.
The offline queue
On an Iranian mobile network a device stays offline for hours at a time. Every message is written to disk before any network attempt, so the app being killed, losing signal and a backend outage cost no data at all.
The stored form is one line per message:
<message_id>\t<the message, already encoded as JSON>\n
Two things follow, and both are the point:
- Nothing is ever parsed. The message was encoded once, when it was queued, and the same bytes reach the collector. A value that survived encoding cannot be mangled by a round trip through storage.
- A write cut off halfway costs exactly the last line. Every complete line before it still loads.
Two checks run at load: a line with no tab is skipped, and a payload that does not both start with { and end with } is skipped. The second exists because half a message, if sent, is refused by the collector as malformed and takes the whole batch behind it down with it.
Where it lives. One file per key, under filesDir/segmentic/ in your app. Not the cache and not external storage: the OS may clear the cache whenever it likes, and events waiting out an outage are not cache. Losing them is losing the customer's data.
The write goes straight to the destination, with no temporary file and no rename. That looks careless and is a decision: renameTo cannot replace an existing file on every Android storage driver, and java.nio.file.Files.move needs API 26 while this SDK's floor is 24. Rather than avoid a truncated write, a truncated write was made harmless.
When the queue fills, it sheds from the front. After a long outage the freshest events are the ones still worth having. The count is reported in stats().dropped and is never silent.
Three drop reasons, all of which also appear in the debug log:
| Reason | When |
|---|---|
queue_full | maxQueueSize was exceeded |
storage_full | the disk write failed, half the buffer was shed and the write retried |
storage_unavailable | the second write also failed. Work continues in memory and what is held is at risk |
De-duplication. message_id is a UUID generated once, at enqueue time, and reused on every retry. That is the entire basis for a resend being safe: without it, a resend on a weak network doubles the customer's purchase count.
HTTP responses, and what the SDK does with each:
| Response | Behaviour |
|---|---|
2xx | Accepted, removed from the queue, the failure counter resets |
4xx except 429 | Dropped permanently, acked off the queue, counted in dropped, with one log line. A body the server refuses will never be accepted, and keeping it blocks every event behind it |
429 | Stays queued and is retried |
5xx | Stays queued and is retried, with backoff |
code 0 | Means there was no HTTP response at all: no signal, a DNS failure, a captive portal. Kept separate from a real status, because collapsing the two is how an SDK ends up retrying a 400 for ever |
The full list of codes and what they mean is in errors.
Backoff is full jitter: base one second, ceiling five minutes, and the exponent is capped at 20 before it is applied so a device that has been offline for months cannot overflow its way to a negative delay. The jitter matters more than the curve: when the backend recovers, thousands of devices that failed at the same moment must not retry at the same moment and knock it over again.
maxRetries caps the cadence, not the data. Once the cap is reached the messages stay queued for the next session. The user may simply be on a train.
Two flushes never run at once. A second flush returns 0 immediately rather than queueing behind the first, because two concurrent drains would each peek the same messages and send them twice. This also differs from the web SDK, which chains them.
Network timeouts: connect ten seconds, read fifteen seconds. Both are set, because a socket with no read timeout on a mobile network can hang for minutes on a half-open connection, and this runs on a thread the SDK owns: hanging it means the queue stops draining with no error anywhere. Response bodies are read to a limit of 8 KiB, because a proxy or a captive portal can answer our POST with a megabyte of HTML.
What goes on the wire
POST {apiHost}/v1/batch with Authorization: Bearer wk_seg_... and Content-Type: application/json; charset=utf-8.
sent_at appears both on the envelope of the batch and on every message inside it. That one field is what lets the collector correct a wrong device clock: the offset it measures against its own time is applied to the event timestamps too. A phone whose date is two years out still produces usable data.
sent_at is spliced into the front of the already-encoded message rather than the message being re-encoded. The message may have been encoded days ago, and re-encoding it would mean parsing it back, which this module deliberately cannot do. Splicing one known field in is exact, because our own writer always emits {"type": first and never puts a space after the brace.
Nulls and empty maps are left out entirely. A batch of twenty events each carrying six empty objects is a bigger body on a metered connection, and the customer pays for that data, not us.
This body is not an invented sample. These are the bytes an Android 15 device put on the wire during the SDK's offline test, kept in the repository as a golden file:
{
"sent_at": "2026-08-07T11:01:47.885Z",
"batch": [
{
"sent_at": "2026-08-07T11:01:47.885Z",
"type": "track",
"message_id": "ff6447a2-6cc3-48b2-a429-f83cb07e126d",
"timestamp": "2026-08-07T11:01:10.609Z",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"event": "scripted_event",
"properties": { "index": 10, "note": "رویداد آزمایشی" },
"context": {
"library": { "name": "segmentic-android", "version": "0.1.0" },
"session_id": "4b3754ac-66cf-4ecf-a700-fc095072c8e5",
"app": { "name": "net.segmentic.sample", "version": "0.1.0" },
"os": { "name": "android", "version": "15" },
"device": {
"type": "android",
"manufacturer": "Google",
"model": "sdk_gphone64_x86_64"
},
"screen": { "width": 320, "height": 640, "density": 1 },
"locale": "en-US",
"timezone": "Asia/Tehran"
}
}
]
}
Five message types exist: track, identify, screen, alias and page. The value page is in the wire enum but no public method on Android ever sends it; page belongs to the web.
context.library and context.session_id are always present, and the platform context is merged under them, never over. A platform collector must not be able to rename the library that sent the message. There is a hostile test for this: a platformContext that deliberately returns a forged library and session_id, and the test asserts neither value reaches the wire.
What autoContext collects: app (package name and version), os, device (manufacturer and model), screen (pixels and density), locale, timezone, and network only when your app already holds ACCESS_NETWORK_STATE.
Timestamps are computed from the epoch rather than formatted with SimpleDateFormat. That class is not thread safe and this is called from whichever thread the customer happened to call track() on. A shared instance produces scrambled timestamps under load, which is the kind of bug that only shows up in production and looks like a server problem.
The JSON writer is hand-written, with a depth limit of 32. NaN and infinity become null rather than an invalid body. Persian text is written through unescaped, and newlines are always escaped, which is what the line-based queue format rests on.
Your app supplies the push token
This is the thing that usually costs a developer an hour, so it is stated plainly: this SDK does not fetch push tokens. You hand them in.
The reason is a decision worth knowing about. An app that sends push already has Firebase, or Bazaar, or Myket, wired up with its own project and its own version of that library. Fetching the token ourselves would mean this SDK picking a Firebase version on your behalf and colliding with yours. So you hand us what your own onNewToken gave you.
The whole customer side is nine lines:
package com.example.shop
import com.google.firebase.messaging.FirebaseMessagingService
import net.segmentic.sdk.PushTransport
import net.segmentic.sdk.android.Segmentic
class MyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
}
}
Call it again every time the provider rotates the token. Registering the same device again is not a duplicate: the server upserts on device_id. The rotation case is the one that matters, because a token that rotates and is never re-registered is a user who silently falls out of every campaign, and the only symptom is a delivery rate that drifts down over months.
Spell the transports exactly this way. They come from push.Transport in the Go code:
object PushTransport {
const val FCM = "fcm"
const val BAZAAR = "bazaar"
const val MYKET = "myket"
const val MQTT = "mqtt"
}
Sending anything else is not a typo that gets ignored: the server warns and drops the token, and the customer then sees a campaign that reports every send as successful and delivers nothing. If you send a transport that cannot reach Android, apns for instance, the response carries a transport_not_supported warning.
Do not send MQTT. The constant exists on both sides and the server accepts it on an Android registration, but no provider implements it and nothing is ever delivered over it. Only fcm, bazaar and myket have a sender today. A registration carrying only mqtt is stored without a warning and never delivered to, which is the worst of the three outcomes: no error, no warning, just silence.
Several routes on one device are supported, and the server decides which one delivers. A phone sold without Play Services still has Bazaar:
Segmentic.registerDevice(
mapOf(
PushTransport.FCM to fcmToken,
PushTransport.BAZAAR to bazaarToken,
),
)
If your app already depends on play-services-base, answer authoritatively yourself. The SDK's own probe exists only so the SDK needs no Google dependency:
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
val gms = GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
Segmentic.registerDevice(mapOf(PushTransport.FCM to fcmToken), hasGms = gms)
Device registration
POST {apiHost}/v1/devices, with the same Authorization: Bearer wk_seg_... header.
The fields, in the order the SDK writes them:
| JSON field | Always present | Source |
|---|---|---|
device_id | yes | segmentic_install_id, a random UUID in the app's private storage |
platform | yes, always "android" | hardcoded |
user_id | only when a user is signed in | filled in by the SDK |
anonymous_id | only when known | filled in by the SDK |
tokens | only when non-empty | you |
has_gms | only when known | the package probe, or the value you passed |
push_enabled | only when known | NotificationManager.areNotificationsEnabled() |
app_version | when readable | PackageManager |
manufacturer | yes | Build.MANUFACTURER |
model | yes | Build.MODEL |
os_name | yes, always "android" | hardcoded |
os_version | yes | Build.VERSION.RELEASE |
locale | yes | Locale.getDefault().toLanguageTag() |
timezone | yes | TimeZone.getDefault().id |
sdk_name | yes, always "segmentic-android" | hardcoded |
sdk_version | yes, always "0.1.0" | hardcoded |
Identity is filled in by the SDK, not by the caller, so the host app cannot register a device against a user id that has since signed out.
A real body, from the same emulator run:
{
"device_id": "79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2",
"platform": "android",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"tokens": { "fcm": "scripted-token-not-a-real-one" },
"has_gms": true,
"push_enabled": false,
"app_version": "0.1.0",
"manufacturer": "Google",
"model": "sdk_gphone64_x86_64",
"os_name": "android",
"os_version": "15",
"locale": "en-US",
"timezone": "Asia/Tehran",
"sdk_name": "segmentic-android",
"sdk_version": "0.1.0"
}
And a successful response:
{ "status": "ok" }
If a token carries the wrong transport, the response is still 200, but it carries a warning and the usable token is still stored:
{
"status": "ok",
"warnings": [
{
"code": "transport_not_supported",
"field": "apns",
"message": "transport apns cannot deliver to android"
}
]
}
The SDK writes those warnings to logcat even when the response is a success. Silence here is what turns a broken install into a campaign that reports 100 percent sent.
has_gms and push_enabled are tri-state, and are omitted entirely when unknown. Not false. The server reads a missing value as unknown, and unknown is not false. Sending false where we simply did not look would mute a user who never asked to be muted, and the only symptom would be an audience that quietly shrinks.
has_gmsistruewhen the Play Services package is found,falseonNameNotFoundException(which is an ordinary Iranian phone, not an error), andnullon any other exception.push_enabledis the value ofareNotificationsEnabled(), andnullwhen theNotificationManagercannot be reached at all.
A registration has three outcomes, and the SDK keeps them apart:
| Result | When | What happens on disk |
|---|---|---|
REGISTERED | 2xx | the pending record is removed |
REFUSED | 4xx except 429 | the pending record is removed. The same body would be refused identically on every launch, so retrying is a loop that never ends and never works |
PENDING | 429, 5xx, or code 0 | the body is written to segmentic_pending_device and resent on every flush and every subsequent launch |
Why a registration is retried when an event is queued instead: the collector's own comment on the endpoint says the SDK has to retry, because nothing else ever will. An event that arrives late is still the event, but a token that never arrives is a person who agreed to be notified and can never be reached.
device_id is deliberately neither the advertising id nor Settings.Secure.ANDROID_ID. Both identify a person across unrelated apps, which is a privacy question the customer has to answer rather than us, and Google restricts the first one anyway. This is a random value in the app's own private storage: it lasts until the app is uninstalled or its data is cleared, and it never follows the user anywhere else.
The manual registration path, for every platform with no SDK, is in devices and push.
In-app messages
A banner or a modal built in the panel is drawn in the app automatically. The only thing asked of you is this:
Segmentic.screen("cart")
A screen view is also the moment an in-app message is decided, because the screen name is what a campaign's rule matches against. There is no option to turn it off.
The rest happens on three threads: the fetch on the network thread, the decision, which is a small disk read, on the same thread, and the drawing on the main thread after whatever delay the campaign asked for.
Fetching the list. GET {apiHost}/v1/onsite?write_key=..., with an Authorization header as well. The key is in both places on purpose: the query form is what makes the response cacheable by a CDN that will not vary on Authorization. The list is trusted for 60 seconds, matching the Cache-Control the collector sets. A failure is completely silent and the previous list stays in place: this runs inside somebody else's app, and a failure of ours must degrade to "no message today", never to an error their user sees.
Targeting is evaluated on the device, not on the server. The alternative is one request per screen view, on the critical path of the customer's app, with our latency in front of their content and our availability in front of their business.
The rules are the web SDK's rules in the same order, with these differences on a phone:
| Server-side rule | What it matches on Android |
|---|---|
targeting.url_contains | the screen name you passed to screen() |
targeting.url_not_contains | the same |
targeting.devices | only mobile or tablet |
targeting.delay_seconds | the delay before drawing, clamped to between zero and 60 seconds |
targeting.new_visitors_only | whether this is this install's first launch |
targeting.returning_only | the inverse |
targeting.logged_in | tri-state: absent means "do not care" |
targeting.traits | the traits from the last identify whose values were scalars |
targeting.scroll_percent | not supported, silently ignored |
targeting.on_exit_intent | not supported, silently ignored |
Matching is always by substring and never by regular expression: a pattern written by a marketer is one that can be catastrophically slow, and this runs on every screen of somebody else's app.
The device class comes from smallestScreenWidthDp, and the break point is 600. Below it is mobile, at or above it is tablet. There is no desktop on Android. Note that this differs from the web SDK, whose break points are 768 and 1024 CSS pixels. 600 is Android's own break point for the same question, so a campaign behaves the way the app's own layouts do.
The frequency cap is applied in this order, each condition taking precedence over the next: outside the starts_at and ends_at window; then converted, which stops it for ever; then dismissed, provided the campaign is dismissible; then max_impressions; then cooldown_hours. Zero in the last two means no ceiling.
The cap is applied on the device and on the server, and it has to be both: local storage alone means clearing the app's data gives an uncapped modal, and the server alone means a request per screen view, which is what this whole design exists to avoid.
What is actually drawn. The renderer uses plain Views, no Compose and no XML, because a Compose dependency here would be a Compose dependency in every customer's build, including the ones still on Views, and would pick their Compose version for them.
| Campaign kind | On Android |
|---|---|
banner | drawn |
modal | drawn |
slidein | drawn, as a banner. Its animation has not been specified yet |
survey | not drawn |
A survey needs input widgets and a question flow. The renderer returns false for a kind it does not know, and the campaign is then neither capped nor reported: when the renderer learns that kind, the campaign is still owed to the user rather than having been silently burned.
The core has a respond method for sending a survey answer, and it is tested, but nothing in the Android layer ever calls it. Until a survey renderer is written, that method is reachable only by a customer who constructs an OnsiteManager themselves.
The message is added to the Activity's own content root rather than shown in a Dialog. A Dialog gets its own window, which on Android means it survives the Activity it belongs to in ways nobody wants, and it does not move with a keyboard or respect the app's insets.
Details you will meet in practice:
- One message at a time.
show()callsremove()first. - Body text is capped at three lines with an ellipsis. A marketer will eventually paste an essay, and a banner that grows to cover the app is worse than a truncated one.
- A banner sits at the bottom by default, unless
content.positionistop. A banner over the app's own toolbar hides navigation, and a user who cannot navigate closes the app. - The close button has a 48 dp minimum tap target and a
contentDescriptionof «بستن». - Default colours: background
#1F2430, text#F5F7FA, accent#2F6FED. An unparseable colour falls back rather than throwing: a marketer typing a colour name into a hex field must not crash the app they are advertising in. - The modal scrim is clickable even when the campaign is not dismissible, so a tap cannot fall through to the app behind a modal that is covering it.
- The click is reported before the link is opened. A link that fails to open is still a click the customer should see in their report.
One real defect, found by looking at a screenshot rather than by reasoning, is worth recording: the first version put a banner flush against the bottom edge and the gesture bar took a bite out of the last line. Two attempts with an inset listener failed, because a listener on a view that is not yet attached is never called, and once attached the dispatch depends on whether every parent passes insets down, which a library cannot assume about somebody else's view hierarchy. The fix reads rootWindowInsets directly at draw time. It mattered before and it matters more now, because Android 15 makes every app targeting targetSdk 35 draw edge to edge.
Reporting. POST {apiHost}/v1/onsite/event, with a body of this shape, where action is one of impression, dismiss, click or convert and user_id is omitted when the user is anonymous:
{
"campaign_id": 42,
"action": "click",
"anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
"user_id": "u_123"
}
The local consequence is applied before the request. The cap has to hold even if the request never arrives, because the alternative is a modal that reappears on every launch for somebody with no signal.
To take the message off the screen yourself:
Segmentic.dismissOnsite()
The concept, and building a campaign in the panel, are in on-site messages.
Opting out
Segmentic.optOut()
Segmentic.optIn()
val stopped = Segmentic.isOptedOut()
optOut() does three things: it sets the flag and persists it to segmentic_opt_out on disk, it clears the queue, and it deletes any pending device registration.
Clearing the queue is the point. Honouring an opt-out only for future events, while quietly delivering what was already captured, is not an opt-out.
After an opt-out: no event is queued, flush() returns without any request, registerDevice returns REFUSED without any request, and no in-app message is drawn. The flag survives the app being closed and reopened, because it lives on disk.
There is no Do Not Track equivalent on Android, correctly: no such OS signal exists. The consent policy and what the panel does with it are in consent.
What is not there today
The honest list, so nobody spends an afternoon hunting for something that does not exist:
page(). It does not exist on Android.screen()is the equivalent.- Campaign attribution. On the web an
sg_midclick is captured, raises amessage_clickedevent, and rides on every event for seven days. None of that exists on Android. A mobile conversion cannot currently be credited to a message. - Any helper that obtains a push token. Only
registerDevice, and you supply the token. - Survey rendering, and any caller for
OnsiteManager.respondin the Android layer. - Scroll-depth and exit-intent triggers. They exist on the web, not here.
- Bazaar and Myket with a real token. Their token map is accepted and the server knows the routes, but neither has ever been tried with a real token. If you push to those two stores, you are the first.
- Publication to any repository. The package is on no public repository; ask us for it.
- Proof against a real customer app. The proof that exists is against our own sample. What is proven, and how says exactly what it covers.
What is proven, and how
103 Kotlin tests, all on a plain JVM:
| File | Count | What it covers |
|---|---|---|
SegmenticClientTest.kt | 33 | wire format, de-duplication, 400 and 429 and 503 and no-signal behaviour, identity, alias, opt-out, buffer overflow, device registration and its three results, option normalisation, and the hostile platform-context test |
CoreTest.kt | 22 | the JSON writer, ISO 8601 dates, the queue, sessions, backoff |
OnsiteTest.kt | 22 | targeting, the frequency cap, seen-record storage, device class |
OnsiteManagerTest.kt | 20 | fetch, parse, cache, decide, report, and the JSON reader |
HttpIntegrationTest.kt | 6 | against a real socket, a com.sun.net.httpserver.HttpServer on localhost |
Six Go tests over real bytes. The files under backend/internal/collector/testdata/android-sdk/ are not fixtures somebody wrote to match the parser. They are the exact bytes an Android 15 device put on the wire: twelve events buffered while nothing was listening on the port, the app killed with force-stop, then delivered after a restart. A recording collector kept the requests verbatim.
Those six tests assert:
- The first batch holds exactly five messages, every message passes the real
model.Normalizewith zero warnings, andsdk_name,sdk_version,os_nameandos_versionare what they should be. properties.indexarrives as a number rather than a string. Had the SDK sent zero as text, the numeric map would hold no entry at all and every "greater than" segment over that property would silently never match. Andproperties.noteis «رویداد آزمایشی», proving Persian survived Kotlin's encoder, the disk, a process death, the socket and Go's parser.- The gap between the first event's timestamp and the batch's
sent_atis more than thirty seconds and less than ten minutes, so the clock correction has real work to do. - Twelve events at a
batchSizeof five leave a tail of exactly two. A tail of one or three would mean the queue'speekorackis wrong. - The golden device body normalises with zero warnings and the
fcmtoken arrives intact. has_gmsandpush_enabledboth arrive as pointers, so the tri-state survives all the way to the server.
A manual run on an Android 15 emulator, which the repository does not re-run but which three adb commands make repeatable:
adb shell am start -n net.segmentic.sample/.MainActivity --ei fire 12
adb shell am force-stop net.segmentic.sample
adb shell am start -n net.segmentic.sample/.MainActivity --ez flush true
All twelve events arrived in batches of five, five and two, with twelve distinct message_id values and none of them twice.
To exercise an in-app message, the extra is sgscreen and not screen, because am start claims the plain name for itself and swallows it silently:
adb shell am start -n net.segmentic.sample/.MainActivity --es sgscreen cart
Real push, on the same emulator with a throwaway Firebase project: Firebase gave a 142-character token, onNewToken handed it to the SDK, the SDK registered it on POST /v1/devices, and a message was sent from FCM v1. With the app in the foreground it reached onMessageReceived; with the app backgrounded the system drew the notification itself. Persian title and body both intact.
One thing showed itself there: before notification permission was granted, push_enabled went as false, and after pm grant as true. The tri-state reports the system's reality rather than an assumption of ours.
An in-app message, against a collector serving one live campaign: a banner with a Persian headline and body was drawn, the impression report reached POST /v1/onsite/event, and on a second run the frequency cap stopped it.