Skip to content

The Frak Android SDK adds a rewarded referral program to a native Android app: customers share personal links, you track purchases, and when a sale is confirmed both the sharer and the friend who bought get paid. It is written in Kotlin, callable from Java, and ships as two artifacts so an app that only needs tracking never links a web view. Integration takes under an hour.

ItemValue
Minimum SDK24 (Android 7.0)
Java / JVM target17
LanguageKotlin 2.2 language level, Java call sites supported
LicenseApache-2.0

Two artifacts, released together:

ArtifactContents
id.frak.sdk:coreIdentity, config, rewards, interaction tracking, sharing links, app links. No UI, no web view.
id.frak.sdk:uiThe sharing sheet. Depends on core.
app/build.gradle.kts
dependencies {
implementation("id.frak.sdk:core:1.0.0")
// Only if you show the sharing sheet
implementation("id.frak.sdk:ui:1.0.0")
}

Artifacts are published to Maven Central. Both ship in lockstep behind a strictly constraint, so take the same version for both: a mismatch fails resolution rather than linking two versions. From 1.0.0 the public surface follows semantic versioning.

The SDK’s own manifest declares the INTERNET permission and the <queries> entries needed to detect the Frak wallet app, so you do not add either by hand. It declares no activity and no intent filter: your app keeps ownership of its own deep links.

Call Frak.initialize once, in Application.onCreate or in your launcher Activity’s onCreate after super.onCreate.

Frak.initialize(
context = applicationContext,
config = FrakConfig(merchantId = BuildConfig.FRAK_MERCHANT_ID) {
metadata = FrakMetadata {
name = "Your Store"
currency = FrakCurrency.EUR
homepageLink = "https://your-store.com"
}
deepLink = DeepLinkHandling.Automatic
logLevel = FrakLogLevel.INFO
},
)

The Kotlin trailing-lambda form is sugar over the same Builder, not a second implementation.

OptionDefaultMeaning
merchantIdnullYour merchant ID from the business dashboard. When null, the merchant is resolved from packageId instead.
packageIdnullFalls back to context.packageName.
metadataemptyStatic merchant facts: name, currency, lang, logoUrl, homepageLink.
deepLinkAutomaticAutomatic, Manual, or Disabled. See Referral deep links.
trackingEnabledtrueA hard floor. Setting it to false cannot be lifted at runtime.
logLevelNONENONE, ERROR, WARN, INFO, DEBUG.
logSinknullA FrakLogSink that replaces logcat. Must be thread-safe and must not throw.

homepageLink is the last fallback of the share link chain: without it, a store-wide share with no product and no explicit link has nothing to point at.

Frak.client throws if the SDK is not initialized; Frak.clientOrNull returns null instead. Every namespace member is a suspend function.

lifecycleScope.launch {
val reward = Frak.client.rewards.best(
RewardRequest { targetInteraction = "purchase" },
)
}

The client exposes environment, anonymousId(), resetAnonymousId(), setTrackingEnabled(), isTrackingEnabled(), and five namespaces: config, rewards, sharing, tracking, appLink.

Every suspending member has a CompletableFuture twin named *Async. The work runs on the SDK’s IO dispatcher and the future completes on the main thread, so a continuation can touch a View directly.

Frak.getClient().getRewards()
.bestAsync(new RewardRequest.Builder().targetInteraction("purchase").build())
.thenAccept(reward -> banner.setText(reward == null ? "" : reward.getFormatted()));
// A confirmed order, on your order confirmation screen
when (val result = Frak.client.tracking.purchase(
customerId = "cust_123",
orderId = "order_456",
token = "a-unique-order-token",
)) {
is FrakResult.Success -> Log.i("frak", "order tracked")
is FrakResult.Failure -> Log.w("frak", result.error.message)
}
// Any other interaction
Frak.client.tracking.track(Interaction.custom("added_to_cart"))

track and purchase succeed once the event is durable, not once it is delivered. Events are queued on disk oldest-first and retried, so an offline device still reports later.

Interaction is an opaque type built through static factories:

Interaction.custom("checkout")
Interaction.custom("checkout", mapOf("plan" to "pro"))
Interaction.sharing()
Interaction.arrival(referrerWallet, referrerClientId, referrerMerchantId, referralTimestamp)

You rarely build arrival yourself: appLink.handleReferral does it for you, and building a second one for the same link double-counts the arrival.

val campaigns = Frak.client.rewards.campaigns()
val best = Frak.client.rewards.best(
RewardRequest {
targetInteraction = "purchase"
products = visibleProducts.map { product ->
ProductDetails {
productId = product.id
name = product.title
}
}
},
)

Call best once per screen for the whole visible product set, not once per row: the cache is keyed on the encoded product list. Both calls accept a forceRefresh overload that skips the cache and the backoff.

RewardTier is a sealed class with three arms: Amount, Percentage, and Unknown. Always give when an Unknown branch: a campaign whose payout shape your compiled binary predates degrades to it instead of failing the whole best call, so a server-side tier type added after you shipped costs you one unrenderable band, not the reward display.

val config = Frak.client.config.resolve()
config.displayName
config.displayLogoUrl

resolve is stale-while-revalidate with a five minute freshness window, so calling it on every screen is cheap. The resolved config is a read model: you read placements, component copy, and translations from it, and never construct one. See Backend-driven configuration.

val link = Frak.client.sharing.buildLink(
SharingRequest {
products = listOf(
SharingProduct(title = product.title, link = product.link) {
imageUrl = product.imageUrl
utmContent = product.id
details = ProductDetails {
productId = product.id
name = product.title
unitPrice = product.priceCents / 100.0
}
},
)
targetInteraction = "purchase"
placement = "product-page"
},
)

buildLink returns null only when there is nothing to link to (no link, no product link, and no homepageLink). It throws a FrakError when a link should have been buildable but was not, for example when tracking is disabled.

AttributionParams (utmSource, utmMedium, utmCampaign, utmContent, utmTerm, via, ref) can be set per call and merges field by field over your merchant-level defaults.

SharingRequest carries three optional overrides for the chooser Android raises when the user taps share:

SharingRequest {
targetInteraction = "purchase"
shareTitle = "Ma sélection chez Your Store"
shareText = "J'ai trouvé ça, ça peut t'intéresser"
shareImageUrl = "https://your-store.com/product.jpg"
}

They are the highest-precedence source: the sharing page’s own copy is used when they are absent, and your merchant-level defaults below that. shareImageUrl is accepted for API symmetry but has no effect here: Android’s chooser ships no preview image, only iOS renders one.

The id.frak.sdk:ui artifact adds a ready-made sheet. Its whole public surface is FrakSharing, SharingResult, and FrakSharingDefaults.

private lateinit var sharing: FrakSharing
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// In onCreate, after super.onCreate. Not a property initializer: an Activity
// has no ViewModelStore before that, and build() throws without one.
sharing = FrakSharing.Builder(::onShareResult).build(this)
}
// When a share affordance becomes visible
sharing.warm()
// On the tap
sharing.present(SharingRequest { targetInteraction = "purchase" })

Builder.heightFraction(Float) tunes the sheet height. Values outside 0.3..1.0 are clamped and logged rather than thrown: a layout number must not crash your app. The default is 0.85. warm() is cheap to call repeatedly, and present implies it.

Builder.language(String?) sets the sheet’s language as a BCP 47 tag ("en", "fr-CA"), defaulting to the device locale. It selects among the translations the page ships rather than adding one, and an unknown tag falls back to the page’s own default. It rides on the pre-warmed URL, so set it once per instance: a tag that changes between warm() and present costs the warm view, not the language.

The sheet reports exactly once per session, through your callback:

private fun onShareResult(result: SharingResult) {
when (result) {
is SharingResult.Shared -> Log.i("frak", "shared ${result.link}")
is SharingResult.Copied -> Log.i("frak", "copied ${result.link}")
SharingResult.InstallStarted -> Unit // informational only
SharingResult.WalletOpened -> Unit // the wallet app was already installed and opened
SharingResult.Dismissed -> Unit
is SharingResult.Failed -> Log.w("frak", result.error.message)
}
}

InstallStarted is informational: it does not mean anything was installed, and it is not a cue to call openFrakApp again.

DeepLinkHandling.Automatic (the default) registers an activity lifecycle observer that reads the inbound intent and calls handleReferral for you. Choose Manual to call it yourself, or Disabled to opt out entirely.

Your app still declares its own intent filters for the domain or scheme you send referral links to:

AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<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-store.com" android:pathPrefix="/product" />
</intent-filter>
</activity>

The appLink namespace covers the rest:

Frak.client.appLink.handleReferral(url) // decode, guard self-referral, track the arrival
Frak.client.appLink.isFrakAppInstalled() // synchronous, no async twin
Frak.client.appLink.openFrakApp() // opens the wallet, or the Play Store listing
Frak.client.appLink.installPageUrl(returnScheme, sessionId)

handleReferral returns whether a referral context was found. It is not a “stop routing” signal: your own navigation still runs.

Test an inbound link without a real referral:

Terminal window
adb shell am start -a android.intent.action.VIEW \
-d "https://your-store.com/product?fCtx=test_token_123" your.app.id

The SDK ships no consent UI. Wire your consent platform to:

Frak.client.setTrackingEnabled(false) // stops tracking and purges anything queued
Frak.client.resetAnonymousId() // rotates the local identity

For Play Data Safety, three things leave the device and only three:

WhatPlay data type
Anonymous ID and the customerId you pass to tracking.purchasePersonal info, User IDs
Referral and sharing eventsApp activity, App interactions
customerId, orderId, and the checkout tokenFinancial info, Purchase history

No advertising ID, no ANDROID_ID, no install referrer, no location, no contacts. The anonymous ID is a per-install keypair held in the Android Keystore, non-exportable, and gone on uninstall, which is why it is declared as a user ID rather than a device ID.

Two caveats worth knowing before you build a compliance story on setTrackingEnabled: the decision is written asynchronously, so a withdrawal lost to a process kill reverts to enabled on the next launch, and the web SDK has no equivalent switch today.

Frak.shutdown() cancels background work and unregisters the deep-link observer, after which initialize can run again. It is not a consent control: it records no decision.