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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions contract/PARITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Flutter–Kotlin test parity

Reference: Flutter `v2.2.0` at
`f38c0618374272ec256bc2809fa70ad86c555631`.

## Shared behavioral contract

| Area | Shared input | Kotlin coverage |
| --- | --- | --- |
| Routing, consent, sampling, enrichment, debug decisions | `core_mvp_cases.json` | `CoreMvpConformanceTest` |
| Offline queueing, partial delivery, selective retry, FIFO, idempotency, lifecycle | `runtime_mvp_cases.json` | `RuntimeMvpConformanceTest` |

Both implementations must consume the same vendored JSON cases. A behavior
change requires updating the fixture version and both conformance reports.

## Platform-equivalent Kotlin coverage

- Transformer execution occurs once across retry.
- Concurrent flush calls cannot redeliver the same queued event.
- Offline flush performs no tracker delivery.
- Queue snapshots cannot mutate internal queue state.
- Partial tracker startup is rolled back and can be retried.
- Shutdown is idempotent.
- Route logs explain consent and unavailable-destination decisions.
- Property values require explicit verbose opt-in.
- Logcat is disabled for every level in non-debuggable builds.
- File queue tests cover recreation, identity/metadata, malformed data,
invalid shape, concurrent enqueue, duplicate IDs, replacement order, and
invalid read limits.
- Compose delivery tests cover rendered queue state, consent/network actions,
track/flush actions, and loading-state duplicate prevention.

## Intentional platform differences

Flutter widget tests (`FlexClickTrack`, `FlexImpressionTrack`,
`FlexMountTrack`, and `FlexRouteTrack`) map to Compose UI interaction and
navigation tests, not line-for-line ports. Flutter's HTTP/WebSocket Inspector
maps to Android's debug-only structured Logcat output. Dart exception-type and
environment-detector tests do not apply directly to the Kotlin API.

## Execution boundary

JVM unit tests run without a device. `FileEventQueueInstrumentedTest` and
`DeliveryScreenTest` compile into Android test APKs and must also run on an
emulator or physical device in CI before release.
17 changes: 10 additions & 7 deletions contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ This directory contains the language-neutral inputs used to keep the Flutter
and Kotlin SDKs behaviorally compatible.

- Core specification: `1.0.0`
- Runtime specification: `1.0.0`
- Fixture version: `1.0.0`
- Flutter reference: `flex_track` `v2.1.0`
- Flutter reference commit: `78d7f46c2479b9680268ab794443375e3959d441`
- Flutter reference: `flex_track` `v2.2.0`
- Flutter reference commit: `f38c0618374272ec256bc2809fa70ad86c555631`

Canonical sources:

- [Core MVP specification](https://github.com/alirezat66/flex_track/blob/v2.1.0/doc/core-mvp-specification.md)
- [Conformance runner contract](https://github.com/alirezat66/flex_track/blob/v2.1.0/doc/conformance.md)
- [Flutter fixtures](https://github.com/alirezat66/flex_track/tree/v2.1.0/test/fixtures/conformance)
- [Core MVP specification](https://github.com/alirezat66/flex_track/blob/v2.2.0/doc/core-mvp-specification.md)
- [Conformance runner contract](https://github.com/alirezat66/flex_track/blob/v2.2.0/doc/conformance.md)
- [Flutter fixtures](https://github.com/alirezat66/flex_track/tree/v2.2.0/test/fixtures/conformance)
- [Runtime fixtures](https://github.com/alirezat66/flex_track/tree/v2.2.0/test/fixtures/conformance)

The JSON files in this directory are vendored so Kotlin CI never depends on
network availability or a moving Flutter branch. Updates require an explicit
fixture version change and source-reference update.

Queues, persistence, retry/backoff, session management, SDK-owned identity,
and optimized batching are intentionally outside Core MVP 1.0.0.
Queueing and selective retry are specified separately by Runtime MVP 1.0.0.
See [PARITY.md](PARITY.md) for platform-equivalent coverage and intentional
Flutter/Android differences.
29 changes: 29 additions & 0 deletions contract/runtime_mvp.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flextrack.taghizadeh.dev/schemas/runtime-mvp-1.0.0.json",
"title": "FlexTrack Runtime MVP fixtures",
"type": "object",
"required": ["specVersion", "fixtureVersion", "cases"],
"additionalProperties": false,
"properties": {
"specVersion": {"const": "1.0.0"},
"fixtureVersion": {"type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$"},
"cases": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "behavior", "input", "expected"],
"additionalProperties": false,
"properties": {
"id": {"type": "string", "minLength": 1},
"behavior": {
"enum": ["offline", "partialFailure", "flush", "queue", "lifecycle"]
},
"input": {"type": "object"},
"expected": {"type": "object"}
}
}
}
}
}
73 changes: 73 additions & 0 deletions contract/runtime_mvp_cases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"$schema": "runtime_mvp.schema.json",
"specVersion": "1.0.0",
"fixtureVersion": "1.0.0",
"cases": [
{
"id": "offline.queue-all-targets",
"behavior": "offline",
"input": {"targets": ["analytics", "archive"]},
"expected": {"attempted": [], "queued": ["analytics", "archive"], "queueSize": 1}
},
{
"id": "offline.no-target-no-queue",
"behavior": "offline",
"input": {"targets": []},
"expected": {"attempted": [], "queued": [], "queueSize": 0}
},
{
"id": "delivery.partial-failure",
"behavior": "partialFailure",
"input": {"targets": ["analytics", "archive"], "failing": ["archive"]},
"expected": {"successful": ["analytics"], "queued": ["archive"], "queueSize": 1}
},
{
"id": "delivery.all-success",
"behavior": "partialFailure",
"input": {"targets": ["analytics", "archive"], "failing": []},
"expected": {"successful": ["analytics", "archive"], "queued": [], "queueSize": 0}
},
{
"id": "flush.selective-retry-success",
"behavior": "flush",
"input": {"pending": ["archive"], "failing": [], "online": true},
"expected": {"attemptedEvents": 1, "deliveredEvents": 1, "remainingEvents": 0, "pending": []}
},
{
"id": "flush.retain-only-failures",
"behavior": "flush",
"input": {"pending": ["analytics", "archive"], "failing": ["archive"], "online": true},
"expected": {"attemptedEvents": 1, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["archive"], "attempts": 1}
},
{
"id": "flush.offline-noop",
"behavior": "flush",
"input": {"pending": ["analytics"], "failing": [], "online": false},
"expected": {"attemptedEvents": 0, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["analytics"], "attempts": 0}
},
{
"id": "queue.duplicate-id-idempotent",
"behavior": "queue",
"input": {"operation": "duplicate", "eventIds": ["event-1", "event-1"]},
"expected": {"eventIds": ["event-1"], "queueSize": 1}
},
{
"id": "queue.fifo-limit",
"behavior": "queue",
"input": {"operation": "read", "eventIds": ["event-1", "event-2", "event-3"], "limit": 2},
"expected": {"eventIds": ["event-1", "event-2"], "queueSize": 3}
},
{
"id": "queue.replace-preserves-position",
"behavior": "queue",
"input": {"operation": "replace", "eventIds": ["event-1", "event-2"]},
"expected": {"eventIds": ["event-1", "event-2"], "queueSize": 2, "attempts": [1, 0]}
},
{
"id": "lifecycle.initialize-idempotent",
"behavior": "lifecycle",
"input": {"initializeCalls": 2},
"expected": {"trackerInitializeCalls": 1}
}
]
}
4 changes: 4 additions & 0 deletions flextrack/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ android {
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
Expand Down Expand Up @@ -53,6 +54,9 @@ dependencies {
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.json)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.test)
}

publishing {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package dev.flextrack.runtime

import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dev.flextrack.event.FlexEvent
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.Instant
import java.util.UUID

@RunWith(AndroidJUnit4::class)
class FileEventQueueInstrumentedTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val files = mutableListOf<File>()

@After
fun cleanUp() {
files.forEach { file ->
file.delete()
File("${file.path}.bak").delete()
File("${file.path}.new").delete()
}
}

@Test
fun survivesRecreationAndPreservesIdentityAndMetadata() = runTest {
val name = queueName()
val original = TestEvent("stable-id")
FileEventQueue(context, name).enqueue(
QueuedEvent(original.eventId, original, listOf("a", "b")),
)

val restored = FileEventQueue(context, name).read(10).single()

assertEquals(original.eventId, restored.id)
assertEquals(original.timestamp, restored.event.timestamp)
assertEquals(original.name, restored.event.name)
assertEquals(original.properties, restored.event.properties)
assertEquals(listOf("a", "b"), restored.trackerIds)
}

@Test
fun malformedJsonFailsWithoutDeletingPersistedBytes() = runTest {
val name = queueName()
val file = trackedFile(name).apply { writeText("{broken") }

expectFailure<Throwable> { FileEventQueue(context, name).read(10) }
assertEquals("{broken", file.readText())
}

@Test
fun invalidPersistedShapeFailsVisibly() = runTest {
val name = queueName()
trackedFile(name).writeText("{}")

expectFailure<Throwable> { FileEventQueue(context, name).size() }
}

@Test
fun concurrentEnqueuesAreSerializedWithoutLoss() = runTest {
val queue = FileEventQueue(context, queueName())

(0 until 50).map { index ->
async {
val id = "event-$index"
queue.enqueue(QueuedEvent(id, TestEvent(id), listOf("analytics")))
}
}.awaitAll()

assertEquals(50, queue.size())
assertEquals((0 until 50).map { "event-$it" }, queue.read(50).map { it.id })
}

@Test
fun duplicateIdIsIdempotentAndReplacePreservesPositionAcrossRecreation() = runTest {
val name = queueName()
val queue = FileEventQueue(context, name)
queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("a")))
queue.enqueue(QueuedEvent("two", TestEvent("two"), listOf("a")))
queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("b")))
queue.replace(queue.read(10).first().copy(attempts = 1))

val restored = FileEventQueue(context, name).read(10)

assertEquals(listOf("one", "two"), restored.map { it.id })
assertEquals(listOf(1, 0), restored.map { it.attempts })
assertEquals(listOf("a"), restored.first().trackerIds)
}

@Test
fun nonPositiveReadDoesNotMutateStorage() = runTest {
val name = queueName()
val queue = FileEventQueue(context, name)
queue.enqueue(QueuedEvent("one", TestEvent("one"), listOf("a")))

expectFailure<IllegalArgumentException> { queue.read(0) }

assertEquals(1, FileEventQueue(context, name).size())
}

private fun queueName(): String = "flextrack-test-${UUID.randomUUID()}.json".also(::trackedFile)

private fun trackedFile(name: String): File = File(context.filesDir, name).also {
if (it !in files) files += it
}

private suspend inline fun <reified T : Throwable> expectFailure(
crossinline block: suspend () -> Unit,
) {
val failure = runCatching { block() }.exceptionOrNull()
assertTrue("Expected ${T::class.java.name}, got $failure", failure is T)
}

private class TestEvent(id: String) : FlexEvent(
id,
Instant.parse("2026-08-17T12:30:00Z"),
) {
override val name: String = "purchase"
override val properties: Map<String, Any> = mapOf(
"plan" to "pro",
"nested" to mapOf("enabled" to true),
"items" to listOf(1, "two"),
)
override val requiresConsent: Boolean = false
}
}
19 changes: 14 additions & 5 deletions flextrack/src/main/kotlin/dev/flextrack/logging/FlexTrackLogger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ 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)

/** Explicit opt-in for payload values. Keep false for production loggers. */
public val includesPropertyValues: Boolean get() = false
}

public object NoOpFlexTrackLogger : FlexTrackLogger {
Expand All @@ -22,8 +25,13 @@ public class AndroidLogcatLogger(
private val tag: String = "FlexTrack",
) : FlexTrackLogger {
private val enabled: Boolean =
context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
level != FlexTrackLogLevel.OFF
shouldEnableLogcat(
isDebuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0,
level = level,
)

override val includesPropertyValues: Boolean
get() = enabled && level == FlexTrackLogLevel.VERBOSE

override fun log(message: String) {
if (!enabled) return
Expand All @@ -35,6 +43,7 @@ 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
internal fun shouldEnableLogcat(
isDebuggable: Boolean,
level: FlexTrackLogLevel,
): Boolean = isDebuggable && level != FlexTrackLogLevel.OFF
Loading
Loading