Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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<String, Any>? = 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.
Expand Down Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 28 additions & 2 deletions sdk/src/main/kotlin/com/linkforty/sdk/LinkForty.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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<String, Any>? = null) {
if (!isInitialized) throw LinkFortyError.NotInitialized()
eventTracker?.trackScreenView(name, properties)
}

/**
* Flushes the event queue, attempting to send all queued events.
*/
Expand Down Expand Up @@ -367,6 +391,7 @@ class LinkForty private constructor() {
*/
fun clearData() {
attributionManager?.clearData()
attributionContext?.clear()
eventTracker?.clearQueue()
deepLinkHandler?.clearCallbacks()
externalUserId = null
Expand All @@ -381,6 +406,7 @@ class LinkForty private constructor() {
config = null
networkManager = null
attributionManager = null
attributionContext = null
eventTracker = null
deepLinkHandler = null
externalUserId = null
Expand Down
17 changes: 17 additions & 0 deletions sdk/src/main/kotlin/com/linkforty/sdk/SdkInfo.kt
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading