Deep linking, attribution, and smart links for Android.
Part of the Grovs open-source mobile linking platform.
Quick Start · API Reference · Full Docs
The Grovs Android SDK provides deep linking, app links, link generation, in-app messaging, revenue tracking, and attribution for your Android apps. It supports both Kotlin and Java.
- Deep linking & app links: route users to the right in-app screen, even after install
- Smart link generation: create trackable links with metadata, custom redirects, and UTM parameters
- In-app messages: show dashboard campaigns inside the app on app open, a screen view or a custom event, with targeting, limits, schedules, questions and custom HTML
- Push notifications: receive Grovs push campaigns via Firebase Cloud Messaging
- Analytics: track custom events, automatic and manual screen views, and Jetpack Navigation destinations
- Revenue tracking: log Google Play Billing and custom purchases with automatic attribution
- User identity: attach user IDs and attributes for analytics and segmentation
- Self-hosting support: point the SDK at your own backend
- Android API 21+ (Android 5.0)
- Kotlin 1.6+ or Java 8+
- Android Studio Arctic Fox+
Add the Grovs dependency to your app-level build.gradle:
dependencies {
implementation("io.grovs:Grovs:3.0.0")
}Configure the SDK in your Application class:
import io.grovs.Grovs
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Grovs.configure(this, "your-api-key", useTestEnvironment = false)
// Optional: enable debug logging
Grovs.setDebug(LogLevel.INFO)
// Optional: set user identity for analytics
Grovs.identifier = "user_id_from_your_app"
Grovs.attributes = mapOf("name" to "John Doe", "plan" to "premium")
}
}For self-hosted backends, pass the baseURL parameter (domain only, the SDK appends the API path):
Grovs.configure(this, "your-api-key", useTestEnvironment = false, baseURL = "https://your-domain.com")In your launcher activity, forward lifecycle events to the SDK:
class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
Grovs.onStart(this)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Grovs.onNewIntent(intent, this)
}
}Add these intent filters to your launcher activity in AndroidManifest.xml:
<!-- Custom URL scheme -->
<intent-filter>
<data android:scheme="your_app_scheme" android:host="open" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
<!-- App links (production) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="your_app_host" />
</intent-filter>
<!-- App links (test) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="your_app_test_host" />
</intent-filter>Register a listener in your launcher activity to receive deep link events:
Grovs.setOnDeeplinkReceivedListener(this) { deeplinkDetails ->
// Route the user based on payload data
val link = deeplinkDetails.link
val payload = deeplinkDetails.data
val tracking = deeplinkDetails.tracking
payload?.get("screen")?.let { screen ->
navigateTo(screen as String)
}
}Or use Kotlin Flow for a coroutine-based approach:
lifecycleScope.launch {
Grovs.Companion::openedLinkDetails.flow.collect { deeplinkDetails ->
deeplinkDetails?.let {
Log.d("Grovs", "Link: ${it.link}, data: ${it.data}")
}
}
}If the link lookup fails because the phone is offline or the backend is unavailable, the SDK keeps the link and looks it up again on its own while the app stays open in the same session: after a short, widening wait, when the app returns to the foreground, or as soon as the network comes back. The listener fires once the lookup succeeds. A link the backend refuses (a 4xx) is not retried.
You can also retrieve details for a specific link path:
// Using a callback
Grovs.linkDetails(path = "/my-link-path", lifecycleOwner = this) { details, error ->
details?.let { Log.d("Grovs", "Details: $it") }
}
// Using coroutines
val details = Grovs.linkDetails(path = "/my-link-path")When a link with copy-to-clipboard enabled is opened in the browser, the preview page copies the link to the visitor's clipboard before sending them to Google Play. On the first launch after install, if fingerprint matching found nothing, the SDK checks the clipboard and attributes the install deterministically. The deferred link is delivered through the same GrovsDeeplinkListener as fingerprint matches. This works out of the box.
Install attribution has a single 25-second waiting budget covering install-referrer lookup, fingerprint matching, and clipboard fallback. Queued lifecycle, purchase, and custom events are held during resolution. If the deadline expires, they may upload without attribution; a later match still applies to events that remain queued. A deep link the app was opened with is attributed to new lifecycle and purchase events immediately and takes precedence over a pending launch lookup; a link the backend rejects yields back to that lookup. Custom-event backfill uses only resolved links from the same session.
Custom events and screen views retain a resolved campaign within their current analytics session, including brief trips to the background. Returning to the foreground after more than 30 minutes starts a new session; its events do not inherit the previous campaign. Already queued events keep their original session and attribution.
On that first launch Android 12+ shows its system "pasted from your clipboard" toast once. The check is skipped entirely for projects with no clipboard-enabled link clicks in the last 48h and for devices whose clipboard holds no web URL, so an organic installer on a project with active clipboard links may see the toast once. Only URLs on accepted Grovs link hosts are sent for matching. Host validation happens after reading the text, so checking another URL can still cause a clipboard read notification.
If your project serves links from a custom domain, list it so the SDK recognizes your links on the clipboard:
Grovs.configure(
this,
"your-api-key",
useTestEnvironment = false,
baseURL = null,
autoTrackScreenViews = true,
clipboardDomains = listOf("links.yourdomain.com")
)Track custom analytics events with optional properties and tags:
Grovs.track("checkout_completed", properties = mapOf("sku" to "abc", "total" to 42.0), tags = listOf("shop"))Event names must not be blank, and cannot be one of the SDK's reserved names: view, open, install, reinstall, app_open, time_spent, reactivation, user_referred, custom, screen_view. Rejected events are logged and dropped.
Properties support strings, booleans, standard numbers, string-keyed maps, lists, and arrays. Nested Date, URL, and UUID values are converted to strings. Sanitization copies nested collections and preserves list order and null entries. A value that can't be represented in JSON is dropped on its own and everything else is kept: a list element is removed from its list, a map entry from its map, and a top-level property from the event. This covers NaN, infinity, other types (such as enums, sets, or arbitrary objects), a map with any non-string key (the whole map is dropped), a reference back to an enclosing collection, and a collection nested more than 16 levels deep. Dropped top-level properties are logged. Shared references without cycles are allowed. These rules also apply to trackScreenView properties.
Numbers must remain finite when read back as doubles from SDK storage, so BigInteger and BigDecimal values beyond the Double range are dropped like infinity. The whole properties map is omitted if its serialized JSON exceeds 8KB in UTF-8 or input traversal exceeds 8,192 values. Tags are capped at 20 per event, 255 characters each.
Grovs.setGlobalTags(listOf("android", "production"))Merged onto every subsequently tracked event, up to the combined 20-tag cap.
Screen views are tracked automatically for Activities and Fragments. When an Activity hosts Fragments, only the Fragment is reported (the host Activity's screen view is suppressed). This matches iOS, which filters out container view controllers. Compose destinations are NOT auto-tracked because the SDK has no Compose dependency, so track those manually.
Modals (DialogFragment, BottomSheetDialogFragment) are treated as overlays, not screens, so they are not auto-tracked. The screen underneath stays the current screen. If you want a modal reported as a screen, call Grovs.trackScreenView("...") when it opens.
Tab switching: tabs driven by the Navigation component,
ViewPager2,setMaxLifecycle, orreplace()transactions are tracked automatically (they resume the destination fragment). The legacyhide()/show()tab pattern does not change fragment lifecycle, so those switches are not auto-tracked. UsetrackNavigation(below),setMaxLifecycle, or a manualtrackScreenViewin your tab handler.
To disable automatic screen tracking:
Grovs.configure(this, "your-api-key", useTestEnvironment = false, baseURL = null, autoTrackScreenViews = false)Upgrading from 1.1.x: auto screen tracking is on by default in 3.0.0. Apps that upgrade will start emitting
screen_viewevents without any code change. PassautoTrackScreenViews = falsetoconfigure()to keep the previous behavior.
Track a screen manually. This is needed for Compose destinations, which the SDK cannot observe:
Grovs.trackScreenView("Checkout", properties = mapOf("step" to 2))For apps using Jetpack Navigation (Navigation-Compose or route-based graphs), hand the SDK your NavController and every destination change is tracked automatically, including bottom-navigation tabs, navigation rails, and Compose destinations that the lifecycle-based tracker cannot see:
Grovs.trackNavigation(navController)The screen name is derived from the destination's route, then its android:label, then its display name. Call it once per NavController (e.g. right after you set the graph); repeat calls on the same controller are ignored, and the SDK holds only a weak reference so it won't leak the hosting Activity/Fragment.
With Fragment-based navigation this can overlap the automatic tracker and emit duplicate screen views under different names. In that case either disable automatic tracking (
autoTrackScreenViews = false) and rely ontrackNavigation, or usetrackNavigationonly for Compose/route-based graphs.
Give screens friendly names in the dashboard:
Grovs.setScreenAliases(mapOf("MainActivity" to "Home", "CartFragment" to "Shopping Cart"))If your app needs user consent before collecting analytics, configure the SDK disabled and enable it once consent is granted:
Grovs.configure(
this, "your-api-key", useTestEnvironment = false, baseURL = null,
autoTrackScreenViews = true, clipboardDomains = null,
enabled = ConsentStore.hasConsent(),
)
// later
Grovs.setSDK(true)Configuring with enabled = false starts no requests, reads no clipboard or device identifiers, and records no events. Install and open are recorded after the SDK is enabled and authenticates. The flag is not persisted: pass the current consent state on every launch.
setSDK(false) immediately rejects new collection and invalidates outstanding SDK operations, including their retries and late results. Requests already transmitted may still reach the server. Local writes already admitted can finish safely; this includes acknowledged-event removal, launch bookkeeping, and closing the enabled portion of an engagement interval. Time spent disabled is excluded from engagement. Queued events retain their identity and resume delivery after enable, subject to the existing retention limits. Attribute and alias setters retain their latest desired values while disabled and synchronize when permitted.
Enabling does not replay a launch deep link or a cancelled link request. Forward a later Grovs.onStart(activity) or Grovs.onNewIntent(intent, activity) explicitly to resolve a link. Revoked link-generation/details requests complete with the existing method-specific error. Listener completions use the main thread, while caller/lifecycle cancellation still suppresses delivery.
Pass the current consent state through configure's enabled parameter on every launch, and call setSDK only after configure. The shorter configure overloads reset the flag to true, so a setSDK(false) made before configure would be overwritten by the next launch's configure call.
Create smart links with metadata, payload data, and tracking parameters:
Grovs.generateLink(
title = "Check out this product",
subtitle = "Limited time offer",
imageURL = "https://example.com/image.jpg",
data = mapOf("productId" to "12345", "screen" to "product_detail"),
tags = listOf("promotion", "share"),
tracking = TrackingParams(
utmCampaign = "spring_sale",
utmSource = "in_app",
utmMedium = "share_button"
),
lifecycleOwner = this,
listener = { link, error ->
link?.let { Log.d("Grovs", "Generated: $it") }
error?.let { Log.e("Grovs", "Error: $it") }
}
)Or using coroutines:
lifecycleScope.launch {
try {
val link = Grovs.generateLink(
title = "Check out this product",
subtitle = "Limited time offer",
imageURL = "https://example.com/image.jpg",
data = mapOf("productId" to "12345"),
tags = listOf("promotion"),
tracking = TrackingParams(
utmCampaign = "spring_sale",
utmSource = "in_app",
utmMedium = "share_button"
)
)
Log.d("Grovs", "Generated: $link")
} catch (e: GrovsException) {
Log.e("Grovs", "Error: ${e.message}")
}
}To override the project's copy-to-clipboard setting for a single link (used for clipboard-assisted deferred deep linking), pass the platform flags. null inherits the project default. The showPreviewIos and showPreviewAndroid flags work the same way for the link preview page:
Grovs.generateLink(
title = "Link Title",
copyToClipboardIos = true,
copyToClipboardAndroid = true,
lifecycleOwner = this,
listener = { link, _ -> link?.let { Log.d("Grovs", "Generated: $it") } }
)Override where a link sends users on each platform:
val redirects = CustomRedirects(
ios = CustomLinkRedirect(link = "https://example.com/ios-promo"),
android = CustomLinkRedirect(link = "https://example.com/android-promo"),
desktop = CustomLinkRedirect(link = "https://example.com/desktop-promo", openAppIfInstalled = false)
)
Grovs.generateLink(
title = "Special offer",
data = mapOf("promoId" to "summer25"),
customRedirects = redirects,
lifecycleOwner = this,
listener = { link, error ->
link?.let { Log.d("Grovs", "Generated: $it") }
}
)Launch a share intent after generating a link:
Grovs.generateLink(
title = "Share this",
data = mapOf("itemId" to "abc"),
lifecycleOwner = this,
listener = { link, _ ->
link?.let {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, it)
type = "text/plain"
}
startActivity(Intent.createChooser(sendIntent, "Share via"))
}
}
)To receive Grovs push campaigns:
1. Add Firebase Cloud Messaging. If your app doesn't already use Firebase, add your app in the Firebase Console, download google-services.json, and add the dependencies:
// project-level build.gradle
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
// app-level build.gradle
plugins {
id("com.google.gms.google-services")
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
}2. Upload your Firebase credentials. In the Firebase Console, go to Project Settings → Service Accounts and generate a new private key. Upload the JSON key file and enter your Firebase Project ID in the Grovs dashboard under Android Setup → Push Notifications.
3. Request notification permission (Android 13+):
import android.Manifest
import android.os.Build
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0)
}Add to your AndroidManifest.xml:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />4. Pass the FCM token to Grovs. You can do this before Grovs.configure. The SDK keeps the token until it is ready.
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
Grovs.pushToken = token
}Token refreshes are picked up automatically by the SDK's own messaging service.
5. Taps work through onStart / onNewIntent. When a Grovs push is tapped, the SDK reports the open and calls your deep link listener with the campaign data. If the push has a link, the link's data is included, and the push's own keys win. The push's own keys are all strings on Android; the link's data keeps its types. The push data includes grovs_campaign_id, plus grovs_link and grovs_image when the campaign sets them.
If your launcher is a splash screen that starts another activity, forward the intent extras, or pass the intent yourself:
Grovs.handleNotificationOpen(intent)6. If your app has its own FirebaseMessagingService. Android delivers messages to one service only. Either extend the Grovs service:
class MyMessagingService : io.grovs.handlers.MessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
if (GrovsMessaging.handleMessage(this, message)) return
// your own messages
}
}Declare your service in your app manifest, and remove the SDK's own declaration so Android does not pick the wrong one:
<service
android:name=".MyMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.grovs.handlers.MessagingService"
tools:node="remove" />or forward to Grovs from yours:
class MyMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
if (GrovsMessaging.handleMessage(this, message)) return
// your own messages
}
override fun onNewToken(token: String) {
GrovsMessaging.onNewToken(token)
}
}7. Notification channel. Pushes received while the app is in the background are shown by Firebase. Set a default channel so they do not land in "Miscellaneous", and create that channel at startup. Grovs uses the same channel for pushes received in the foreground:
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="promotions" />On Android 8 and later a custom campaign sound plays only if that channel has the sound. To stop the SDK showing pushes while the app is open, set GrovsMessaging.showForegroundNotifications = false.
8. Small icon. For pushes received while the app is open, the SDK uses the io.grovs.NotificationIconSmall icon, then the Firebase default icon (com.google.firebase.messaging.default_notification_icon, which Firebase also uses in the background), then its own. Give the icon as a resource or as a drawable name:
<meta-data
android:name="io.grovs.NotificationIconSmall"
android:resource="@drawable/ic_notify" />
<!-- or -->
<meta-data
android:name="io.grovs.NotificationIconSmall"
android:value="ic_notify" />Push notifications require a physical device or an emulator with Google Play Services.
In-app campaigns are built and launched in the Grovs dashboard. The SDK syncs them, decides when each one shows (its trigger, conditions, limits and schedule), draws it over the current screen and reports views, taps and answers. You do not need any code for this.
A test message sent from the dashboard waits up to 10 minutes on the server. If it does not show, send the app to the background and open it again.
Optional hooks:
Grovs.setInAppListener(object : GrovsInAppListener {
override fun shouldShowInAppMessage(campaignId: String): Boolean = !isCheckoutOpen
override fun onInAppMessageShown(campaignId: String) {}
// buttonId is null for a tap on the message; payload is set only for key_value actions.
override fun onInAppMessageClicked(campaignId: String, buttonId: String?, payload: Map<String, Any?>?) {}
// reason: close_button, backdrop, swipe, app or orientation.
override fun onInAppMessageDismissed(campaignId: String, reason: String) {}
// value is an Int for stars, the option id for a choice. Called instead of the dismissal.
override fun onInAppMessageAnswered(campaignId: String, value: Any, comment: String?) {}
// The message could not be drawn (no visible activity). It is tried again at its next trigger.
override fun onInAppMessageFailed(campaignId: String, message: String) {}
})Every method has a default, so override only what you need. Callbacks arrive on the main thread. The SDK holds the listener strongly; call Grovs.setInAppListener(null) to release it.
From Java, extend GrovsInAppListenerAdapter:
Grovs.Companion.setInAppListener(new GrovsInAppListenerAdapter() {
@Override
public void onInAppMessageShown(@NonNull String campaignId) {
Log.d("Grovs", "Shown " + campaignId);
}
});Control when messages may appear:
Grovs.pauseInAppMessages() // nothing new shows; a message that comes due is kept
Grovs.resumeInAppMessages() // shows the waiting message if it still qualifies
Grovs.discardInAppMessages(dismissCurrent = true) // drops waiting and delayed messages, stays paused
Grovs.setInAppExcludedScreens(listOf("CheckoutActivity", "PaymentFragment"))
Grovs.setInAppDebugLogging(true) // logs why each campaign was skippeddiscardInAppMessages() can also be called with no argument from Kotlin or Java; dismissCurrent defaults to false. These settings can be called before configure. Excluded screens use the same names as screen views, compared without case. A message waits until the user leaves an excluded screen. Debug logging writes to Logcat at INFO under the tag GrovsInApp, whatever the SDK log level.
Optional: app ratings. Star questions and the "Rate the app" action use Google Play In-App Review. Add it to your app to enable them:
implementation("com.google.android.play:review-ktx:2.0.2")Without it, campaigns with a "Rate the app" action are skipped and the review step after a high star rating is left out. Play shows the review dialog only for apps installed from Play, and at most a few times per user.
Push permission button. The SDK does not declare POST_NOTIFICATIONS; that is left to your app. On Android 13 and later, campaigns with an "Ask for push permission" button are not supported unless your app declares it:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Revenue tracking is currently in beta.
- Enable revenue tracking in the Grovs dashboard under Settings → Revenue Tracking
- Configure Google Play Real-Time Developer Notifications. The Grovs dashboard provides an automated setup script under Developers → Android Setup → Revenue, or you can configure Pub/Sub manually
// In your PurchasesUpdatedListener
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
for (purchase in purchases) {
Grovs.logInAppPurchase(purchase.originalJson)
}
}
}The SDK automatically extracts price, currency, and product info from the purchase JSON. Duplicates are filtered.
Grovs.logCustomPurchase(
type = PaymentEventType.BUY,
priceInCents = 999, // $9.99
currency = "USD",
productId = "premium_monthly"
)Use CANCELLATION and REFUND payment event types for cancellations and refunds. For Google Play purchases, these are detected automatically via Real-Time Developer Notifications.
| Property | Type | Description |
|---|---|---|
identifier |
String? |
User ID shown in dashboard and reports |
attributes |
Map<String, Any>? |
User attributes for analytics |
openedLinkDetails |
DeeplinkDetails? |
Kotlin Flow emitting deep link details |
pushToken |
String? |
FCM device token for push notifications |
| Method | Description |
|---|---|
configure(application, apiKey, useTestEnvironment, baseURL, autoTrackScreenViews, clipboardDomains, enabled) |
Initialize the SDK (shorter overloads keep the defaults) |
setSDK(enabled) |
Enable or disable collection at runtime (consent) |
setDebug(level) |
Set logging level (INFO, ERROR) |
onStart(activity) |
Forward launcher activity's onStart() |
onNewIntent(intent, activity) |
Forward launcher activity's onNewIntent() |
generateLink(...) |
Generate a smart link (callback or coroutine) |
setOnDeeplinkReceivedListener(activity, listener) |
Register deep link listener |
linkDetails(path, ...) |
Get details for a link path (callback or coroutine) |
track(name, properties, tags) |
Track a custom analytics event |
setGlobalTags(tags) |
Tags merged onto every subsequent event |
trackScreenView(screenName, properties) |
Track a screen view manually |
trackNavigation(navController) |
Track Jetpack Navigation destinations |
setScreenAliases(aliases) |
Friendly screen names for the dashboard |
displayMessagesFragment(onDismissed) |
Deprecated: the messages inbox was removed; only calls onDismissed |
isGrovsNotification(data) / isGrovsNotification(intent) |
Check whether a push or its tap intent comes from Grovs |
handleNotificationOpen(intent) |
Report a Grovs push tap and deliver its data to the deep link listener. Returns true if the intent was a Grovs push |
GrovsMessaging.handleMessage(context, message) |
Handle a push from your own messaging service. Returns true if it was a Grovs push |
GrovsMessaging.onNewToken(token) |
Pass a refreshed FCM token to Grovs from your own messaging service |
GrovsMessaging.showForegroundNotifications |
Set to false to stop the SDK showing pushes while the app is open (default true) |
setInAppListener(listener) |
Get in-app message callbacks (GrovsInAppListener; Java: GrovsInAppListenerAdapter) |
pauseInAppMessages() / resumeInAppMessages() |
Hold in-app messages and let them show again |
discardInAppMessages(dismissCurrent) |
Drop waiting in-app messages, optionally closing the one on screen |
setInAppExcludedScreens(screens) |
Screens where in-app messages never appear |
setInAppDebugLogging(enabled) |
Log why each in-app campaign was or was not shown |
setOnAutomaticNotificationsListener(listener) |
Deprecated: the messages inbox was removed; the listener is never called |
numberOfUnreadMessages(...) |
Deprecated: the messages inbox was removed; always yields 0 |
logInAppPurchase(originalJson) |
Log a Google Play Billing purchase |
logCustomPurchase(type, priceInCents, currency, productId, startDate) |
Log a custom purchase |
Full API reference: docs.grovs.io/docs/sdk/android/api-reference
A demo project is available at grovs-io/grovs-android-example-app.
- Adding a Gradle Dependency: add the SDK to your project
- Getting the Package Name: find your application ID
- Getting the SHA-256 Fingerprint: get your signing certificate fingerprint
- Adding an Intent Filter: set up deep link intent filters
Full documentation at docs.grovs.io.
For technical support and inquiries, contact support@grovs.io.
See LICENSE for details.