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