The Personyze Android SDK brings your campaigns into a native app. It tells Personyze what the visitor does in the app, gets back the campaigns they match and the actions to present, renders those actions, and shows Personyze notifications. It is written in Kotlin, with coroutines, StateFlow and Jetpack Compose support.
This guide covers version 2.1, which adds push through Firebase and counts each notification shown and tapped in the campaign's statistics. Every class and method is listed in the Android SDK reference. Coming from version 2.0 or 1.x (PersonyzeTracker.inst)? See Upgrading.
Requirements
- Android 7.0 (API level 24) or later. The app compiles with
compileSdk35 or later. - Kotlin 2.1 or later.
- Your account's API key, from Settings › Integrations › Full-featured API. The setup step of an API & SDK Personalization campaign shows the snippets below with your key already in them.
Install
Add the SDK to your app module's build.gradle.kts:
dependencies {
implementation("com.personyze:personyze-android:2.1.0")
// only if you render actions with Jetpack Compose:
implementation("com.personyze:personyze-android-compose:2.1.0")
}
It comes from Maven Central, which every new Android project already lists among its repositories. The SDK declares the INTERNET and ACCESS_NETWORK_STATE permissions itself and ships its own R8 rules, so a minified build needs nothing extra.
Initialize
Initialize the SDK once, in your Application class:
class App : Application()
{ override fun onCreate()
{ super.onCreate()
Personyze.initialize(this, PersonyzeConfig("YOUR_API_KEY"))
}
}
If the app has no Application class yet, register this one in AndroidManifest.xml: <application android:name=".App" …>.
It has to be Application.onCreate, not an activity. The SDK sends queued events and checks for notifications in the background, and that work runs only in a process where the app has initialized it. The SDK never writes the key to disk.
Personalize a screen
Each time the visitor opens a screen, tell Personyze, and get back what to show there in the same round trip:
lifecycleScope.launch {
val result = Personyze.navigate("ProductScreen") {
productViewed("A1000") // what happened on this screen goes in the same request
}
result.actionsIn("product_banner").firstOrNull()?.let { banner.render(it) }
}
- The document name (
"ProductScreen") is what Personyze records as the page's address. It shows in Real time visitors, and a campaign's page rules match against it. Name each screen, and each item where it matters ("Product/A1000"), and campaigns can target them. - One call is one page view. Call
navigatewhen a screen opens (in aViewModel'sinit, or inonCreatewhensavedInstanceStateis null), not on every recomposition or rotation. - It never throws for a network or server problem. If the call fails or takes longer than 5 seconds, the result is empty, its
errorsays why, and your screen shows its default content, just as it would for a visitor no campaign matched.
The latest result is also kept in Personyze.result, a StateFlow that every response updates and that is restored when the app restarts. In Compose:
val result by Personyze.result.collectAsStateWithLifecycle()
Where actions go: placeholders
On a website, a placeholder is the spot in the page an action is placed into. In an app, a placeholder is a named slot of your own: create it in the panel with a name (say home_banner), target the campaign's actions to it, and ask the result for it:
val banners = result.actionsIn("home_banner")
You get the placeholder's actions the way the web tag fills a placeholder: in their placeholder order, and no more than the placeholder holds. You can also go by action: result.has(88), result.action(88), or the whole list, result.actions.
The decision arrives first and the action's template a moment later; after that it is cached. Until the template arrives, action.isLoaded is false. Personyze.result updates again when it comes, and the renderers below wait for it on their own.
Rendering actions
Banners, recommendation widgets and other HTML actions render in a WebView. The SDK has a view for that, and a composable:
// Views. In a layout: <com.personyze.android.PersonyzeActionView … />
val view = PersonyzeActionView(context)
view.render(action)
// when the screen is gone:
view.destroy()
// Jetpack Compose (personyze-android-compose)
result.actionsIn("home_banner").firstOrNull()?.let { PersonyzeActionContent(it) }
Both load Personyze's WebView library and the scripts the action needs, and size themselves to the content, so they also work in a scrolling list. They report for you that the action was shown, and each tap: the call to action, a close button, a recommended product or article. Then they call your click handler:
view.onClick = PersonyzeClickListener { click ->
if (click.kind == PersonyzeClick.Kind.PRODUCT)
{ openProduct(click.productId!!)
true // handled by the app
}
else
{ false // the default: a link opens in the browser, a close hides the action
}
}
Nothing inside the action can navigate the WebView away. A link written for a website, such as href="/", has no site behind it in an app: the tap is still reported and reaches your handler, but no browser opens. Handle it there, or give the action absolute links.
Rendering an action yourself
An action whose contentType is application/json (a JSON feed) is data for your own UI: action.contentJsonArray() gives its rows. Any action's per-visitor values are in action.data, and action.contentHtmlDocument() gives a complete page for a WebView of your own. Whatever renders the action, report what happens to it:
| Call | When |
|---|---|
action.reportExecuted() |
You showed it. An action never reported shown looks like it never ran, and its A/B arm like it had no traffic. |
action.reportEngaged() |
The visitor tapped its call to action. This is what the Clicks criterion of an A/B test counts. |
action.reportClosed(3) |
The visitor closed it. With a number, it is not shown again for that many sessions. |
action.reportProductClicked(id), action.reportArticleClicked(id) |
The visitor tapped a recommended product or article. |
action.reportError(message) |
It could not be shown. The message is recorded with the visit. |
Reporting what the visitor does
Events feed targeting, recommendations and A/B results. Each call queues one event; none of them blocks, and all are safe to call from any thread. They go out with the next navigate, or by themselves 3 seconds after the last one, or when the app goes to the background.
Personyze.setProfile("email", user.email)
Personyze.productAddedToCart("A1000", quantity = 2)
Personyze.productsPurchased() // everything in the cart
| Calls | What they report |
|---|---|
setProfile(field, value), setProfile(mapOf(…)) |
Profile data (email, first_name or a field of your own), for targeting, content and email campaigns. |
productViewed, productAddedToCart, productRemovedFromCart, productLiked, productUnliked, productPurchased |
One product, by the Internal ID in your products catalog. Optionally actionId (the action that showed it) and, for the cart and purchases, quantity. |
productsPurchased(), productsRemovedFromCart(), productsUnliked() |
The whole cart, or the whole wishlist. |
articleViewed, articleLiked, articleCommented, articleUnliked, articleGoal, articlesUnliked() |
Articles, by the Internal ID in your articles catalog. |
promotionViewed, promotionClicked, codeRevealed, couponUsed |
A promotions action that your app draws itself. |
track(listOf(…)) |
Any tracker command the calls above don't cover. |
A blank id is not sent. Personyze would refuse it, and every other event in the same request with it, so the call is ignored and reported to your error listener.
To send the queue right away, for example before a screen that cannot wait, call Personyze.flush().
A/B tests in your own code
Personyze assigns each visitor to an arm of the test and keeps them there. Your code only branches on which action came back:
val result = Personyze.navigate("Checkout")
if (result.has(NEW_CHECKOUT_ACTION_ID))
{ showNewCheckout()
Personyze.reportExecuted(NEW_CHECKOUT_ACTION_ID)
}
else
{ showClassicCheckout()
}
Then report the outcome the test is scored on: Personyze.reportEngaged(actionId) for Clicks, or the purchase with the action's id, productPurchased("A1000", actionId = NEW_CHECKOUT_ACTION_ID), for Products purchased. For an arm with no content of its own, made only to branch on, use the Server-side decision action type.
The server-side guide explains what each winner criterion measures, which action types can be delivered to an app, and how to find action ids. To read who is winning from inside the app, call Personyze.verdict(campaignId).
Logged-in users
The SDK gives each installation an anonymous visitor. When the user logs in, identify them with your own user id; from then on Personyze files their activity under it, and the same id on another device is the same visitor:
Personyze.setUserId(user.id) // a String or a Long
Personyze.setUserId(null) // on logout: back to the installation's anonymous visitor
The logged-in visitor gets a session of their own; the anonymous visitor's session is not carried over. A session lasts 90 minutes from its start, and Personyze.startNewSession() starts a new one now. The current visitor and session are in Personyze.visitor.
Notifications
"Web Push Send" actions reach the app as Android notifications. Declare the permission in AndroidManifest.xml:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
On Android 13 and later, ask the user for it at runtime. Then turn notifications on where you initialize the SDK:
Personyze.initialize(this, PersonyzeConfig("YOUR_API_KEY") { notifications = true })
The SDK checks for a queued message as the app comes to the front (once a minute at most) and every 15 minutes in the background, and shows it with its title, text, image and up to three buttons. A tap opens the message's link. Each message appears once.
The campaign's statistics count each notification shown and each tap on it or one of its buttons, as they count a website's push. There is nothing to add for this: a tap passes through a small invisible activity that the SDK declares in its own manifest.
A notification reaches a visitor who matched the campaign's rules after their session has ended, typically about an hour and a half after they last used the app. Without push, it then appears at the app's next check. To check right away while testing, call Personyze.checkNotifications(). The status-bar icon and the channel's name in the system settings are set with notificationSmallIcon and notificationChannelName.
Some Android builds put apps in a battery saver that stops background work. On those devices the user may have to allow background activity for your app before notifications arrive.
Pushed at once with Firebase
With push, a notification reaches the phone as soon as the campaign sends it, even while the app is closed, instead of at the app's next check. It goes through Firebase Cloud Messaging, from your own Firebase project. The SDK depends on no Firebase library; your app uses Firebase and hands Personyze what concerns it.
- Add Firebase Cloud Messaging to the app, if it does not use it yet, as Firebase's Android setup describes: your project's
google-services.jsonin the app module, the Google services Gradle plugin, and thecom.google.firebase:firebase-messaginglibrary. - Upload the project's service account to Personyze. In the Firebase console, open Project settings › Service accounts and click Generate new private key. Upload that JSON file in the Personyze panel under Settings › Integrations › App push. It is not
google-services.json, which is the app's own configuration. Personyze asks Firebase whether it accepts the key before keeping it, and keeps it encrypted. - Pass the token and the messages to the SDK from a
FirebaseMessagingServiceof your own:
class PushService : FirebaseMessagingService()
{ override fun onNewToken(token: String)
{ Personyze.setPushToken(token)
}
override fun onMessageReceived(message: RemoteMessage)
{ if (Personyze.handlePushMessage(message.data))
{ return
}
// your app's own messages
}
}
Declare it in AndroidManifest.xml, inside <application>:
<service android:name=".PushService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
- Ask for the token each time the app comes to the front, in your main activity's
onStart:
override fun onStart()
{ super.onStart()
FirebaseMessaging.getInstance().token.addOnSuccessListener { Personyze.setPushToken(it) }
}
Firebase keeps the token, so asking again costs nothing. If Firebase's first request fails (no network on the first launch, or Google Play services not ready yet), it does not try again by itself until the app restarts. Asking at every start gets the token the next time the user opens the app.
handlePushMessage shows the notification as a checked one is shown, reports it shown, and answers false for a message that is not Personyze's, so your app's own pushes keep working. The SDK registers the device with Personyze for the current visitor, again after a login or a logout, and removes it when notifications are turned off or the visitor is reset. Once Personyze pushes to the device, the background check runs every 6 hours instead of every 15 minutes. A push that cannot be delivered is still shown at the app's next check.
Firebase Messaging 26 marks onNewToken and getToken() as deprecated. They still work, and Personyze sends to the token they give.
Checking that push works
- The device registers after the app's first request to Personyze (a
navigate, events or arefresh), because a device is registered for a visitor. Personyze.pushStatus, aStateFlowof PersonyzePushStatus, says where push stands: the token the app passed on, the visitor the device is registered for, and whether Personyze pushes to it.pushedstays false until the service account is uploaded.Personyze.client.diagnostics()prints the same, with the token cut short, for a support case.- In the panel, Settings › Integrations › App push counts the devices registered for each platform. Send a test notification pushes one at once, to a visitor id or to the devices that registered last, and shows what Firebase answered for each.
Test with that button rather than with a campaign: a campaign's notification waits for the visitor's session to end.
When something fails
Personyze never crashes your app. A failed or slow decision gives an empty result, and the screen shows its default.
- A decision has 5 seconds, a retry included. It is retried only after a connection failure, a timeout or a server error, and only within those 5 seconds.
- Events that could not leave the device (no network) are saved and sent when the network is back, for up to a day. Events that may already have reached Personyze are not sent again: a purchase counted twice is worse than one missing.
- If your account's tracking is paused (its page views are used up), the SDK stops asking for 10 minutes at a time.
To see what failed, set an error listener:
PersonyzeConfig("YOUR_API_KEY") {
onError = PersonyzeErrorListener { error, context ->
Log.w("Personyze", "${context.operation}: ${error.message}")
}
}
It hears about every failure, and the error's type says what kind it was: a rejected API key (PersonyzeException.Auth), a refused request (BadRequest), the network (Transport), events dropped (Delivery), or a call the SDK refused before sending anything (Invalid). It is called on a background thread.
Privacy
Personyze.reset()makes a new anonymous visitor and deletes everything the SDK stored for the old one: for a user's deletion request, or for testing.- The SDK keeps its state in the app's no-backup storage, so restoring a backup on another phone does not clone the visitor.
- The API key ships inside your app, as with any client-side SDK. Treat it as readable by anyone who unpacks the app.
Configuration
Everything is optional except the key:
PersonyzeConfig("YOUR_API_KEY") {
timeout = 3.seconds
notifications = true
onError = PersonyzeErrorListener { error, context -> /* … */ }
}
The settings and their defaults are listed under PersonyzeConfig in the reference.
Upgrading from 2.0
Change the version in your build.gradle.kts to 2.1.0. Nothing else is required: notifications shown and tapped are counted from then on. Push is optional; to add it, see Pushed at once with Firebase.
Upgrading from 1.x
Version 2 is a new SDK with a new API, not a drop-in update. To upgrade:
- Remove the 1.x module: the
personyzeandroidsdkfolder,implementation project(':personyzeandroidsdk'), and its line insettings.gradle. Also remove theorg.apache.http.legacy<uses-library>entry from your manifest, andplay-services-tasksif you added it only for Personyze. - Add the dependency above.
- Move the initialization to
Application.onCreate. - Replace the calls:
| 1.x | 2.0 |
|---|---|
PersonyzeTracker.inst.initialize(context, key) |
Personyze.initialize(this, PersonyzeConfig(key)), in Application.onCreate |
initialize(context, key, true) |
PersonyzeConfig(key) { notifications = true }, and declare POST_NOTIFICATIONS |
navigate(doc), then getResult(ctx).addOnCompleteListener(…) |
val result = Personyze.navigate(doc) |
getResult(ctx) |
Personyze.refresh(), or Personyze.result |
done(ctx) |
automatic; Personyze.flush() to send now |
logUserData(field, value) |
setProfile(field, value) |
productViewed(id) … articleGoal(id) |
the same names, plus the optional actionId and quantity |
action.renderOnWebView(ctx, webView, callback) |
PersonyzeActionView.render(action), or PersonyzeActionContent(action) |
reportActionClicked(ctx, clicked) |
automatic in the renderers; report(click) for a renderer of your own |
action.reportClick(ctx) |
action.reportEngaged() |
action.reportClose(ctx, n) |
action.reportClosed(n) |
action.getContentHtmlDoc(), getContentJsonArray() |
contentHtmlDocument(), contentJsonArray() |
checkForNotification(ctx) |
checkNotifications() |
startNewSession(ctx), clearCache(ctx) |
startNewSession(), clearCache() |
a PersonyzeAction passed in an Intent |
pass action.id, and look it up in Personyze.result |
On its first run, 2.0 takes over what 1.x stored on the device: the same visitor (and so the same A/B arms), the session, the session history, and the actions closed with "don't show again".
Things that work in 2.0 that did not in 1.x:
- Notifications show their title, and each appears once (1.x showed the same message again every 15 minutes).
- Notifications are checked in the background, not only while the app is open.
- Actions that need a helper script render with it.
- The OS version, the time zone during daylight saving time, and the visitor's session history reach Personyze in a form it uses, so the targeting rules that read them work.
- Events are no longer lost when a request fails.
The 1.x SDK remains available on the v1 branch of its GitHub repository.