diff --git a/.gitignore b/.gitignore
index aa724b7..c2866d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
*.iml
.gradle
+.kotlin/
/local.properties
/.idea/caches
/.idea/libraries
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..ddcbcd0
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+flex-track-kotlin
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..1eedd53
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..64f56bf
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..ed8e69c
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 0d775ea..b2c751a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,1737 +1,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index d843f34..94a25f7 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,4 +1,6 @@
-
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index e1f0c96..ce257fc 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ routing with deterministic cross-SDK behavior.
The Kotlin SDK targets Android API 21+ and implements
[FlexTrack Core Specification 1.0.0](contract/README.md), shared with
-[FlexTrack Flutter 2.1.0](https://pub.dev/packages/flex_track).
+[FlexTrack Flutter 2.2.0](https://pub.dev/packages/flex_track).
## Features
@@ -82,6 +82,23 @@ client.flush()
client.shutdown()
```
+### Debug Logcat
+
+Enable structured diagnostics in debug builds without adding a logging
+framework dependency:
+
+```kotlin
+logger = AndroidLogcatLogger(
+ context = applicationContext,
+ level = FlexTrackLogLevel.BASIC,
+)
+```
+
+Filter Logcat by the `FlexTrack` tag. Routing, delivery, failures, queueing,
+offline skips, retries, and flush summaries are reported. Event properties and
+PII are never included. `AndroidLogcatLogger` produces no output when the host
+application is not debuggable.
+
All client operations are `suspend` functions. Call them from an application-
owned coroutine scope. `FileEventQueue` stores failed/offline deliveries in the
app's private files directory and retries only the destinations still pending.
@@ -92,6 +109,19 @@ app's private files directory and retries only the destinations still pending.
- `sample`: Android application that consumes `flextrack` as a project dependency.
- `contract`: shared specification and deterministic Flutter/Kotlin fixtures.
+### Sample application
+
+The `sample` module is a production-style Compose application using MVVM,
+Hilt, StateFlow, Navigation Compose, and DataStore. It mirrors the Flutter
+example with functional screens for:
+
+- Home event demonstrations and batch tracking
+- E-commerce cart, purchase, and abandonment flows
+- User registration, profile, feature, engagement, and churn journeys
+- Consent, network, runtime status, flushing, and diagnostics
+- Event enrichment with a live event log
+- Persistent offline delivery and selective retry
+
## Build
```bash
diff --git a/build.gradle.kts b/build.gradle.kts
index 67f437e..67644a8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,4 +3,7 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
}
diff --git a/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt b/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt
new file mode 100644
index 0000000..4c8fd52
--- /dev/null
+++ b/flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt
@@ -0,0 +1,40 @@
+package dev.flextrack.logging
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.util.Log
+
+public enum class FlexTrackLogLevel { OFF, BASIC, VERBOSE }
+
+/** Logging boundary. Implementations must never throw into analytics delivery. */
+public fun interface FlexTrackLogger {
+ public fun log(message: String)
+}
+
+public object NoOpFlexTrackLogger : FlexTrackLogger {
+ override fun log(message: String): Unit = Unit
+}
+
+/** Privacy-safe Logcat output that is forcibly disabled for non-debuggable apps. */
+public class AndroidLogcatLogger(
+ context: Context,
+ public val level: FlexTrackLogLevel = FlexTrackLogLevel.BASIC,
+ private val tag: String = "FlexTrack",
+) : FlexTrackLogger {
+ private val enabled: Boolean =
+ context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
+ level != FlexTrackLogLevel.OFF
+
+ override fun log(message: String) {
+ if (!enabled) return
+ runCatching { Log.d(tag, message) }
+ }
+}
+
+internal fun FlexTrackLogger.safeLog(message: () -> String) {
+ runCatching { log(message()) }
+}
+
+/** Only the debuggable Android logger can opt into event values. */
+internal fun FlexTrackLogger.includesPropertyValues(): Boolean =
+ this is AndroidLogcatLogger && level == FlexTrackLogLevel.VERBOSE
diff --git a/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt b/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt
index b48d313..3afc7ff 100644
--- a/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt
+++ b/flextrack/src/main/kotlin/dev/flextrack/runtime/FlexTrackClient.kt
@@ -2,6 +2,10 @@ package dev.flextrack.runtime
import dev.flextrack.event.FlexEvent
import dev.flextrack.event.TransformerPipeline
+import dev.flextrack.logging.FlexTrackLogger
+import dev.flextrack.logging.NoOpFlexTrackLogger
+import dev.flextrack.logging.includesPropertyValues
+import dev.flextrack.logging.safeLog
import dev.flextrack.routing.ConsentState
import dev.flextrack.routing.RoutingEngine
import kotlinx.coroutines.async
@@ -17,10 +21,18 @@ public class FlexTrackClient(
public val transformers: TransformerPipeline = TransformerPipeline(),
private val consentProvider: () -> ConsentState = { ConsentState() },
private val onlineProvider: () -> Boolean = { true },
+ private val logger: FlexTrackLogger = NoOpFlexTrackLogger,
) {
- public suspend fun start(): Unit = registry.start()
+ public suspend fun start() {
+ registry.start()
+ val trackerCount = registry.snapshot().size
+ logger.safeLog { "🚀 START trackers=$trackerCount" }
+ }
- public suspend fun shutdown(): Unit = registry.shutdown()
+ public suspend fun shutdown() {
+ registry.shutdown()
+ logger.safeLog { "⏹ SHUTDOWN" }
+ }
public suspend fun register(tracker: Tracker): Unit = registry.register(tracker)
@@ -31,9 +43,32 @@ public class FlexTrackClient(
val trackers = registry.snapshot()
val routing = routingEngine.route(transformed, consentProvider(), trackers.keys)
val targets = routing.targetTrackers
+ val propertyKeys = transformed.properties.orEmpty().keys.sorted()
+ logger.safeLog {
+ "🟣 ROUTE ${transformed.name} targets=${targets.renderIds()} " +
+ "properties=${propertyKeys.size} keys=${propertyKeys.renderIds()}"
+ }
+ if (logger.includesPropertyValues()) {
+ logger.safeLog {
+ "🔎 PAYLOAD ${transformed.name} eventId=${transformed.eventId} " +
+ "values=${transformed.properties.orEmpty()}"
+ }
+ }
+ if (routing.skippedRules.isNotEmpty() || routing.warnings.isNotEmpty()) {
+ val skipped = routing.skippedRules.joinToString(prefix = "[", postfix = "]") {
+ "${it.rule.id ?: "unnamed"}: ${it.reason}"
+ }
+ logger.safeLog {
+ "🟡 SKIPPED ${transformed.name} rules=$skipped warnings=${routing.warnings}"
+ }
+ }
if (!onlineProvider() && targets.isNotEmpty()) {
queue.enqueue(QueuedEvent(transformed.eventId, transformed, targets))
+ val queueSize = queue.size()
+ logger.safeLog {
+ "🟠 QUEUED ${transformed.name} → ${targets.renderIds()} queue=$queueSize reason=offline"
+ }
return DispatchResult(transformed, routing, emptyList(), emptyList(), targets)
}
@@ -42,6 +77,10 @@ public class FlexTrackClient(
val failedIds = failures.map(TrackerFailure::trackerId)
if (failedIds.isNotEmpty()) {
queue.enqueue(QueuedEvent(transformed.eventId, transformed, failedIds))
+ val queueSize = queue.size()
+ logger.safeLog {
+ "🟠 QUEUED ${transformed.name} → ${failedIds.renderIds()} queue=$queueSize reason=delivery_failure"
+ }
}
return DispatchResult(
transformed,
@@ -54,7 +93,11 @@ public class FlexTrackClient(
public suspend fun flush(limit: Int = 100): FlushResult {
require(limit > 0) { "limit must be positive" }
- if (!onlineProvider()) return FlushResult(0, 0, queue.size())
+ if (!onlineProvider()) {
+ val remaining = queue.size()
+ logger.safeLog { "⚪ OFFLINE flush skipped queue=$remaining" }
+ return FlushResult(0, 0, remaining)
+ }
val items = queue.read(limit)
val trackers = registry.snapshot()
@@ -65,11 +108,19 @@ public class FlexTrackClient(
if (failedIds.isEmpty()) {
queue.remove(item.id)
delivered++
+ logger.safeLog { "🟢 RETRY ${item.event.name} delivered" }
} else {
queue.replace(item.copy(trackerIds = failedIds, attempts = item.attempts + 1))
+ logger.safeLog {
+ "🔴 RETRY ${item.event.name} pending=${failedIds.renderIds()} attempt=${item.attempts + 1}"
+ }
}
}
- return FlushResult(items.size, delivered, queue.size())
+ val result = FlushResult(items.size, delivered, queue.size())
+ logger.safeLog {
+ "🔵 FLUSH attempted=${result.attemptedEvents} delivered=${result.deliveredEvents} remaining=${result.remainingEvents}"
+ }
+ return result
}
private suspend fun deliver(
@@ -83,11 +134,17 @@ public class FlexTrackClient(
if (tracker == null) {
Outcome(id, TrackerFailure(id, IllegalStateException("tracker '$id' is unavailable")))
} else {
+ val startedAt = System.nanoTime()
try {
tracker.track(event)
+ val millis = (System.nanoTime() - startedAt) / 1_000_000
+ logger.safeLog { "🟢 DELIVER ${event.name} → $id ${millis}ms" }
Outcome(id)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
+ logger.safeLog {
+ "🔴 FAILED ${event.name} → $id error=${failure::class.simpleName ?: "Throwable"}"
+ }
Outcome(id, TrackerFailure(id, failure))
}
}
@@ -97,3 +154,5 @@ public class FlexTrackClient(
private data class Outcome(val trackerId: String, val failure: TrackerFailure? = null)
}
+
+private fun List.renderIds(): String = joinToString(prefix = "[", postfix = "]")
diff --git a/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt b/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt
index 161a5c4..5576f23 100644
--- a/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt
+++ b/flextrack/src/test/kotlin/dev/flextrack/runtime/FlexTrackClientTest.kt
@@ -1,6 +1,7 @@
package dev.flextrack.runtime
import dev.flextrack.event.FlexEvent
+import dev.flextrack.logging.FlexTrackLogger
import dev.flextrack.routing.ConsentState
import dev.flextrack.routing.RoutingConfiguration
import dev.flextrack.routing.RoutingEngine
@@ -90,10 +91,84 @@ class FlexTrackClientTest {
assertTrue(tracker.events.isEmpty())
}
+ @Test
+ fun `structured logs show property keys but never property values`() = runTest {
+ val messages = mutableListOf()
+ val logger = FlexTrackLogger(messages::add)
+ val tracker = RecordingTracker("analytics", fail = true)
+ val queue = InMemoryEventQueue()
+ val client = client(queue = queue, logger = logger)
+ client.register(tracker)
+
+ client.track(TestEvent())
+ tracker.fail = false
+ client.flush()
+
+ assertTrue(
+ messages.any { "ROUTE purchase targets=[analytics] properties=1 keys=[plan]" in it },
+ messages.toString(),
+ )
+ assertTrue(messages.any { "FAILED purchase → analytics" in it })
+ assertTrue(messages.any { "QUEUED purchase" in it })
+ assertTrue(messages.any { "FLUSH attempted=1 delivered=1 remaining=0" in it })
+ assertTrue(messages.none { "secret-value" in it })
+ }
+
+ @Test
+ fun `route logs explain consent rejection`() = runTest {
+ val messages = mutableListOf()
+ val client = client(
+ queue = InMemoryEventQueue(),
+ consent = { ConsentState() },
+ logger = FlexTrackLogger(messages::add),
+ )
+ client.register(RecordingTracker("analytics"))
+
+ client.track(TestEvent(requiresConsentValue = true))
+
+ assertTrue(messages.any { "ROUTE purchase targets=[] properties=1 keys=[plan]" in it })
+ assertTrue(messages.any { "SKIPPED purchase" in it && "Consent requirements not met" in it })
+ }
+
+ @Test
+ fun `offline flush logs skip and never delivers`() = runTest {
+ val messages = mutableListOf()
+ val queue = InMemoryEventQueue()
+ queue.enqueue(QueuedEvent("queued", TestEvent(), listOf("analytics")))
+ val client = client(
+ queue = queue,
+ online = { false },
+ logger = FlexTrackLogger(messages::add),
+ )
+ val tracker = RecordingTracker("analytics")
+ client.register(tracker)
+
+ val result = client.flush()
+
+ assertEquals(0, result.attemptedEvents)
+ assertTrue(tracker.events.isEmpty())
+ assertTrue(messages.any { it == "⚪ OFFLINE flush skipped queue=1" })
+ }
+
+ @Test
+ fun `logger failures never interrupt delivery`() = runTest {
+ val tracker = RecordingTracker("analytics")
+ val client = client(
+ queue = InMemoryEventQueue(),
+ logger = FlexTrackLogger { error("logger failed") },
+ )
+ client.register(tracker)
+
+ val result = client.track(TestEvent())
+
+ assertEquals(listOf("analytics"), result.successfulTrackerIds)
+ }
+
private fun client(
queue: EventQueue,
online: () -> Boolean = { true },
consent: () -> ConsentState = { ConsentState(general = true) },
+ logger: FlexTrackLogger = FlexTrackLogger { },
): FlexTrackClient = FlexTrackClient(
routingEngine = RoutingEngine(
RoutingConfiguration(
@@ -107,13 +182,14 @@ class FlexTrackClientTest {
queue = queue,
onlineProvider = online,
consentProvider = consent,
+ logger = logger,
)
private class TestEvent(
private val requiresConsentValue: Boolean = false,
) : FlexEvent() {
override val name: String = "purchase"
- override val properties: Map = mapOf("plan" to "pro")
+ override val properties: Map = mapOf("plan" to "secret-value")
override val requiresConsent: Boolean = requiresConsentValue
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b8c7b2e..f7fabc5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,6 +11,14 @@ material = "1.10.0"
desugarJdkLibs = "2.1.5"
coroutines = "1.9.0"
json = "20250107"
+composeBom = "2026.06.01"
+activityCompose = "1.12.4"
+lifecycle = "2.10.0"
+navigationCompose = "2.9.5"
+hilt = "2.57.1"
+hiltNavigationCompose = "1.3.0"
+ksp = "2.0.21-1.0.28"
+datastore = "1.2.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -25,8 +33,26 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
json = { module = "org.json:json", version.ref = "json" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui" }
+androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
+androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
+androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
+hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index 6a1bb27..a742c32 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -1,6 +1,9 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
}
android {
@@ -11,7 +14,7 @@ android {
defaultConfig {
applicationId = "dev.flextrack.sample"
- minSdk = 21
+ minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "1.0"
@@ -36,16 +39,35 @@ android {
kotlinOptions {
jvmTarget = "11"
}
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
}
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(project(":flextrack"))
implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.appcompat)
- implementation(libs.material)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.hilt.navigation.compose)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt b/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt
new file mode 100644
index 0000000..aa661b6
--- /dev/null
+++ b/sample/src/androidTest/java/dev/flextrack/sample/DeliveryScreenTest.kt
@@ -0,0 +1,44 @@
+package dev.flextrack.sample
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import dev.flextrack.runtime.FlushResult
+import dev.flextrack.sample.ui.DeliveryUiState
+import org.junit.Rule
+import org.junit.Test
+
+class DeliveryScreenTest {
+ @get:Rule
+ val compose = createComposeRule()
+
+ @Test
+ fun rendersOfflineQueueAndSelectiveRetryResult() {
+ compose.setContent {
+ DeliveryScreen(
+ state = DeliveryUiState(
+ loading = false,
+ isOnline = false,
+ queueSize = 2,
+ deliveredIds = listOf("sample_reliable"),
+ failedIds = listOf("sample_retry"),
+ queuedIds = listOf("sample_retry"),
+ flushResult = FlushResult(1, 0, 2),
+ ),
+ onBack = {},
+ onOnlineChanged = {},
+ onConsentChanged = {},
+ onRetryHealthyChanged = {},
+ onTrack = {},
+ onFlush = {},
+ )
+ }
+
+ compose.onNodeWithText("Offline Delivery Lab").assertIsDisplayed()
+ compose.onNodeWithTag("queue-count").assertIsDisplayed()
+ compose.onNodeWithText("Pending events: 2").assertIsDisplayed()
+ compose.onNodeWithText("Delivered: sample_reliable").assertIsDisplayed()
+ compose.onNodeWithText("Queued: sample_retry").assertIsDisplayed()
+ }
+}
diff --git a/sample/src/androidTest/java/dev/flextrack/sample/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/dev/flextrack/sample/ExampleInstrumentedTest.kt
deleted file mode 100644
index 13057af..0000000
--- a/sample/src/androidTest/java/dev/flextrack/sample/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package dev.flextrack.sample
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("dev.flextrack.sample", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index cef8d4c..064fbc8 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
Unit) {
+ Scaffold(topBar = { TopAppBar(title = { Text("FlexTrack Kotlin") }) }) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("Production-style sample", style = MaterialTheme.typography.headlineMedium)
+ Text("Jetpack Compose · MVVM · Hilt · StateFlow · DataStore")
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Offline delivery", style = MaterialTheme.typography.titleLarge)
+ Text("Persist events, restore after process death, and retry only failed destinations.")
+ Button(onClick = onOpenDeliveryLab) { Text("Open Delivery Lab") }
+ }
+ }
}
}
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DeliveryScreen(
+ state: DeliveryUiState,
+ onBack: () -> Unit,
+ onOnlineChanged: (Boolean) -> Unit,
+ onConsentChanged: (Boolean) -> Unit,
+ onRetryHealthyChanged: (Boolean) -> Unit,
+ onTrack: () -> Unit,
+ onFlush: () -> Unit,
+) {
+ Scaffold(topBar = {
+ TopAppBar(
+ title = { Text("Offline Delivery Lab") },
+ navigationIcon = { OutlinedButton(onClick = onBack) { Text("Back") } },
+ )
+ }) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ SettingRow("Network available", state.isOnline, onOnlineChanged, "network-toggle")
+ SettingRow("Analytics consent", state.hasConsent, onConsentChanged, "consent-toggle")
+ SettingRow(
+ "Retry destination healthy",
+ state.retryDestinationHealthy,
+ onRetryHealthyChanged,
+ "failure-toggle",
+ )
+
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ "Pending events: ${state.queueSize}",
+ modifier = Modifier.testTag("queue-count"),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Text("Reliable: ${state.reliableDeliveries} delivered / ${state.reliableAttempts} attempts")
+ Text("Retry: ${state.retryDeliveries} delivered / ${state.retryAttempts} attempts")
+ }
+ }
- override fun onDestroy() {
- scope.launch { client.shutdown() }.invokeOnCompletion { scope.cancel() }
- super.onDestroy()
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(
+ onClick = onTrack,
+ enabled = !state.loading,
+ modifier = Modifier.weight(1f).testTag("track-event"),
+ ) { Text("Track event") }
+ OutlinedButton(
+ onClick = onFlush,
+ enabled = !state.loading,
+ modifier = Modifier.weight(1f).testTag("flush-queue"),
+ ) { Text("Flush queue") }
+ }
+
+ ResultCard("Delivered", state.deliveredIds)
+ ResultCard("Failed", state.failedIds)
+ ResultCard("Queued", state.queuedIds)
+ state.flushResult?.let {
+ Text("Flush: ${it.attemptedEvents} attempted · ${it.deliveredEvents} delivered · ${it.remainingEvents} remaining")
+ }
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ Spacer(Modifier.height(24.dp))
+ }
}
}
-private class PurchaseEvent : FlexEvent() {
- override val name: String = "purchase"
- override val properties: Map = mapOf("plan" to "pro", "currency" to "EUR")
+@Composable
+private fun SettingRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, tag: String) {
+ Card(Modifier.fillMaxWidth()) {
+ Row(
+ Modifier.fillMaxWidth().padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label)
+ Switch(checked = checked, onCheckedChange = onChange, modifier = Modifier.testTag(tag))
+ }
+ }
}
-private class ConsoleTracker(private val log: (String) -> Unit) : Tracker {
- override val id: String = "console"
-
- override suspend fun track(event: FlexEvent) {
- log("${event.name}: ${event.properties}")
- }
+@Composable
+private fun ResultCard(label: String, values: List) {
+ Text("$label: ${values.ifEmpty { listOf("none") }.joinToString()}")
}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/analytics/DemoAnalytics.kt b/sample/src/main/kotlin/dev/flextrack/sample/analytics/DemoAnalytics.kt
new file mode 100644
index 0000000..222d2c6
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/analytics/DemoAnalytics.kt
@@ -0,0 +1,68 @@
+package dev.flextrack.sample.analytics
+
+import android.util.Log
+import dev.flextrack.sample.BuildConfig
+import dev.flextrack.event.FlexEvent
+import dev.flextrack.runtime.Tracker
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+class DeliveryLabEvent(sequence: Int) : FlexEvent() {
+ override val name: String = "sample_delivery_lab"
+ override val properties: Map = mapOf("sequence" to sequence)
+ override val requiresConsent: Boolean = false
+}
+
+class SampleEvent(
+ override val name: String,
+ override val properties: Map = emptyMap(),
+ override val requiresConsent: Boolean = true,
+) : FlexEvent()
+
+@Singleton
+class FailureController @Inject constructor() {
+ val retryDestinationFails = AtomicBoolean(false)
+}
+
+abstract class CountingTracker : Tracker {
+ var attempts: Int = 0
+ private set
+ var deliveries: Int = 0
+ private set
+
+ final override suspend fun track(event: FlexEvent) {
+ attempts++
+ deliver(event)
+ deliveries++
+ if (BuildConfig.DEBUG) {
+ runCatching {
+ Log.d(
+ "FlexTrackSample",
+ "🧪 RECEIVED ${event.name} tracker=$id " +
+ "eventId=${event.eventId} properties=${event.properties.orEmpty()}",
+ )
+ }
+ }
+ }
+
+ protected open suspend fun deliver(event: FlexEvent) = Unit
+}
+
+@Singleton
+class ReliableDemoTracker @Inject constructor() : CountingTracker() {
+ override val id: String = "sample_reliable"
+}
+
+@Singleton
+class RetryDemoTracker @Inject constructor(
+ private val failureController: FailureController,
+) : CountingTracker() {
+ override val id: String = "sample_retry"
+
+ override suspend fun deliver(event: FlexEvent) {
+ check(!failureController.retryDestinationFails.get()) {
+ "Intentional sample tracker failure"
+ }
+ }
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/data/DeliveryRepository.kt b/sample/src/main/kotlin/dev/flextrack/sample/data/DeliveryRepository.kt
new file mode 100644
index 0000000..39e5f3e
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/data/DeliveryRepository.kt
@@ -0,0 +1,71 @@
+package dev.flextrack.sample.data
+
+import dev.flextrack.runtime.DispatchResult
+import dev.flextrack.runtime.FlexTrackClient
+import dev.flextrack.runtime.FlushResult
+import dev.flextrack.sample.analytics.DeliveryLabEvent
+import dev.flextrack.sample.analytics.FailureController
+import dev.flextrack.sample.analytics.ReliableDemoTracker
+import dev.flextrack.sample.analytics.RetryDemoTracker
+import dev.flextrack.sample.analytics.SampleEvent
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DeliveryRepository @Inject constructor(
+ private val client: FlexTrackClient,
+ private val preferences: DemoPreferences,
+ val reliableTracker: ReliableDemoTracker,
+ val retryTracker: RetryDemoTracker,
+ private val failureController: FailureController,
+) {
+ private val initializationMutex = Mutex()
+ private var initialized = false
+ val isOnline: Flow = preferences.isOnline
+ val hasConsent: Flow = preferences.hasConsent
+
+ suspend fun initialize(): FlushResult = initializationMutex.withLock {
+ if (!initialized) {
+ preferences.initialize()
+ client.register(reliableTracker)
+ client.register(retryTracker)
+ client.start()
+ initialized = true
+ }
+ client.flush()
+ }
+
+ suspend fun setOnline(value: Boolean) = preferences.setOnline(value)
+ suspend fun setConsent(value: Boolean) = preferences.setConsent(value)
+
+ fun setRetryDestinationHealthy(value: Boolean) {
+ failureController.retryDestinationFails.set(!value)
+ }
+
+ fun retryDestinationHealthy(): Boolean =
+ !failureController.retryDestinationFails.get()
+
+ suspend fun track(): DispatchResult = client.track(
+ DeliveryLabEvent(reliableTracker.attempts + retryTracker.attempts + 1),
+ )
+
+ suspend fun trackSample(
+ name: String,
+ properties: Map = emptyMap(),
+ requiresConsent: Boolean = true,
+ ): DispatchResult {
+ // Sample screens may emit before the Delivery screen/view model exists.
+ // Initialization is idempotent and guarantees trackers are registered first.
+ initialize()
+ return client.track(SampleEvent(name, properties, requiresConsent))
+ }
+
+ suspend fun flush(): FlushResult = client.flush()
+ suspend fun queueSize(): Int = client.queue.size()
+ suspend fun onlineNow(): Boolean = isOnline.first()
+ suspend fun consentNow(): Boolean = hasConsent.first()
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/data/DemoPreferences.kt b/sample/src/main/kotlin/dev/flextrack/sample/data/DemoPreferences.kt
new file mode 100644
index 0000000..d021345
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/data/DemoPreferences.kt
@@ -0,0 +1,48 @@
+package dev.flextrack.sample.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.demoDataStore by preferencesDataStore("flextrack_sample")
+
+@Singleton
+class DemoPreferences @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val networkKey = booleanPreferencesKey("network_available")
+ private val consentKey = booleanPreferencesKey("analytics_consent")
+ private val onlineCache = AtomicBoolean(true)
+ private val consentCache = AtomicBoolean(true)
+
+ val isOnline: Flow = context.demoDataStore.data
+ .map { it[networkKey] ?: true }
+ val hasConsent: Flow = context.demoDataStore.data
+ .map { it[consentKey] ?: true }
+
+ fun onlineNow(): Boolean = onlineCache.get()
+ fun consentNow(): Boolean = consentCache.get()
+
+ suspend fun initialize() {
+ onlineCache.set(isOnline.first())
+ consentCache.set(hasConsent.first())
+ }
+
+ suspend fun setOnline(value: Boolean) {
+ onlineCache.set(value)
+ context.demoDataStore.edit { it[networkKey] = value }
+ }
+
+ suspend fun setConsent(value: Boolean) {
+ consentCache.set(value)
+ context.demoDataStore.edit { it[consentKey] = value }
+ }
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/di/AnalyticsModule.kt b/sample/src/main/kotlin/dev/flextrack/sample/di/AnalyticsModule.kt
new file mode 100644
index 0000000..8350209
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/di/AnalyticsModule.kt
@@ -0,0 +1,63 @@
+package dev.flextrack.sample.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import dev.flextrack.routing.RoutingConfiguration
+import dev.flextrack.routing.ConsentState
+import dev.flextrack.logging.AndroidLogcatLogger
+import dev.flextrack.logging.FlexTrackLogLevel
+import dev.flextrack.routing.RoutingEngine
+import dev.flextrack.routing.RoutingRule
+import dev.flextrack.routing.TrackerGroup
+import dev.flextrack.runtime.FileEventQueue
+import dev.flextrack.runtime.FlexTrackClient
+import dev.flextrack.sample.data.DemoPreferences
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AnalyticsModule {
+ @Provides
+ @Singleton
+ fun provideClient(
+ @ApplicationContext context: Context,
+ preferences: DemoPreferences,
+ ): FlexTrackClient = FlexTrackClient(
+ routingEngine = RoutingEngine(
+ RoutingConfiguration(
+ rules = listOf(
+ RoutingRule(
+ id = "delivery-lab",
+ eventNameContains = "sample_delivery_lab",
+ targetGroup = TrackerGroup(
+ "sample-destinations",
+ listOf("sample_reliable", "sample_retry"),
+ ),
+ requireConsent = false,
+ priority = 100,
+ ),
+ RoutingRule(
+ id = "sample-default",
+ isDefault = true,
+ targetGroup = TrackerGroup(
+ "sample-default-destination",
+ listOf("sample_reliable"),
+ ),
+ requireConsent = true,
+ priority = 0,
+ ),
+ ),
+ ),
+ ),
+ queue = FileEventQueue(context),
+ consentProvider = { ConsentState(general = preferences.consentNow()) },
+ onlineProvider = preferences::onlineNow,
+ // The sample intentionally exposes payload values to demonstrate debugging.
+ // AndroidLogcatLogger still disables itself for non-debuggable builds.
+ logger = AndroidLogcatLogger(context, FlexTrackLogLevel.VERBOSE),
+ )
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/ui/DeliveryViewModel.kt b/sample/src/main/kotlin/dev/flextrack/sample/ui/DeliveryViewModel.kt
new file mode 100644
index 0000000..7927713
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/ui/DeliveryViewModel.kt
@@ -0,0 +1,99 @@
+package dev.flextrack.sample.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.flextrack.runtime.DispatchResult
+import dev.flextrack.runtime.FlushResult
+import dev.flextrack.sample.data.DeliveryRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class DeliveryUiState(
+ val loading: Boolean = true,
+ val isOnline: Boolean = true,
+ val hasConsent: Boolean = true,
+ val retryDestinationHealthy: Boolean = true,
+ val queueSize: Int = 0,
+ val reliableAttempts: Int = 0,
+ val reliableDeliveries: Int = 0,
+ val retryAttempts: Int = 0,
+ val retryDeliveries: Int = 0,
+ val deliveredIds: List = emptyList(),
+ val failedIds: List = emptyList(),
+ val queuedIds: List = emptyList(),
+ val flushResult: FlushResult? = null,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class DeliveryViewModel @Inject constructor(
+ private val repository: DeliveryRepository,
+) : ViewModel() {
+ private val _state = MutableStateFlow(DeliveryUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ repository.initialize()
+ combine(repository.isOnline, repository.hasConsent, ::Pair)
+ .collect { (online, consent) ->
+ refresh { copy(isOnline = online, hasConsent = consent, loading = false) }
+ }
+ }
+ }
+
+ fun setOnline(value: Boolean) = runAction { repository.setOnline(value) }
+ fun setConsent(value: Boolean) = runAction { repository.setConsent(value) }
+
+ fun setRetryDestinationHealthy(value: Boolean) {
+ repository.setRetryDestinationHealthy(value)
+ _state.update { it.copy(retryDestinationHealthy = value) }
+ }
+
+ fun track() = runAction {
+ val result = repository.track()
+ refresh { withDispatch(result) }
+ }
+
+ fun flush() = runAction {
+ val result = repository.flush()
+ refresh { copy(flushResult = result) }
+ }
+
+ private fun runAction(block: suspend () -> Unit) {
+ viewModelScope.launch {
+ _state.update { it.copy(loading = true, error = null) }
+ runCatching { block() }
+ .onFailure { failure ->
+ _state.update { it.copy(error = failure.message ?: failure.toString()) }
+ }
+ refresh { copy(loading = false) }
+ }
+ }
+
+ private suspend fun refresh(change: DeliveryUiState.() -> DeliveryUiState) {
+ _state.update {
+ it.change().copy(
+ queueSize = repository.queueSize(),
+ reliableAttempts = repository.reliableTracker.attempts,
+ reliableDeliveries = repository.reliableTracker.deliveries,
+ retryAttempts = repository.retryTracker.attempts,
+ retryDeliveries = repository.retryTracker.deliveries,
+ retryDestinationHealthy = repository.retryDestinationHealthy(),
+ )
+ }
+ }
+
+ private fun DeliveryUiState.withDispatch(result: DispatchResult): DeliveryUiState = copy(
+ deliveredIds = result.successfulTrackerIds,
+ failedIds = result.failures.map { it.trackerId },
+ queuedIds = result.queuedTrackerIds,
+ flushResult = null,
+ )
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleScreens.kt b/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleScreens.kt
new file mode 100644
index 0000000..c17692d
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleScreens.kt
@@ -0,0 +1,217 @@
+package dev.flextrack.sample.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+data class SampleDestination(val route: String, val title: String, val description: String)
+
+val sampleDestinations = listOf(
+ SampleDestination("ecommerce", "E-commerce", "Cart, purchase, and abandonment events"),
+ SampleDestination("journey", "User journey", "Registration, profile, engagement, and churn"),
+ SampleDestination("settings", "Settings", "Consent, runtime status, flush, and test tools"),
+ SampleDestination("enrichment", "Enrichment", "Transform events and inspect the live log"),
+ SampleDestination("delivery", "Delivery", "Persistent offline queue and selective retry"),
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleHomeScreen(
+ state: SampleUiState,
+ onTrack: (String, Map) -> Unit,
+ onNavigate: (String) -> Unit,
+) {
+ Scaffold(topBar = { TopAppBar(title = { Text("FlexTrack control room") }) }) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ TelemetryHeader("Android SDK sample", "Compose · MVVM · Hilt · StateFlow · DataStore")
+ Text(state.message)
+ }
+ item {
+ ActionGrid(
+ actions = listOf("Basic event", "Business event", "User event", "Error", "Performance", "Debug"),
+ onAction = { label ->
+ onTrack(label.lowercase().replace(' ', '_'), mapOf("surface" to "home"))
+ },
+ )
+ }
+ items(sampleDestinations) { destination ->
+ PageCard(destination.title, destination.description) { onNavigate(destination.route) }
+ }
+ item {
+ PageCard("Batch events", "Send three events through the same runtime") {
+ repeat(3) { onTrack("batch_event", mapOf("index" to it)) }
+ }
+ }
+ }
+ }
+}
+
+data class Product(val id: String, val name: String, val price: Double)
+
+@Composable
+fun EcommerceScreen(onBack: () -> Unit, onTrack: (String, Map) -> Unit) {
+ val products = remember { listOf(Product("pro", "Pro plan", 29.0), Product("team", "Team plan", 79.0), Product("scale", "Scale plan", 149.0)) }
+ val cart = remember { mutableStateListOf() }
+ SamplePage("E-commerce", "Revenue events with a live cart", onBack) {
+ item { Text("Cart: ${cart.size} items · €${cart.sumOf { it.price }}") }
+ items(products) { product ->
+ Card(Modifier.fillMaxWidth()) {
+ Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Column { Text(product.name); Text("€${product.price}") }
+ Button(onClick = {
+ cart += product
+ onTrack("add_to_cart", mapOf("product_id" to product.id, "price" to product.price))
+ }) { Text("Add") }
+ }
+ }
+ }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(enabled = cart.isNotEmpty(), onClick = {
+ onTrack("purchase", mapOf("items" to cart.size, "total" to cart.sumOf { it.price }))
+ cart.clear()
+ }) { Text("Checkout") }
+ OutlinedButton(enabled = cart.isNotEmpty(), onClick = {
+ onTrack("cart_abandonment", mapOf("items" to cart.size)); cart.clear()
+ }) { Text("Abandon cart") }
+ OutlinedButton(onClick = cart::clear) { Text("Clear") }
+ }
+ }
+ }
+}
+
+@Composable
+fun UserJourneyScreen(onBack: () -> Unit, onTrack: (String, Map) -> Unit) {
+ val stages = listOf("Welcome", "Registration", "Profile", "Features", "Engagement")
+ var stage by remember { mutableIntStateOf(0) }
+ SamplePage("User journey", "Stage ${stage + 1} of ${stages.size}", onBack) {
+ item { TelemetryHeader(stages[stage], "Follow a complete lifecycle and inspect each emitted event.") }
+ item {
+ ActionGrid(
+ actions = when (stage) {
+ 1 -> listOf("Register email", "Register Google", "Register Apple")
+ 2 -> listOf("Update profile", "Set user properties")
+ 3 -> listOf("Search", "Favorites", "Share", "Notifications")
+ 4 -> listOf("Deep engagement", "Churn risk")
+ else -> listOf("Get started")
+ },
+ onAction = { onTrack("journey_${it.lowercase().replace(' ', '_')}", mapOf("stage" to stage)) },
+ )
+ }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(enabled = stage > 0, onClick = { stage--; onTrack("journey_previous", mapOf("stage" to stage)) }) { Text("Previous") }
+ Button(onClick = {
+ if (stage < stages.lastIndex) stage++ else stage = 0
+ onTrack("journey_next", mapOf("stage" to stage))
+ }) { Text(if (stage == stages.lastIndex) "Complete" else "Next") }
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(
+ state: DeliveryUiState,
+ onBack: () -> Unit,
+ onConsent: (Boolean) -> Unit,
+ onOnline: (Boolean) -> Unit,
+ onFlush: () -> Unit,
+ onTrack: (String, Map) -> Unit,
+) {
+ SamplePage("Settings", "Privacy, runtime status, and diagnostics", onBack) {
+ item { ToggleCard("Analytics consent", state.hasConsent, onConsent) }
+ item { ToggleCard("Network available", state.isOnline, onOnline) }
+ item { TelemetryHeader("FlexTrack status", "${state.queueSize} queued · 2 registered destinations") }
+ item {
+ ActionGrid(listOf("Flush events", "Send test events", "Test routing", "Validate config", "Export log")) {
+ when (it) {
+ "Flush events" -> onFlush()
+ else -> onTrack("settings_${it.lowercase().replace(' ', '_')}", emptyMap())
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun EnrichmentScreen(
+ state: SampleUiState,
+ onBack: () -> Unit,
+ onToggle: () -> Unit,
+ onTrack: (String, Map) -> Unit,
+ onClear: () -> Unit,
+) {
+ SamplePage("Event enrichment", "Attach shared context before routing", onBack) {
+ item { ToggleCard("Context transformer", state.transformerEnabled) { onToggle() } }
+ item {
+ ActionGrid(listOf("Button event", "Page view", "Click wrapper")) {
+ onTrack("enrichment_${it.lowercase().replace(' ', '_')}", mapOf("source" to "enrichment"))
+ }
+ }
+ item {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("Event log"); OutlinedButton(onClick = onClear) { Text("Clear") }
+ }
+ }
+ if (state.eventLog.isEmpty()) item { Text("Fire an event to see it here.") }
+ items(state.eventLog) { line -> Card(Modifier.fillMaxWidth()) { Text(line, Modifier.padding(12.dp)) } }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SamplePage(title: String, subtitle: String, onBack: () -> Unit, content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) {
+ Scaffold(topBar = { TopAppBar(title = { Column { Text(title); Text(subtitle) } }, navigationIcon = { OutlinedButton(onClick = onBack) { Text("Back") } }) }) { padding ->
+ LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
+ }
+}
+
+@Composable
+private fun TelemetryHeader(title: String, subtitle: String) {
+ Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(title, style = androidx.compose.material3.MaterialTheme.typography.headlineMedium)
+ Text(subtitle, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium)
+ }
+}
+
+@Composable
+private fun PageCard(title: String, description: String, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(title); Text(description) }; Button(onClick = onClick) { Text("Open") } } }
+}
+
+@Composable
+private fun ActionGrid(actions: List, onAction: (String) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { actions.chunked(2).forEach { row -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { row.forEach { action -> OutlinedButton(onClick = { onAction(action) }, modifier = Modifier.weight(1f)) { Text(action) } } } } }
+}
+
+@Composable
+private fun ToggleCard(title: String, value: Boolean, onChange: (Boolean) -> Unit) {
+ Card(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(title); Switch(checked = value, onCheckedChange = onChange) } }
+}
diff --git a/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleViewModel.kt b/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleViewModel.kt
new file mode 100644
index 0000000..f5190d0
--- /dev/null
+++ b/sample/src/main/kotlin/dev/flextrack/sample/ui/SampleViewModel.kt
@@ -0,0 +1,49 @@
+package dev.flextrack.sample.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.flextrack.sample.data.DeliveryRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class SampleUiState(
+ val message: String = "Ready",
+ val eventLog: List = emptyList(),
+ val transformerEnabled: Boolean = false,
+)
+
+@HiltViewModel
+class SampleViewModel @Inject constructor(
+ private val repository: DeliveryRepository,
+) : ViewModel() {
+ private val _state = MutableStateFlow(SampleUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun track(name: String, properties: Map = emptyMap()) {
+ viewModelScope.launch {
+ val enriched = if (_state.value.transformerEnabled) {
+ properties + mapOf("app_version" to "1.0", "environment" to "sample")
+ } else properties
+ runCatching { repository.trackSample(name, enriched) }
+ .onSuccess { result ->
+ val destination = result.successfulTrackerIds.ifEmpty { result.queuedTrackerIds }
+ val line = "$name → ${destination.joinToString().ifEmpty { "not routed" }}"
+ _state.update {
+ it.copy(message = line, eventLog = (listOf(line) + it.eventLog).take(20))
+ }
+ }
+ .onFailure { failure -> _state.update { it.copy(message = failure.message ?: "Failed") } }
+ }
+ }
+
+ fun toggleTransformer() {
+ _state.update { it.copy(transformerEnabled = !it.transformerEnabled) }
+ }
+
+ fun clearLog() = _state.update { it.copy(eventLog = emptyList()) }
+}
diff --git a/sample/src/main/res/values-night/themes.xml b/sample/src/main/res/values-night/themes.xml
index df3bfe9..335c041 100644
--- a/sample/src/main/res/values-night/themes.xml
+++ b/sample/src/main/res/values-night/themes.xml
@@ -1,16 +1,5 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/sample/src/main/res/values/themes.xml b/sample/src/main/res/values/themes.xml
index 7ca5daa..e564b16 100644
--- a/sample/src/main/res/values/themes.xml
+++ b/sample/src/main/res/values/themes.xml
@@ -1,16 +1,6 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/sample/src/test/java/dev/flextrack/sample/DemoAnalyticsTest.kt b/sample/src/test/java/dev/flextrack/sample/DemoAnalyticsTest.kt
new file mode 100644
index 0000000..24cebf8
--- /dev/null
+++ b/sample/src/test/java/dev/flextrack/sample/DemoAnalyticsTest.kt
@@ -0,0 +1,49 @@
+package dev.flextrack.sample
+
+import dev.flextrack.sample.analytics.DeliveryLabEvent
+import dev.flextrack.sample.analytics.FailureController
+import dev.flextrack.sample.analytics.ReliableDemoTracker
+import dev.flextrack.sample.analytics.RetryDemoTracker
+import dev.flextrack.sample.analytics.SampleEvent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+
+class DemoAnalyticsTest {
+ @Test
+ fun reliableTrackerCountsDelivery() = runTest {
+ val tracker = ReliableDemoTracker()
+
+ tracker.track(DeliveryLabEvent(1))
+
+ assertEquals(1, tracker.attempts)
+ assertEquals(1, tracker.deliveries)
+ }
+
+ @Test
+ fun reliableTrackerReceivesRegularSampleEvents() = runTest {
+ val tracker = ReliableDemoTracker()
+
+ tracker.track(SampleEvent("add_to_cart", mapOf("product_id" to "shoe-1")))
+
+ assertEquals(1, tracker.attempts)
+ assertEquals(1, tracker.deliveries)
+ }
+
+ @Test
+ fun retryTrackerCountsAttemptButNotDeliveryWhenFailing() = runTest {
+ val failure = FailureController().apply { retryDestinationFails.set(true) }
+ val tracker = RetryDemoTracker(failure)
+
+ try {
+ tracker.track(DeliveryLabEvent(1))
+ fail("Expected intentional tracker failure")
+ } catch (_: IllegalStateException) {
+ // Expected.
+ }
+
+ assertEquals(1, tracker.attempts)
+ assertEquals(0, tracker.deliveries)
+ }
+}
diff --git a/sample/src/test/java/dev/flextrack/sample/ExampleUnitTest.kt b/sample/src/test/java/dev/flextrack/sample/ExampleUnitTest.kt
deleted file mode 100644
index b6e8e85..0000000
--- a/sample/src/test/java/dev/flextrack/sample/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.flextrack.sample
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file