From 0eebfa9cd749b46c482d2e242e5c7e786c3a8626 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Thu, 11 Jun 2026 08:53:50 -0700 Subject: [PATCH 1/2] feat: add SDK identity, last-click attribution, and screen-view tracking - Report sdkName/sdkVersion on the install and event payloads and send an X-LinkForty-SDK header on all requests, sourced from BuildConfig. - Stamp every event with the deep link that most recently opened the app (deferred install or direct re-engagement) plus a per-app-open sessionId, via a new persisted AttributionContext. The newest open supersedes; the active link persists across restarts; organic activity stays session-only. - Add trackScreenView() and a LinkFortyNavObserver (a Jetpack Navigation OnDestinationChangedListener; androidx.navigation as compileOnly) that emit screen_view events with screen/previousScreen carrying the attribution stamp. All additive and backward compatible. --- sdk/build.gradle.kts | 14 ++ .../kotlin/com/linkforty/sdk/LinkForty.kt | 30 +++- .../main/kotlin/com/linkforty/sdk/SdkInfo.kt | 17 +++ .../sdk/attribution/AttributionContext.kt | 130 ++++++++++++++++++ .../linkforty/sdk/deeplink/DeepLinkHandler.kt | 14 +- .../com/linkforty/sdk/events/EventTracker.kt | 48 ++++++- .../sdk/fingerprint/DeviceFingerprint.kt | 9 +- .../com/linkforty/sdk/models/EventRequest.kt | 21 ++- .../sdk/navigation/LinkFortyNavObserver.kt | 57 ++++++++ .../linkforty/sdk/network/NetworkManager.kt | 5 + .../com/linkforty/sdk/storage/StorageKeys.kt | 3 + .../linkforty/sdk/storage/StorageManager.kt | 18 +++ .../sdk/attribution/AttributionContextTest.kt | 96 +++++++++++++ .../linkforty/sdk/events/EventTrackerTest.kt | 51 ++++++- .../sdk/network/NetworkManagerTest.kt | 13 ++ .../linkforty/sdk/testhelpers/MockHelpers.kt | 13 ++ 16 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 sdk/src/main/kotlin/com/linkforty/sdk/SdkInfo.kt create mode 100644 sdk/src/main/kotlin/com/linkforty/sdk/attribution/AttributionContext.kt create mode 100644 sdk/src/main/kotlin/com/linkforty/sdk/navigation/LinkFortyNavObserver.kt create mode 100644 sdk/src/test/kotlin/com/linkforty/sdk/attribution/AttributionContextTest.kt diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts index b97cc47..47aeaef 100644 --- a/sdk/build.gradle.kts +++ b/sdk/build.gradle.kts @@ -13,6 +13,15 @@ android { defaultConfig { minSdk = 26 consumerProguardFiles("consumer-rules.pro") + + // Expose the published version to runtime code so the SDK can report its + // own version (sdkVersion / X-LinkForty-SDK). Sourced from VERSION_NAME in + // gradle.properties so it always matches the released artifact. + buildConfigField("String", "SDK_VERSION", "\"${property("VERSION_NAME")}\"") + } + + buildFeatures { + buildConfig = true } buildTypes { @@ -48,6 +57,11 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.coroutines.android) + // Optional: enables LinkFortyNavObserver for automatic screen-view tracking. + // compileOnly so apps that don't use Jetpack Navigation don't pull it in; + // apps that do already have it on their classpath. + compileOnly("androidx.navigation:navigation-runtime:2.7.7") + testImplementation(libs.junit5.api) testRuntimeOnly(libs.junit5.engine) testImplementation(libs.mockk) diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/LinkForty.kt b/sdk/src/main/kotlin/com/linkforty/sdk/LinkForty.kt index fca3413..72111fc 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/LinkForty.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/LinkForty.kt @@ -2,6 +2,7 @@ package com.linkforty.sdk import android.content.Context import android.net.Uri +import com.linkforty.sdk.attribution.AttributionContext import com.linkforty.sdk.attribution.AttributionManager import com.linkforty.sdk.deeplink.DeepLinkCallback import com.linkforty.sdk.deeplink.DeepLinkHandler @@ -72,6 +73,7 @@ class LinkForty private constructor() { private var config: LinkFortyConfig? = null private var networkManager: NetworkManager? = null private var attributionManager: AttributionManager? = null + private var attributionContext: AttributionContext? = null private var eventTracker: EventTracker? = null private var deepLinkHandler: DeepLinkHandler? = null private var externalUserId: String? = null @@ -108,6 +110,9 @@ class LinkForty private constructor() { this.networkManager = networkManager + val attributionContext = AttributionContext(storageManager, config.debug) + this.attributionContext = attributionContext + this.attributionManager = AttributionManager( networkManager = networkManager, storageManager = storageManager, @@ -116,14 +121,16 @@ class LinkForty private constructor() { this.eventTracker = EventTracker( networkManager = networkManager, - storageManager = storageManager + storageManager = storageManager, + attributionContext = attributionContext ) val handler = DeepLinkHandler() handler.configure( networkManager = networkManager, fingerprintCollector = fingerprintCollector, - baseURL = config.baseURL + baseURL = config.baseURL, + attributionContext = attributionContext ) this.deepLinkHandler = handler @@ -239,6 +246,23 @@ class LinkForty private constructor() { eventTracker?.trackRevenue(amount, currency, properties) } + /** + * Tracks a screen view for per-link screen-flow funnels. + * + * Emits a `screen_view` event stamped with the active last-click attribution + * context, so the dashboard can show which screens users reach after opening a + * deep link. Call from your screen's lifecycle (e.g., `onResume`) or a + * `NavController.OnDestinationChangedListener`. + * + * @param name Screen name (e.g., "ProductDetail") + * @param properties Optional additional properties + * @throws LinkFortyError if tracking fails + */ + suspend fun trackScreenView(name: String, properties: Map? = null) { + if (!isInitialized) throw LinkFortyError.NotInitialized() + eventTracker?.trackScreenView(name, properties) + } + /** * Flushes the event queue, attempting to send all queued events. */ @@ -367,6 +391,7 @@ class LinkForty private constructor() { */ fun clearData() { attributionManager?.clearData() + attributionContext?.clear() eventTracker?.clearQueue() deepLinkHandler?.clearCallbacks() externalUserId = null @@ -381,6 +406,7 @@ class LinkForty private constructor() { config = null networkManager = null attributionManager = null + attributionContext = null eventTracker = null deepLinkHandler = null externalUserId = null diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/SdkInfo.kt b/sdk/src/main/kotlin/com/linkforty/sdk/SdkInfo.kt new file mode 100644 index 0000000..9c1a54f --- /dev/null +++ b/sdk/src/main/kotlin/com/linkforty/sdk/SdkInfo.kt @@ -0,0 +1,17 @@ +package com.linkforty.sdk + +/** + * Identifies this SDK (name + version) on outbound requests so the backend can + * report which SDKs/versions are in use and flag outdated integrations. + * + * [VERSION] is sourced from `BuildConfig.SDK_VERSION` (VERSION_NAME in + * gradle.properties), so it stays in sync with the published artifact version + * automatically — no manual bump required. + */ +internal object SdkInfo { + /** SDK platform identifier, sent as `sdkName` and in the `X-LinkForty-SDK` header. */ + const val NAME = "android" + + /** SDK release version, sent as `sdkVersion`. */ + val VERSION: String = BuildConfig.SDK_VERSION +} diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/attribution/AttributionContext.kt b/sdk/src/main/kotlin/com/linkforty/sdk/attribution/AttributionContext.kt new file mode 100644 index 0000000..8b0269c --- /dev/null +++ b/sdk/src/main/kotlin/com/linkforty/sdk/attribution/AttributionContext.kt @@ -0,0 +1,130 @@ +package com.linkforty.sdk.attribution + +import com.linkforty.sdk.LinkFortyLogger +import com.linkforty.sdk.storage.StorageManagerProtocol +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import java.time.Instant +import java.util.UUID + +/** + * The active last-click attribution: the deep link currently credited for in-app + * activity, and when it opened the app. + */ +@JsonClass(generateAdapter = true) +data class ActiveAttribution( + val linkId: String, + val clickId: String? = null, + /** ISO 8601 timestamp of when the deep link opened the app. */ + val openedAt: String +) + +/** + * The attribution fields merged into every event payload. [sessionId] is always + * present; the link fields are absent until a deep link has opened the app. + */ +data class AttributionStamp( + val attributedLinkId: String?, + val attributedClickId: String?, + val linkOpenedAt: String?, + val sessionId: String +) + +/** + * Last-click attribution + session tracking for in-app events. + * + * LinkForty attributes in-app activity (events and screen views) to the deep link + * that drove it, using a last-click + window model: + * + * - Every deep-link open (deferred install OR direct re-engagement) pins an active + * context to THAT link. The newest open wins (supersede). + * - Every event is stamped with the active context so the backend can credit the + * link. The conversion window and session grouping are applied server-side at + * query time — the SDK only reports the active link, when it opened, and the + * current session. + * - A [sessionId] identifies one app-open journey: generated on cold start and + * rotated on each new deep-link open. + * + * The active context is persisted so a reopen without a new click still attributes + * to the last link. The session is in-memory: a cold start is a new session. + */ +internal class AttributionContext( + private val storage: StorageManagerProtocol, + private val debug: Boolean = false, + moshi: Moshi = Moshi.Builder().build() +) { + private val adapter = moshi.adapter(ActiveAttribution::class.java) + private val lock = Any() + + private var active: ActiveAttribution? = loadActive() + + @Volatile + private var sessionId: String = UUID.randomUUID().toString() + + /** + * Records a deep-link open. The newest open supersedes the previous one + * (last-click) and starts a new session. A no-op when [linkId] is null + * (organic/unresolved open) — there is nothing to attribute to. + */ + fun recordDeepLinkOpen(linkId: String?, clickId: String? = null) { + if (linkId == null) return + + val attribution = ActiveAttribution( + linkId = linkId, + clickId = clickId, + openedAt = Instant.now().toString() + ) + + val session: String + synchronized(lock) { + active = attribution + // A new deep-link open is the start of a new attributed journey. + sessionId = UUID.randomUUID().toString() + session = sessionId + } + + try { + storage.saveAttribution(adapter.toJson(attribution)) + } catch (e: Exception) { + if (debug) LinkFortyLogger.log("Failed to persist attribution: ${e.message}") + } + + if (debug) LinkFortyLogger.log("Attribution context set: link=$linkId session=$session") + } + + /** The attribution fields to merge into every event payload. */ + fun getStamp(): AttributionStamp = synchronized(lock) { + AttributionStamp( + attributedLinkId = active?.linkId, + attributedClickId = active?.clickId, + linkOpenedAt = active?.openedAt, + sessionId = sessionId + ) + } + + /** The current session id (one app-open journey). */ + fun currentSessionId(): String = sessionId + + /** Clears the persisted context and starts a fresh session (used by clearData). */ + fun clear() { + synchronized(lock) { + active = null + sessionId = UUID.randomUUID().toString() + } + try { + storage.removeAttribution() + } catch (_: Exception) { + // best-effort + } + } + + private fun loadActive(): ActiveAttribution? { + return try { + val raw = storage.getAttribution() ?: return null + adapter.fromJson(raw) + } catch (_: Exception) { + // Missing/corrupt data (or an unstubbed mock in tests) → no active context. + null + } + } +} diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt b/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt index 51c8888..cc7e4bc 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt @@ -2,6 +2,7 @@ package com.linkforty.sdk.deeplink import android.net.Uri import com.linkforty.sdk.LinkFortyLogger +import com.linkforty.sdk.attribution.AttributionContext import com.linkforty.sdk.fingerprint.FingerprintCollectorProtocol import com.linkforty.sdk.models.DeepLinkData import com.linkforty.sdk.network.HttpMethod @@ -45,6 +46,9 @@ internal class DeepLinkHandler { /** Base URL for detecting LinkForty URLs */ private var baseURL: String? = null + /** Last-click attribution context updated on each deep-link open */ + private var attributionContext: AttributionContext? = null + /** Flag to track if deferred deep link has been delivered */ private var deferredDeepLinkDelivered = false @@ -57,11 +61,13 @@ internal class DeepLinkHandler { fun configure( networkManager: NetworkManagerProtocol, fingerprintCollector: FingerprintCollectorProtocol, - baseURL: String + baseURL: String, + attributionContext: AttributionContext? = null ) { this.networkManager = networkManager this.fingerprintCollector = fingerprintCollector this.baseURL = baseURL + this.attributionContext = attributionContext } // -- Deferred Deep Link (Install Attribution) -- @@ -101,6 +107,9 @@ internal class DeepLinkHandler { deferredDeepLinkCallbacks.toList() } + // Pin last-click attribution to this deferred (install) open. + attributionContext?.recordDeepLinkOpen(deepLinkData?.linkId) + withContext(Dispatchers.Main) { callbacks.forEach { it(deepLinkData) } } @@ -141,6 +150,9 @@ internal class DeepLinkHandler { } if (resolvedData != null) { + // Pin last-click attribution to this direct (re-engagement) open; + // supersedes any prior context. Organic/unresolved opens are a no-op. + attributionContext?.recordDeepLinkOpen(resolvedData.linkId) LinkFortyLogger.log("Parsed deep link: $resolvedData") } else { LinkFortyLogger.log("Failed to parse deep link URL") diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/events/EventTracker.kt b/sdk/src/main/kotlin/com/linkforty/sdk/events/EventTracker.kt index 27b210d..ac39bd8 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/events/EventTracker.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/events/EventTracker.kt @@ -1,6 +1,7 @@ package com.linkforty.sdk.events import com.linkforty.sdk.LinkFortyLogger +import com.linkforty.sdk.attribution.AttributionContext import com.linkforty.sdk.errors.LinkFortyError import com.linkforty.sdk.models.EventRequest import com.linkforty.sdk.models.EventResponse @@ -17,9 +18,13 @@ import java.time.Instant internal class EventTracker( private val networkManager: NetworkManagerProtocol, private val storageManager: StorageManagerProtocol, + private val attributionContext: AttributionContext, private val eventQueue: EventQueue = EventQueue() ) { + /** Last tracked screen name, for the `previousScreen` transition stamp. */ + private var lastScreen: String? = null + /** * Tracks a custom event. * @@ -37,12 +42,19 @@ internal class EventTracker( val installId = storageManager.getInstallId() ?: throw LinkFortyError.NotInitialized() - // Create event request + // Stamp the event with the active last-click attribution context so the + // backend can credit the deep link that drove it (organic events carry + // only the session id). + val stamp = attributionContext.getStamp() val event = EventRequest( installId = installId, eventName = name, eventData = properties ?: emptyMap(), - timestamp = Instant.now().toString() + timestamp = Instant.now().toString(), + attributedLinkId = stamp.attributedLinkId, + attributedClickId = stamp.attributedClickId, + linkOpenedAt = stamp.linkOpenedAt, + sessionId = stamp.sessionId ) // Try to send immediately @@ -84,6 +96,38 @@ internal class EventTracker( trackEvent(name = "revenue", properties = eventProperties) } + /** + * Tracks a screen view. + * + * Emits a `screen_view` event (through the normal pipeline, so it is stamped + * with the active last-click attribution context) carrying the screen name and + * — when available — the previously tracked screen, so the dashboard can build + * a per-link screen-flow funnel. + * + * @param name Screen name (e.g., "ProductDetail") + * @param properties Optional additional properties + * @throws LinkFortyError if tracking fails + */ + suspend fun trackScreenView(name: String, properties: Map? = null) { + if (name.isBlank()) { + throw LinkFortyError.InvalidEventData("Screen name cannot be empty") + } + + val previous = synchronized(this) { + val p = lastScreen + lastScreen = name + p + } + + val eventProperties = (properties ?: emptyMap()).toMutableMap() + eventProperties["screen"] = name + if (previous != null && previous != name) { + eventProperties["previousScreen"] = previous + } + + trackEvent(name = "screen_view", properties = eventProperties) + } + /** * Flushes the event queue, attempting to send all queued events. */ diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/fingerprint/DeviceFingerprint.kt b/sdk/src/main/kotlin/com/linkforty/sdk/fingerprint/DeviceFingerprint.kt index 1a36827..3964962 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/fingerprint/DeviceFingerprint.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/fingerprint/DeviceFingerprint.kt @@ -1,5 +1,6 @@ package com.linkforty.sdk.fingerprint +import com.linkforty.sdk.SdkInfo import com.squareup.moshi.JsonClass /** @@ -43,5 +44,11 @@ data class DeviceFingerprint( * null values from JSON by default, so legacy/self-hosted servers * see the same wire format they always have. */ - val appToken: String? = null + val appToken: String? = null, + + /** SDK platform identifier (e.g., "android"), for backend SDK diagnostics */ + val sdkName: String = SdkInfo.NAME, + + /** SDK release version (e.g., "1.2.0"), for backend SDK diagnostics */ + val sdkVersion: String = SdkInfo.VERSION ) diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/models/EventRequest.kt b/sdk/src/main/kotlin/com/linkforty/sdk/models/EventRequest.kt index fac9bbe..4de95c3 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/models/EventRequest.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/models/EventRequest.kt @@ -1,5 +1,6 @@ package com.linkforty.sdk.models +import com.linkforty.sdk.SdkInfo import com.squareup.moshi.FromJson import com.squareup.moshi.JsonClass import com.squareup.moshi.ToJson @@ -20,7 +21,25 @@ data class EventRequest( val eventData: Map, /** ISO 8601 timestamp of when the event occurred */ - val timestamp: String = Instant.now().toString() + val timestamp: String = Instant.now().toString(), + + /** SDK platform identifier (e.g., "android"), for backend SDK diagnostics */ + val sdkName: String = SdkInfo.NAME, + + /** SDK release version (e.g., "1.2.0"), for backend SDK diagnostics */ + val sdkVersion: String = SdkInfo.VERSION, + + /** The deep link currently credited for this event (last-click); null if organic */ + val attributedLinkId: String? = null, + + /** The originating click id, when known */ + val attributedClickId: String? = null, + + /** ISO 8601 timestamp of when the attributing deep link opened the app */ + val linkOpenedAt: String? = null, + + /** The app-open session this event belongs to (for screen-flow grouping) */ + val sessionId: String? = null ) /** diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/navigation/LinkFortyNavObserver.kt b/sdk/src/main/kotlin/com/linkforty/sdk/navigation/LinkFortyNavObserver.kt new file mode 100644 index 0000000..66c7be4 --- /dev/null +++ b/sdk/src/main/kotlin/com/linkforty/sdk/navigation/LinkFortyNavObserver.kt @@ -0,0 +1,57 @@ +package com.linkforty.sdk.navigation + +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import com.linkforty.sdk.LinkForty +import com.linkforty.sdk.LinkFortyLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * A [NavController.OnDestinationChangedListener] that reports a `screen_view` to + * LinkForty whenever the navigation destination changes, so you get screen-flow + * tracking without instrumenting every screen. + * + * Requires Jetpack Navigation (`androidx.navigation`), which your app already + * provides if you use it — the SDK depends on it only as `compileOnly`. Attach + * the observer to your `NavController`: + * + * ```kotlin + * navController.addOnDestinationChangedListener(LinkFortyNavObserver()) + * ``` + * + * By default the destination's `route` (Compose navigation) or `label` (XML nav + * graphs) is used as the screen name; destinations with neither are skipped. + * Provide [screenNameExtractor] to customize naming. Each reported `screen_view` + * is stamped with the active last-click attribution context. + */ +class LinkFortyNavObserver( + private val screenNameExtractor: (NavDestination) -> String? = ::defaultScreenName, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main) +) : NavController.OnDestinationChangedListener { + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + val name = screenNameExtractor(destination)?.takeIf { it.isNotBlank() } ?: return + + // Fire-and-forget; navigation must not block on network/queue work. If the + // SDK isn't initialized yet, trackScreenView (or `shared`) throws and we + // simply skip this screen view. + scope.launch { + try { + LinkForty.shared.trackScreenView(name) + } catch (e: Exception) { + LinkFortyLogger.log("Auto screen-view skipped: ${e.message}") + } + } + } +} + +/** Default screen name: the destination route (Compose nav) or its label (XML nav). */ +internal fun defaultScreenName(destination: NavDestination): String? = + destination.route ?: destination.label?.toString() diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt b/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt index 1f318d1..6733f11 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt @@ -1,6 +1,7 @@ package com.linkforty.sdk.network import com.linkforty.sdk.LinkFortyLogger +import com.linkforty.sdk.SdkInfo import com.linkforty.sdk.errors.LinkFortyError import com.linkforty.sdk.models.AnyJsonAdapter import com.linkforty.sdk.models.LinkFortyConfig @@ -127,6 +128,10 @@ internal class NetworkManager( val requestHeaders = mutableMapOf() requestHeaders["Content-Type"] = "application/json" + // Identify the SDK + version on every request (mirrors the sdkName/ + // sdkVersion fields in the install/event payloads) for backend diagnostics. + requestHeaders["X-LinkForty-SDK"] = "${SdkInfo.NAME}/${SdkInfo.VERSION}" + config.apiKey?.let { apiKey -> requestHeaders["Authorization"] = "Bearer $apiKey" } diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageKeys.kt b/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageKeys.kt index 39f7724..a90c42c 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageKeys.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageKeys.kt @@ -18,4 +18,7 @@ internal object StorageKeys { /** First launch flag key */ const val FIRST_LAUNCH = "$PREFIX.firstLaunch" + + /** Active last-click attribution context key (ActiveAttribution JSON) */ + const val ATTRIBUTION = "$PREFIX.attribution" } diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageManager.kt b/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageManager.kt index a2f2ac0..f8d1ca8 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageManager.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/storage/StorageManager.kt @@ -16,6 +16,9 @@ internal interface StorageManagerProtocol { fun getInstallData(): DeepLinkData? fun isFirstLaunch(): Boolean fun setHasLaunched() + fun saveAttribution(json: String) + fun getAttribution(): String? + fun removeAttribution() fun clearAll() } @@ -73,6 +76,20 @@ internal class StorageManager( prefs.edit().putBoolean(StorageKeys.FIRST_LAUNCH, true).apply() } + // -- Attribution -- + + override fun saveAttribution(json: String) { + prefs.edit().putString(StorageKeys.ATTRIBUTION, json).apply() + } + + override fun getAttribution(): String? { + return prefs.getString(StorageKeys.ATTRIBUTION, null) + } + + override fun removeAttribution() { + prefs.edit().remove(StorageKeys.ATTRIBUTION).apply() + } + // -- Clear Data -- override fun clearAll() { @@ -80,6 +97,7 @@ internal class StorageManager( .remove(StorageKeys.INSTALL_ID) .remove(StorageKeys.INSTALL_DATA) .remove(StorageKeys.FIRST_LAUNCH) + .remove(StorageKeys.ATTRIBUTION) .apply() } } diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/attribution/AttributionContextTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/attribution/AttributionContextTest.kt new file mode 100644 index 0000000..12d4796 --- /dev/null +++ b/sdk/src/test/kotlin/com/linkforty/sdk/attribution/AttributionContextTest.kt @@ -0,0 +1,96 @@ +package com.linkforty.sdk.attribution + +import com.linkforty.sdk.testhelpers.MockStorageManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AttributionContextTest { + + private lateinit var storage: MockStorageManager + + @BeforeEach + fun setUp() { + storage = MockStorageManager() + } + + @Test + fun `fresh context has a session but no link`() { + val ctx = AttributionContext(storage) + val stamp = ctx.getStamp() + assertTrue(stamp.sessionId.isNotEmpty()) + assertNull(stamp.attributedLinkId) + assertNull(stamp.attributedClickId) + assertNull(stamp.linkOpenedAt) + } + + @Test + fun `recordDeepLinkOpen stamps the link`() { + val ctx = AttributionContext(storage) + ctx.recordDeepLinkOpen("link-A", "click-1") + + val stamp = ctx.getStamp() + assertEquals("link-A", stamp.attributedLinkId) + assertEquals("click-1", stamp.attributedClickId) + assertNotNull(stamp.linkOpenedAt) + } + + @Test + fun `newest open supersedes and rotates the session`() { + val ctx = AttributionContext(storage) + ctx.recordDeepLinkOpen("link-A") + val first = ctx.getStamp() + + ctx.recordDeepLinkOpen("link-B") + val second = ctx.getStamp() + + assertEquals("link-B", second.attributedLinkId) // newest wins + assertNotEquals(first.sessionId, second.sessionId) // session rotates + } + + @Test + fun `organic open (no linkId) is a no-op`() { + val ctx = AttributionContext(storage) + ctx.recordDeepLinkOpen("link-A") + val sessionAfterLink = ctx.getStamp().sessionId + + ctx.recordDeepLinkOpen(null) + val stamp = ctx.getStamp() + + assertEquals("link-A", stamp.attributedLinkId) + assertEquals(sessionAfterLink, stamp.sessionId) + } + + @Test + fun `active context persists across instances and starts a new session`() { + val first = AttributionContext(storage) + first.recordDeepLinkOpen("link-A") + + val second = AttributionContext(storage) + val stamp = second.getStamp() + + assertEquals("link-A", stamp.attributedLinkId) + assertNotEquals(first.getStamp().sessionId, stamp.sessionId) + } + + @Test + fun `clear removes the link and rotates the session`() { + val ctx = AttributionContext(storage) + ctx.recordDeepLinkOpen("link-A") + val before = ctx.getStamp().sessionId + + ctx.clear() + val stamp = ctx.getStamp() + + assertNull(stamp.attributedLinkId) + assertNotEquals(before, stamp.sessionId) + + // Cleared state must not be restored by a new instance. + val reopened = AttributionContext(storage) + assertNull(reopened.getStamp().attributedLinkId) + } +} diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/events/EventTrackerTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/events/EventTrackerTest.kt index 607bd22..2e0c7f4 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/events/EventTrackerTest.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/events/EventTrackerTest.kt @@ -1,5 +1,6 @@ package com.linkforty.sdk.events +import com.linkforty.sdk.attribution.AttributionContext import com.linkforty.sdk.errors.LinkFortyError import com.linkforty.sdk.models.EventResponse import com.linkforty.sdk.network.HttpMethod @@ -22,6 +23,7 @@ class EventTrackerTest { private lateinit var mockStorage: MockStorageManager private lateinit var networkManager: NetworkManager private lateinit var eventQueue: EventQueue + private lateinit var attributionContext: AttributionContext private lateinit var sut: EventTracker @BeforeEach @@ -36,7 +38,8 @@ class EventTrackerTest { ) networkManager = NetworkManager(config, mockHttpClient) eventQueue = EventQueue() - sut = EventTracker(networkManager, mockStorage, eventQueue) + attributionContext = AttributionContext(mockStorage) + sut = EventTracker(networkManager, mockStorage, attributionContext, eventQueue) } // -- Track Event Tests -- @@ -58,6 +61,52 @@ class EventTrackerTest { } } + // -- Last-click attribution + screen views (SIT-237) -- + + @Test + fun `trackEvent stamps the active last-click attribution`() = runTest { + mockHttpClient.mockResponse = HttpResponse(200, """{"success": true}""".toByteArray()) + attributionContext.recordDeepLinkOpen("link-A", "click-1") + + sut.trackEvent("purchase") + + val body = String(mockHttpClient.lastBody!!) + assertTrue(body.contains("\"attributedLinkId\":\"link-A\"")) + assertTrue(body.contains("\"attributedClickId\":\"click-1\"")) + assertTrue(body.contains("\"sessionId\":")) + } + + @Test + fun `organic event carries a session but no link`() = runTest { + mockHttpClient.mockResponse = HttpResponse(200, """{"success": true}""".toByteArray()) + + sut.trackEvent("organic_event") + + val body = String(mockHttpClient.lastBody!!) + assertTrue(body.contains("\"sessionId\":")) + assertTrue(!body.contains("attributedLinkId")) + } + + @Test + fun `trackScreenView emits screen_view with screen and previousScreen`() = runTest { + mockHttpClient.mockResponse = HttpResponse(200, """{"success": true}""".toByteArray()) + + sut.trackScreenView("Home") + sut.trackScreenView("ProductDetail") + + val body = String(mockHttpClient.lastBody!!) + assertTrue(body.contains("\"eventName\":\"screen_view\"")) + assertTrue(body.contains("\"screen\":\"ProductDetail\"")) + assertTrue(body.contains("\"previousScreen\":\"Home\"")) + } + + @Test + fun `trackScreenView throws for empty name`() = runTest { + assertThrows { + sut.trackScreenView(" ") + } + } + @Test fun `trackEvent throws for blank name`() = runTest { assertThrows { diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt index 4295c67..bac214f 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt @@ -86,6 +86,19 @@ class NetworkManagerTest { assertEquals("Bearer test-api-key", authHeader) } + @Test + fun `request includes SDK identity header`() = runTest { + mockHttpClient.mockResponse = HttpResponse(200, """{"ok": true}""".toByteArray()) + + sut.request( + endpoint = "/test", + method = HttpMethod.GET + ) + + val sdkHeader = mockHttpClient.lastHeaders?.get("X-LinkForty-SDK") + assertEquals("${com.linkforty.sdk.SdkInfo.NAME}/${com.linkforty.sdk.SdkInfo.VERSION}", sdkHeader) + } + @Test fun `request without API key has no auth header`() = runTest { val configNoKey = LinkFortyConfig( diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/testhelpers/MockHelpers.kt b/sdk/src/test/kotlin/com/linkforty/sdk/testhelpers/MockHelpers.kt index 0f5b74e..ee5e92c 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/testhelpers/MockHelpers.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/testhelpers/MockHelpers.kt @@ -53,6 +53,8 @@ class MockStorageManager : StorageManagerProtocol { var mockInstallData: DeepLinkData? = null var mockIsFirstLaunch = true + var savedAttribution: String? = null + override fun saveInstallId(installId: String) { savedInstallId = installId } @@ -71,8 +73,19 @@ class MockStorageManager : StorageManagerProtocol { hasLaunchedCalled = true } + override fun saveAttribution(json: String) { + savedAttribution = json + } + + override fun getAttribution(): String? = savedAttribution + + override fun removeAttribution() { + savedAttribution = null + } + override fun clearAll() { clearAllCalled = true + savedAttribution = null } } From 254d3c4df6eb9a916672c66fdb415a6697501585 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Thu, 11 Jun 2026 08:53:51 -0700 Subject: [PATCH 2/2] docs: document SDK identity, attribution, and screen-view APIs Update the README and API reference for trackScreenView / LinkFortyNavObserver and automatic last-click attribution. Add a RELEASING guide. --- API.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 4 ++++ README.md | 26 +++++++++++++++++++++++++- RELEASING.md | 18 ++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 RELEASING.md diff --git a/API.md b/API.md index 35fa4b8..1b4f9bc 100644 --- a/API.md +++ b/API.md @@ -13,6 +13,7 @@ Complete API reference for the LinkForty Android SDK. - [UTMParameters](#utmparameters) - UTM tracking parameters - [LinkFortyError](#linkfortyerror) - Error types - [Type Aliases](#type-aliases) - Callback types +- [LinkFortyNavObserver](#linkfortynavobserver) - Automatic screen-view tracking --- @@ -218,6 +219,32 @@ LinkForty.shared.trackRevenue( --- +#### trackScreenView(name, properties) + +Reports a screen view. Emits a `screen_view` event carrying the screen name and the previously tracked screen, stamped with the active last-click attribution context, so the dashboard can build a per-link screen-flow funnel. + +```kotlin +suspend fun trackScreenView( + name: String, + properties: Map? = null +) +``` + +**Parameters:** +- `name`: Screen name (e.g., "ProductDetail"). Must not be blank. +- `properties`: Optional additional properties + +**Throws:** `LinkFortyError` if tracking fails (including a blank screen name) + +**Example:** +```kotlin +LinkForty.shared.trackScreenView("ProductDetail") +``` + +For automatic tracking with Jetpack Navigation, use [`LinkFortyNavObserver`](#linkfortynavobserver). + +--- + #### flushEvents() Flushes the event queue, attempting to send all queued events. @@ -494,6 +521,30 @@ typealias DeepLinkCallback = (Uri, DeepLinkData?) -> Unit --- +## LinkFortyNavObserver + +A `NavController.OnDestinationChangedListener` that automatically reports a `screen_view` (see [`trackScreenView`](#trackscreenviewname-properties)) whenever the Jetpack Navigation destination changes. + +Requires `androidx.navigation`, which your app already provides if it uses Jetpack Navigation. The SDK depends on it only as `compileOnly`, so apps that don't use Navigation are unaffected. + +```kotlin +class LinkFortyNavObserver( + screenNameExtractor: (NavDestination) -> String? = ::defaultScreenName, + scope: CoroutineScope = CoroutineScope(Dispatchers.Main) +) : NavController.OnDestinationChangedListener +``` + +By default a destination's `route` (Compose navigation) or `label` (XML nav graph) is used as the screen name; destinations with neither are skipped. Pass `screenNameExtractor` to customize. + +**Example:** +```kotlin +import com.linkforty.sdk.navigation.LinkFortyNavObserver + +navController.addOnDestinationChangedListener(LinkFortyNavObserver()) +``` + +--- + ## Thread Safety All SDK methods are thread-safe. Callbacks are executed on the main thread. Internal state is protected by Kotlin `Mutex`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 786afc6..f7b8eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- ## [Unreleased] +### Added +- The SDK now identifies itself on every request: a `sdkName` (`"android"`) and `sdkVersion` field is included on the install and event payloads, and an `X-LinkForty-SDK: android/` header is sent on all requests. This lets the backend report which SDKs and versions are in use and flag outdated integrations. The reported version is sourced from `BuildConfig` so it always matches the published artifact. No API or integration changes are required. +- **Last-click attribution for in-app events.** Every tracked event is now stamped with the deep link that most recently opened the app (deferred install *or* direct re-engagement) plus an app-open `sessionId`, so the backend can credit in-app activity to the originating link. The newest deep-link open supersedes the previous one, and the active link is persisted across app restarts; events with no preceding deep-link open stay organic (session only). Fully automatic. +- **Screen-view tracking** for per-link screen-flow funnels. New `LinkForty.shared.trackScreenView(name)` emits a `screen_view` event (carrying the screen name, the previous screen, and the active attribution stamp). For automatic tracking with Jetpack Navigation, attach `LinkFortyNavObserver` to your `NavController` (`androidx.navigation` is a `compileOnly` dependency, so apps that don't use Navigation are unaffected). ## [1.2.0] - 2026-05-04 ### Added diff --git a/README.md b/README.md index 5d53cb8..1467aca 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Native Android SDK for [LinkForty](https://github.com/LinkForty/core) — the op - **Custom URL Schemes**: Handle custom app URL schemes - **Event Tracking**: Track in-app events and conversions - **Revenue Tracking**: Dedicated revenue tracking with BigDecimal precision +- **Last-Click Attribution**: In-app events are automatically credited to the deep link that most recently opened the app +- **Screen-Flow Tracking**: Report screen views (manually or automatically via a Jetpack Navigation listener) to see what users do after clicking a link - **Offline Support**: Queue events when offline with automatic retry (max 100 events) - **Programmatic Link Creation**: Create short links directly from your app - **Privacy-First**: No GAID collection by default @@ -172,7 +174,29 @@ LinkForty.shared.trackRevenue( ) ``` -### 5. Create Links Programmatically +Every event is automatically stamped with the deep link that most recently opened the app (last-click attribution), so the dashboard can show what users do *after* clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required. + +### 5. Track Screen Views + +Reporting screen views lets the dashboard build a per-link screen-flow funnel. Each `screen_view` carries the same last-click attribution stamp as other events. + +**Automatic (Jetpack Navigation)** — attach `LinkFortyNavObserver` to your `NavController`. Each destination's `route` (Compose) or `label` (XML nav graph) is reported as it appears: + +```kotlin +import com.linkforty.sdk.navigation.LinkFortyNavObserver + +navController.addOnDestinationChangedListener(LinkFortyNavObserver()) +``` + +> `LinkFortyNavObserver` requires `androidx.navigation`, which your app already has if it uses Jetpack Navigation. The SDK depends on it only as `compileOnly`, so apps that don't use Navigation aren't affected. + +**Manual** — call it yourself (e.g. for screens not driven by a `NavController`): + +```kotlin +LinkForty.shared.trackScreenView("ProductDetail") +``` + +### 6. Create Links Programmatically ```kotlin val result = LinkForty.shared.createLink( diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a24d8e5 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,18 @@ +# Releasing + +## Version + +The version lives in **one place**: `VERSION_NAME` in [`gradle.properties`](gradle.properties). + +It flows automatically to: +- the published Maven artifact (`version = property("VERSION_NAME")` in `sdk/build.gradle.kts`), and +- the version the SDK reports to the backend at runtime — `sdkVersion` field on install/event payloads and the `X-LinkForty-SDK` header — via `BuildConfig.SDK_VERSION` (a `buildConfigField` sourced from `VERSION_NAME`, exposed through `com.linkforty.sdk.SdkInfo`). + +So there is **no separate version constant to bump** — update `VERSION_NAME` and everything stays in sync. + +## Steps + +1. Bump `VERSION_NAME` in `gradle.properties`. +2. Update `CHANGELOG.md` (move `[Unreleased]` to the new version heading). +3. Commit, then create and push the git tag (matching `VERSION_NAME`). +4. Publish to Maven Central (`./gradlew publish`), or let the release workflow do it.