Android
Use the FounderHQ Android SDK.
Capture screens, app lifecycle, and your own events from a Kotlin app.
Before you start
You need a publishable key (fhq_pk_...) for the brand you want to
measure. See Getting started.
Install
dependencies {
implementation("com.getfounderhq:events:1.3.0")
// Navigation Compose screens
implementation("com.getfounderhq:events-compose:1.3.0")
}The SDK needs minSdk 24 and Java 17.
Initialize
Create one client in your Application, and share it.
class MyApplication : Application() {
lateinit var events: FounderHQEvents
override fun onCreate() {
super.onCreate()
events = FounderHQEvents(this, "fhq_pk_XXXX")
}
}Pass a config when you want to change the defaults:
events = FounderHQEvents(
this,
"fhq_pk_XXXX",
FounderHQEventsConfig(captureScreens = true),
)The client registers activity lifecycle callbacks, captures activity screens and application lifecycle, and records the session start.
Capture events
events.capture("trial.started", mapOf("plan" to "growth"))capture waits for startup to finish, then queues the event. Call
readyForCapture() when you want to wait for startup on its own.
Record a deep link when your app opens from one:
events.captureDeepLink(url)captureDeepLink drops the query and fragment, and keeps the campaign
parameters it recognizes. After you read Play Install Referrer data, pass
it on:
events.captureInstallReferrer(mapOf("utm_source" to "google_play"))Properties on every event
register adds properties to every later event. registerOnce does the
same but keeps a value that is already set. unregister removes one.
events.register(mapOf("plan" to "pro"))
events.unregister("plan")Track screens
Activity screens are captured for you. Call events.screen("Pricing")
anywhere else.
With Navigation Compose, add the observer in the composable that owns
your NavHost, beside the NavHost and not inside its builder:
import com.founderhq.events.compose.FounderHQNavigationObserver
FounderHQNavigationObserver(navController = navController, events = events)It records the current route, and skips a repeat of the route the app is already on.
Each screen includes these properties:
| Property | Value |
|---|---|
$screen_name | Activity title, Compose route, or the name passed to screen() |
$screen_id | A new identifier for this screen view |
$prefers_color_scheme | "dark" or "light" from Android's current appearance |
Appearance is read at capture time from the resumed activity's configuration, or the application's when no activity is resumed. Undefined or unavailable appearance is omitted. Other events do not automatically receive this property.
Adding properties to automatic events
Use autoProperties to add properties to $screen, $autocapture, and
$rageclick:
val config = FounderHQEventsConfig(
autoProperties = { context ->
mapOf(
"section" to (context.screenName ?: "unknown"),
"control" to context.elementDescription?.get("view_class"),
)
},
)FounderHQAutoPropertiesContext contains event, nullable screenName,
element (the interactive Android View that owns the tap, which may be
an ancestor of the touched view), and elementDescription (the same facts
as $elements[0]). Both element fields are null for screens. The
callback runs for automatic activity screens, Navigation Compose screens,
every enabled screen() call, and each captured tap or rage tap. Return a
map or null. If it throws, the event still sends without callback
properties, and the SDK logs one warning per client.
Lifecycle, session, identity, push, and your own capture() calls do not
call this hook, even when you pass a reserved event name. Remote config
updates keep your callback. autoProperties runs outside the SDK lock:
on the UI thread for taps, automatic activity screens, and Navigation
Compose screens. An explicit screen() runs it on the calling thread
(normally the UI thread). Keep it fast and non-blocking.
For taps, attach properties to a view or a container:
import com.founderhq.events.setFhqProperties
productCard.setFhqProperties(mapOf("product_id" to "sku_1"))
buyButton.setFhqProperties(mapOf("action" to "buy"))A tap on the view or anything inside it carries these as top-level
properties. The declarative walk starts at the touched view, even when
element is an ancestor that owns the tap, and continues through every
ancestor.
The nearest valid value wins. Keys stay as given; snake_case is the
convention. Pass null to clear a view's properties. The
android:tag="fhq-no-capture" marker still skips a whole subtree.
Later values win in this order: automatic and registered properties →
autoProperties → view properties → explicit screen(name, properties)
values. Both new sources use one set of limits:
- Keys must have 1–64 characters and must not start with
$. - Values must be strings, finite numbers, or booleans. Strings are trimmed and cut to at most 200 UTF-16 code units without splitting a surrogate pair. Other values are dropped.
- At most 20 extra keys survive per event. View properties come first, nearest view first, then callback properties. Each map keeps its iteration order. The first valid value for a key wins; invalid values do not use a slot.
These limits do not change explicit screen() or capture() properties.
The SDK does not capture Compose taps or dead clicks. events-compose
only observes navigation, so it has no Modifier.fhqProperties.
beforeSend
Use beforeSend to change or drop a finished event:
val config = FounderHQEventsConfig(
beforeSend = { event ->
if (event.optString("event") == "debug_action") {
null
} else {
event.getJSONObject("properties").remove("internal_note")
event
}
},
)The callback receives an org.json.JSONObject after all properties have
been merged. Return that object or another event object, or null to
drop it. A throw also drops the event. $mobile_purchase_prepared and
$mobile_purchase_claim bypass the hook and keep the SDK-built event so
purchase attribution cannot be silently dropped or reshaped.
New $identify events synthesized after an identity-rotation directive do
run through the hook. The hook can see an identify it already transformed
once, so make transformations idempotent. Ordinary delivery retries reuse
the queued snapshot.
For hook results, the SDK rebuilds and snapshots only uuid, event,
distinct_id, timestamp, properties, session_id, window_id, and
options.process_person_profile. All other top-level and options keys,
including cookieless_mode, are stripped. Validation follows these rules:
eventis trimmed and must not be empty. Keeping the original event's exact name is allowed, including internal names. A renamed event must pass the public event-name check after trimming: at most 200 characters, and no$prefix unless it is a reserved name apps may send.uuidmust use the RFC 4122 8-4-4-4-12 hexadecimal form, version 1–8, and variant 8, 9, a, or b (case-insensitive).distinct_idis trimmed, must have 1–400 characters, and must pass the SDK's illegal-distinct-ID check. The trimmed name and ID are queued.timestampmust be a real calendar instant in RFC 3339 form:YYYY-MM-DDTHH:MM:SS, optional fractional seconds, thenZor±HH:MM. Whole seconds are valid; impossible dates and missing zones are rejected.propertiesmust be a JSON object andoptions.process_person_profilemust be a boolean. Invalid required fields drop the event.- Optional
session_idis kept only when it is a UUIDv7 with variant 8–b. Optionalwindow_idis kept only when it is a string of at most 200 characters. Invalid optional fields are removed; the event is kept.
Without a hook, SDK-built events are queued untouched. Changing automatic identity or session events can affect those features.
beforeSend runs synchronously under the SDK's lock, on the thread
that builds the event: the caller for explicit capture(), screen(),
identity, and push calls; the UI thread for activity lifecycle and screen
events (including Navigation Compose); the SDK executor for captured taps,
rage taps, and initialization events. A synthesized identity retry runs on
the thread processing flush() (its caller for an explicit flush, or the
SDK's background thread for automatic delivery). Session events run on the
thread of the capture that created them. The hook must be fast, must not
block, and must not call back into the SDK.
Identify a contact
events.identify("user_42", mapOf("email" to "jane@acme.com"))Change identity and account in one call:
events.identify(
"user_42",
mapOf("email" to "jane@acme.com"),
FounderHQAccountContext("workspace_123"),
)Update contact properties on their own with setPersonProperties(set, setOnce). With the default IDENTIFIED_ONLY mode, changes made before
identify stay on the device and go out with the identify call.
Call reset() on logout. It clears the identity and the account. When a
push token is registered, it first removes the device from the person who
signs out.
Accounts
events.setAccount("workspace_123", mapOf("plan" to "growth"))
events.setAccountProperties(mapOf("seats" to 12))
events.clearAccount() // resetAccounts() does the samesetAccount also takes a FounderHQAccountContext, which carries key,
properties, and contextToken. Set account in the config when the first
automatic event must already carry it. An account change rotates the
account span only. The person session and queued events are untouched. See
Accounts and groups.
Consent
| You call | What happens |
|---|---|
optIn() | Capture is on |
optOut() | Capture stops, and the queue is cleared |
isOptedOut() | Returns true while capture is off |
Set optOutByDefault = true in the config to start silent until the person
agrees. See Consent and privacy.
The SDK never collects advertising identifiers.
Purchases
The client can tie a Play Billing or RevenueCat purchase to the person who made it.
| Method | What it does |
|---|---|
purchaseAttribution() | Returns the identifiers to pass to the store or to RevenueCat |
preparePurchase(source, completion) | Records intent before checkout, and returns the purchase context |
applyPurchaseContext(builder, prepared) | Puts the context on your Play Billing flow |
observePurchase(purchase, prepared) | Reports the purchase that followed |
claimSubscription(purchase, confirmation) | Moves future revenue of an existing subscription |
The full flow, including what each store needs, is on Mobile purchase claims.
Push notifications
Register the device so FounderHQ Sequences can send it a push. The SDK has no Firebase dependency, never asks for the notification permission, and never gets a token itself.
Create the client in Application.onCreate, as in
Initialize, not in an activity. Firebase can start your app in
a new process only to give it a new token, and no activity exists then. The
SDK supports one process: do not put the messaging service, or other code
that calls the SDK, in a separate android:process.
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
events.registerPushToken(token)
}
// In your FirebaseMessagingService
override fun onNewToken(token: String) {
events.registerPushToken(token)
}| Method | What it does |
|---|---|
registerPushToken(token, provider, appId, environment, enabled, permission) | Stores the token. provider defaults to FounderHQPushProvider.FCM |
setPushEnabled(enabled) | Your app's own push switch for this device. false stops pushes; the device stays registered |
unregisterPushToken() | Removes the device from the person and forgets the token |
capturePushNotificationOpened(intent, properties) | Reports the open of a FounderHQ notification and returns the link, or null. Sends nothing for any other intent |
capturePushNotificationOpened(payload, properties) | The same, for a FounderHQPushPayload |
FounderHQPushPayload.from(data) | Reads messageId, link, and notificationKey from RemoteMessage.data, a Bundle, or an Intent |
dismissPushNotification(key) | Cancels this app's notifications with that notification key (or message ID). Returns how many |
handlePushMessage(data) | Removes a notification when FounderHQ asks from your server. Returns true for such a message, false for every other. See Replace or remove a notification |
The SDK always stores the token. A device is registered only for an
identified person: the SDK sends the registration after identify and until
reset(), and sends nothing for a guest. While a person is identified, the
SDK sends the registration again on every app start, on a new token, and
after setPushEnabled. A registerPushToken call that changes nothing sends
no second registration in the same app start, so you can call it on every
launch.
With the same provider, a call that gives no appId or no environment
keeps the stored value. Thus onNewToken can pass only the token.
reset() removes the device from the person who signs out. It keeps the
token on the phone and clears the setPushEnabled switch. An identify for
a different person without a reset() also clears the switch. If the phone
is offline, the SDK keeps the removal and sends it first on the next app
start, with the time of the sign-out. If the same person signs in again
before the removal goes out, the SDK drops the removal and the device stays.
While the person is opted out, registrations wait, but a removal is still
sent.
The SDK does not store or send a token that FounderHQ refuses. It logs a warning with the reason, and never the token.
The SDK reads whether notifications are on for your app, without a prompt,
and sends authorized or denied with the registration. Before your app
asks on Android 13 and later, the system reports them off, so the SDK sends
denied. Pass permission to set the state yourself.
Android gives a notification tap to your app, so report the open from the activity that handles it:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Not after a rotation: the same intent comes back then.
if (savedInstanceState == null) reportPushOpen(intent)
}
// The activity was already open when the person tapped.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
reportPushOpen(intent)
}
private fun reportPushOpen(intent: Intent?) {
val link = events.capturePushNotificationOpened(intent)
if (link != null) openLink(link)
}The SDK reads two extras, fhqOutboundMessageId and fhqLink, and nothing
else. It sends the first as $push_message_id and returns the second. It
never opens the link. An intent with neither extra is not a FounderHQ
notification: the SDK sends nothing and returns null, so you can pass every
intent.
The call never throws. If Android cannot read the extras (an intent from
another app can hold a class your app does not know), the SDK sends nothing
and returns null. It does the same when the person starts the app from
Recents: Android gives the old notification intent again, but the person did
not tap again.
A push that arrives in the foreground
FounderHQ sends a notification part and a data part. The result depends
on the state of your app:
- Background or closed: the system tray shows the notification. A tap
starts your launcher activity, and the
datakeys are String extras on its intent. FounderHQ sets noclick_action, so the tap always goes to the launcher activity. - Foreground: Firebase calls
onMessageReceivedand shows nothing. Your app must build the notification. Put the two FounderHQ keys on its content intent as String extras, then the open is reported the same way.
class AppMessagingService : FirebaseMessagingService() {
private val events get() = (application as MyApplication).events
// Firebase can call this in a new process, with no activity.
override fun onNewToken(token: String) {
events.registerPushToken(token)
}
// Firebase calls this for a push that arrives while your app is in the
// foreground, and for a removal FounderHQ sends from the server.
override fun onMessageReceived(message: RemoteMessage) {
// A removal shows nothing: the SDK cancels the notification it names.
if (events.handlePushMessage(message.data)) return
val notification = message.notification ?: return
val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return
launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
// The same two String extras that the system tray gives.
message.data["fhqOutboundMessageId"]?.let { launch.putExtra("fhqOutboundMessageId", it) }
message.data["fhqLink"]?.let { launch.putExtra("fhqLink", it) }
// The tag the system tray gives a FounderHQ push: a later push with
// the same key replaces this one, and dismissPushNotification finds it.
val key = FounderHQPushPayload.from(message.data).notificationKey
val tap = PendingIntent.getActivity(
this, key.hashCode(), launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// Needs your notification channel and the notification permission.
NotificationManagerCompat.from(this).notify(
key,
0,
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setContentIntent(tap)
.setAutoCancel(true)
.build(),
)
}
}The full setup is on Push.
Config
FounderHQEventsConfig takes these values.
| Value | Type | Default | What it does |
|---|---|---|---|
host | String | https://i.getfounderhq.com | Where events are sent |
flushAt | Int | 20 | Sends a batch once this many events are queued |
flushIntervalSeconds | Long | 10 | Sends a batch on this timer. 0 turns the timer off |
personProfiles | PersonProfiles | IDENTIFIED_ONLY | When contact property changes are applied |
optOutByDefault | Boolean | false | Starts opted out |
captureLifecycle | Boolean | true | App opened and backgrounded events |
captureScreens | Boolean | true | Activity screen events |
captureSessions | Boolean | true | Session start events |
captureInstallUpdates | Boolean | true | App installed and updated events |
remoteConfig | Boolean | true | Reads capture settings you set in FounderHQ |
account | FounderHQAccountContext? | null | Installs account context before the first event |
purchasePrepareTimeoutMillis | Long | 3000 | How long preparePurchase waits before checkout goes ahead offline |
captureElementInteractions | Boolean | false | Taps and rage taps. See Element interactions |
capturePushNotificationOpened | Boolean | true | Records the notification taps your app reports |
tracingHeaders | List<String>? | null | Hostnames whose requests carry the session id. Add FounderHQOkHttpInterceptor(events) to your OkHttp client for this to take effect |
maxQueueSize | Int | 1000 | Events kept offline before the oldest are dropped |
eventTtlMillis | Long | 86400000 | How long an unsent event may wait |
maxRetries | Int | 5 | Times a failed event is retried |
autoProperties | ((FounderHQAutoPropertiesContext) -> Map<String, Any?>?)? | null | Adds properties to screens and captured taps |
beforeSend | ((JSONObject) -> JSONObject?)? | null | Changes or drops finished events except purchase and push device controls |
debug | Boolean | false | Logs what the SDK drops or refuses |
FounderHQEventsDependencies replaces the clock, UUID source, storage,
transport, and platform facts. Use it in tests.
Sending
| Method | Returns | What it does |
|---|---|---|
flush() | Boolean | Sends queued events now, and tells you whether the queue drained |
close() | — | Flushes, stops the executors, and unregisters the lifecycle callbacks |
getDistinctId() | String | The current identity |
getSessionId() | String | The current session |
FounderHQEvents implements AutoCloseable.
Element interactions
Set captureElementInteractions to true. The SDK then records
$autocapture when someone taps a control, and $rageclick when they tap the
same control three times in a second. Both are off by default.
Each event carries the control and up to four of its parents.
| Stored | Not stored |
|---|---|
| The view class, resource entry name, and content description | Anything a person typed |
| The path through the view hierarchy | Tap coordinates |
The label a Button shows, or the selected tab of a TabLayout | The contents or hint of an EditText |
A tap on an EditText records nothing at all, and a plain TextView never
contributes its words. Give a view the tag android:tag="fhq-no-capture" to
skip it and everything inside it.
Labels are cut to 255 characters. Numbers that look like a card number or a social security number are removed from them.
Only a tap counts. A finger that moves further than the platform's touch slop, a second finger, or a cancelled touch means the gesture was a scroll. Lifting a finger over a row at the end of a scroll records nothing.
Remote config
The client reads your capture settings from FounderHQ at startup, caches them in its storage, and applies the cached copy on the next launch.
| Key | What it turns on and off |
|---|---|
capture_sessions | Session start events |
capture_screens | Screen events |
capture_lifecycle | App opened and backgrounded events |
autocapture | Taps and rage taps |
capture_rageclicks | Rage taps only. Taps are still recorded |
Your settings in FounderHQ win. They can turn capture off for every install, and on for an app that shipped with the wrong value. You cannot rebuild an app people have already installed, so the dashboard decides. The values in your config apply until your settings arrive.
Events already queued under a key that turns off are dropped before they leave the device.
Call refreshRemoteConfig() to fetch them again while the app runs. Call
applyRemoteConfig(remote) to apply settings you supply yourself. Set
remoteConfig = false in the config to skip the request.
Next
Read the protocol reference for the wire format, the reserved event names, and the campaign properties a deep link can carry.