diff --git a/Cable/AppDelegate.swift b/Cable/AppDelegate.swift index 8fa19ab..78f4282 100644 --- a/Cable/AppDelegate.swift +++ b/Cable/AppDelegate.swift @@ -19,6 +19,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") if isFirstLaunch { UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") + } + // Before the first log call: every event carries this launch's tenure counters. + UsageMetrics.beginLaunch(isFirstLaunch: isFirstLaunch) + if isFirstLaunch { AnalyticsTracker.log("First Launch") } ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch) @@ -31,8 +35,12 @@ enum AnalyticsTracker { static func configure() {} static func log(_ event: String, properties: [String: Any] = [:]) { + // Tenure counters first so an explicit property of the same name would win. + var merged = UsageMetrics.eventProps + for (key, value) in properties { merged[key] = value } + var converted: [String: Any] = [:] - for (key, value) in properties { + for (key, value) in merged { switch value { case let s as String: converted[key] = s case let i as Int: converted[key] = i @@ -44,10 +52,10 @@ enum AnalyticsTracker { } Aptabase.shared.trackEvent(event, with: converted) #if DEBUG - if properties.isEmpty { + if converted.isEmpty { NSLog("Analytics: %@", event) } else { - let formatted = properties + let formatted = converted .map { "\($0.key)=\($0.value)" } .sorted() .joined(separator: ", ") diff --git a/Cable/UsageMetrics.swift b/Cable/UsageMetrics.swift new file mode 100644 index 0000000..2235a22 --- /dev/null +++ b/Cable/UsageMetrics.swift @@ -0,0 +1,80 @@ +// +// UsageMetrics.swift +// Cable +// +// Makes retention measurable although the analytics backend cannot identify a device across +// days: Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that +// rotates every 24 h, so events can never be linked to the previous day's events. Sessions +// expire after an hour of inactivity, so `session_id` cannot bridge days either. +// +// Instead of an identity, every tracked event carries this install's own tenure counters, which +// never leave the device in raw form — only the derived day counts are sent. Exact retention +// curves can then be reconstructed by *counting events* in the export: +// +// installs on a given day launch_no == 1 && tenure_days == 0 +// installs active on day k dormant_days >= 1 && tenure_days == k +// D_k retention the latter / the former, k days earlier +// +// `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the +// second line count installs rather than launches. +// +// Days are UTC day indices so they line up with the timestamps in the analytics export. +// Mirrors the Android `UsageMetrics` object, which reports into the same Aptabase project. +// + +import Foundation + +enum UsageMetrics { + private enum Key { + static let installDay = "usage.installDay" + static let launchCount = "usage.launchCount" + static let activeDays = "usage.activeDays" + static let lastActiveDay = "usage.lastActiveDay" + } + + /// Injection seams for tests; production always uses `.standard` and the real clock. + static var store: UserDefaults = .standard + static var clock: () -> Date = Date.init + + /// Merged into every event by `AnalyticsTracker.log`. Empty until `beginLaunch` has run. + private(set) static var eventProps: [String: Any] = [:] + + /// Advances the counters once per process start and freezes this launch's props. + /// + /// `isFirstLaunch` is the app's own install marker (`hasLaunchedBefore`). Installs that + /// predate these counters have no known install date and report `tenure_days == -1` for the + /// rest of their life, so cohort analysis can exclude them instead of mistaking their first + /// instrumented launch for a fresh install. + static func beginLaunch(isFirstLaunch: Bool) { + let today = dayIndex(clock()) + + if isFirstLaunch, store.object(forKey: Key.installDay) == nil { + store.set(today, forKey: Key.installDay) + } + + let launchCount = store.integer(forKey: Key.launchCount) + 1 + store.set(launchCount, forKey: Key.launchCount) + + // nil on the very first instrumented launch — reported as -1 ("no previous use"), which + // keeps it out of the `dormant_days >= 1` day-boundary count. + let lastActiveDay = store.object(forKey: Key.lastActiveDay) as? Int + let dormantDays = lastActiveDay.map { max(0, today - $0) } ?? -1 + if lastActiveDay != today { + store.set(today, forKey: Key.lastActiveDay) + store.set(store.integer(forKey: Key.activeDays) + 1, forKey: Key.activeDays) + } + + let installDay = store.object(forKey: Key.installDay) as? Int + eventProps = [ + "tenure_days": installDay.map { max(0, today - $0) } ?? -1, + "launch_no": launchCount, + "active_days": store.integer(forKey: Key.activeDays), + "dormant_days": dormantDays, + ] + } + + /// Whole days since the Unix epoch, in UTC. + static func dayIndex(_ date: Date) -> Int { + Int(floor(date.timeIntervalSince1970 / 86_400)) + } +} diff --git a/CableTests/UsageMetricsTests.swift b/CableTests/UsageMetricsTests.swift new file mode 100644 index 0000000..4ab4e65 --- /dev/null +++ b/CableTests/UsageMetricsTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +@testable import Cable + +/// The retention counters are the only way to reconstruct D_k curves from an analytics backend +/// that cannot identify a device across days, so their arithmetic is verified here rather than +/// trusted in production. Serialized because `UsageMetrics.store` is process-wide state. +@Suite(.serialized) +struct UsageMetricsTests { + + /// Runs `body` against an isolated defaults suite and a clock the test drives itself. + private func withFreshStore(_ body: (UserDefaults, _ setDay: (Int) -> Void) -> Void) { + let name = "usage.tests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: name) else { + Issue.record("could not create a test defaults suite") + return + } + let previousStore = UsageMetrics.store + let previousClock = UsageMetrics.clock + var day = 20_000 + UsageMetrics.store = defaults + UsageMetrics.clock = { Date(timeIntervalSince1970: Double(day) * 86_400 + 3_600) } + defer { + UsageMetrics.store = previousStore + UsageMetrics.clock = previousClock + defaults.removePersistentDomain(forName: name) + } + body(defaults, { day = $0 }) + } + + private func props() -> [String: Int] { + UsageMetrics.eventProps.compactMapValues { $0 as? Int } + } + + @Test func firstLaunchOfANewInstallStartsTheCounters() { + withFreshStore { _, _ in + UsageMetrics.beginLaunch(isFirstLaunch: true) + #expect(props() == [ + "tenure_days": 0, "launch_no": 1, "active_days": 1, "dormant_days": -1, + ]) + } + } + + @Test func furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() { + withFreshStore { _, _ in + UsageMetrics.beginLaunch(isFirstLaunch: true) + UsageMetrics.beginLaunch(isFirstLaunch: false) + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props() == [ + "tenure_days": 0, "launch_no": 3, "active_days": 1, "dormant_days": 0, + ]) + } + } + + @Test func tenureAndActiveDaysAdvanceAcrossDays() { + withFreshStore { _, setDay in + UsageMetrics.beginLaunch(isFirstLaunch: true) + setDay(20_001) + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props() == [ + "tenure_days": 1, "launch_no": 2, "active_days": 2, "dormant_days": 1, + ]) + setDay(20_007) + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props() == [ + "tenure_days": 7, "launch_no": 3, "active_days": 3, "dormant_days": 6, + ]) + } + } + + /// The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly + /// one launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in + /// the export measure launches instead of installs. + @Test func exactlyOneLaunchPerDayMarksTheDayBoundary() { + withFreshStore { _, setDay in + var installMarkers = 0 + var dayMarkers = 0 + var isFirst = true + for day in 20_000...20_004 { + setDay(day) + for _ in 0..<3 { + UsageMetrics.beginLaunch(isFirstLaunch: isFirst) + isFirst = false + if props()["launch_no"] == 1 { installMarkers += 1 } + if props()["dormant_days", default: 0] >= 1 { dayMarkers += 1 } + } + } + #expect(installMarkers == 1) + // Day 0 is counted by the install marker, so the boundary marks days 1...4. + #expect(dayMarkers == 4) + #expect(props()["active_days"] == 5) + #expect(props()["launch_no"] == 15) + } + } + + /// Installs that predate the counters have no install date. They must stay distinguishable + /// from fresh installs forever, otherwise the update inflates the new-install cohort. + @Test func installsPredatingTheCountersReportUnknownTenure() { + withFreshStore { _, setDay in + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props() == [ + "tenure_days": -1, "launch_no": 1, "active_days": 1, "dormant_days": -1, + ]) + setDay(20_003) + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props() == [ + "tenure_days": -1, "launch_no": 2, "active_days": 2, "dormant_days": 3, + ]) + } + } + + @Test func aBackwardsClockNeverProducesNegativeCounts() { + withFreshStore { _, setDay in + setDay(20_010) + UsageMetrics.beginLaunch(isFirstLaunch: true) + setDay(20_002) + UsageMetrics.beginLaunch(isFirstLaunch: false) + #expect(props()["tenure_days"] == 0) + #expect(props()["dormant_days"] == 0) + } + } + + @Test func dayIndexIsAUTCDayCount() { + #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 0)) == 0) + #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_399)) == 0) + #expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_400)) == 1) + } +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f52531..d2d2301 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -122,6 +122,8 @@ dependencies { // Installs the bundled baseline profile on devices that do not get it from Play. implementation(libs.androidx.profileinstaller) + testImplementation(libs.junit) + // Consumes the profile produced by :baselineprofile. baselineProfile(project(":baselineprofile")) } diff --git a/android/app/src/main/java/app/voltplan/cable/CableApplication.kt b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt index 5a20e89..33de36b 100644 --- a/android/app/src/main/java/app/voltplan/cable/CableApplication.kt +++ b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt @@ -2,6 +2,7 @@ package app.voltplan.cable import android.app.Application import app.voltplan.cable.analytics.Analytics +import app.voltplan.cable.analytics.UsageMetrics import app.voltplan.cable.data.CableRepository import app.voltplan.cable.data.ReviewPrompt import app.voltplan.cable.data.UnitSystemSettings @@ -26,6 +27,8 @@ class CableApplication : Application() { // Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:). CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { val isFirstLaunch = settings.consumeFirstLaunch() + // Before the first log call: every event carries this launch's tenure counters. + UsageMetrics.beginLaunch(this@CableApplication, isFirstLaunch) if (isFirstLaunch) { Analytics.log("First Launch") } diff --git a/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt b/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt index 4bfcf58..53a9b9d 100644 --- a/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt +++ b/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt @@ -78,17 +78,19 @@ object Analytics { /** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */ fun log(event: String, properties: Map = emptyMap()) { + // Tenure counters first so an explicit property of the same name would win. + val merged = UsageMetrics.eventProps + properties if (BuildConfig.DEBUG) { - if (properties.isEmpty()) { + if (merged.isEmpty()) { Log.d(TAG, "Analytics: $event") } else { - val formatted = properties.entries.sortedBy { it.key } + val formatted = merged.entries.sortedBy { it.key } .joinToString(", ") { "${it.key}=${it.value}" } Log.d(TAG, "Analytics: $event { $formatted }") } } val props = buildJsonObject { - for ((key, value) in properties) { + for ((key, value) in merged) { when (value) { null -> {} is String -> put(key, value) diff --git a/android/app/src/main/java/app/voltplan/cable/analytics/UsageMetrics.kt b/android/app/src/main/java/app/voltplan/cable/analytics/UsageMetrics.kt new file mode 100644 index 0000000..977fd42 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/analytics/UsageMetrics.kt @@ -0,0 +1,114 @@ +package app.voltplan.cable.analytics + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import app.voltplan.cable.data.dataStore + +/** + * Makes retention measurable although the analytics backend cannot identify a device across days: + * Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that rotates + * every 24 h, so events can never be linked to the previous day's events. Sessions expire after an + * hour of inactivity, so `sessionId` cannot bridge days either. + * + * Instead of an identity, every tracked event carries this install's own tenure counters, which + * never leave the device in raw form — only the derived day counts are sent. Exact retention + * curves can then be reconstructed by *counting events* in the export: + * + * installs on a given day launch_no == 1 && tenure_days == 0 + * installs active on day k dormant_days >= 1 && tenure_days == k + * D_k retention the latter / the former, k days earlier + * + * `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the + * second line count installs rather than launches. + * + * Days are UTC day indices so they line up with the timestamps in the analytics export. + * Mirrors the iOS `UsageMetrics` enum, which reports into the same Aptabase project. The counter + * arithmetic lives in the pure [advance] so it can be tested without an Android context; iOS tests + * the same rules through its injectable `UserDefaults`. + */ +object UsageMetrics { + private val INSTALL_DAY = intPreferencesKey("usage.installDay") + private val LAUNCH_COUNT = intPreferencesKey("usage.launchCount") + private val ACTIVE_DAYS = intPreferencesKey("usage.activeDays") + private val LAST_ACTIVE_DAY = intPreferencesKey("usage.lastActiveDay") + + private const val DAY_MS = 86_400_000L + + /** Persisted counters. `installDay` and `lastActiveDay` are null until the first launch. */ + internal data class State( + val installDay: Int? = null, + val launchCount: Int = 0, + val activeDays: Int = 0, + val lastActiveDay: Int? = null, + ) + + internal data class Launch(val state: State, val props: Map) + + /** Merged into every event by [Analytics.log]. Empty until [beginLaunch] has run. */ + @Volatile + var eventProps: Map = emptyMap() + private set + + /** + * Advances the counters once per process start and freezes this launch's props. + * + * [isFirstLaunch] is the app's own install marker (`hasLaunchedBefore`, consumed by + * `UnitSystemSettings.consumeFirstLaunch`). Installs that predate these counters have no known + * install date and report `tenure_days == -1` for the rest of their life, so cohort analysis + * can exclude them instead of mistaking their first instrumented launch for a fresh install. + */ + suspend fun beginLaunch( + context: Context, + isFirstLaunch: Boolean, + nowMillis: Long = System.currentTimeMillis(), + ) { + val today = dayIndex(nowMillis) + var props: Map = emptyMap() + + context.dataStore.edit { prefs -> + val launch = advance( + State( + installDay = prefs[INSTALL_DAY], + launchCount = prefs[LAUNCH_COUNT] ?: 0, + activeDays = prefs[ACTIVE_DAYS] ?: 0, + lastActiveDay = prefs[LAST_ACTIVE_DAY], + ), + isFirstLaunch, + today, + ) + launch.state.installDay?.let { prefs[INSTALL_DAY] = it } + prefs[LAUNCH_COUNT] = launch.state.launchCount + prefs[ACTIVE_DAYS] = launch.state.activeDays + launch.state.lastActiveDay?.let { prefs[LAST_ACTIVE_DAY] = it } + props = launch.props + } + + eventProps = props + } + + /** Pure counter arithmetic: the new state plus the props this launch reports. */ + internal fun advance(state: State, isFirstLaunch: Boolean, today: Int): Launch { + val installDay = state.installDay ?: today.takeIf { isFirstLaunch } + val launchCount = state.launchCount + 1 + + // null on the very first instrumented launch — reported as -1 ("no previous use"), which + // keeps it out of the `dormant_days >= 1` day-boundary count. + val dormantDays = state.lastActiveDay?.let { maxOf(0, today - it) } ?: -1 + val isNewDay = state.lastActiveDay != today + val activeDays = if (isNewDay) state.activeDays + 1 else state.activeDays + + return Launch( + State(installDay, launchCount, activeDays, if (isNewDay) today else state.lastActiveDay), + mapOf( + "tenure_days" to (installDay?.let { maxOf(0, today - it) } ?: -1), + "launch_no" to launchCount, + "active_days" to activeDays, + "dormant_days" to dormantDays, + ), + ) + } + + /** Whole days since the Unix epoch, in UTC. */ + fun dayIndex(epochMillis: Long): Int = Math.floorDiv(epochMillis, DAY_MS).toInt() +} diff --git a/android/app/src/test/java/app/voltplan/cable/analytics/UsageMetricsTest.kt b/android/app/src/test/java/app/voltplan/cable/analytics/UsageMetricsTest.kt new file mode 100644 index 0000000..da86cbc --- /dev/null +++ b/android/app/src/test/java/app/voltplan/cable/analytics/UsageMetricsTest.kt @@ -0,0 +1,116 @@ +package app.voltplan.cable.analytics + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The retention counters are the only way to reconstruct D_k curves from an analytics backend that + * cannot identify a device across days, so their arithmetic is verified here rather than trusted in + * production. Mirrors `CableTests/UsageMetricsTests.swift`. + */ +class UsageMetricsTest { + + private fun props(vararg pairs: Pair) = mapOf(*pairs) + + @Test + fun firstLaunchOfANewInstallStartsTheCounters() { + val launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000) + assertEquals( + props("tenure_days" to 0, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1), + launch.props, + ) + assertEquals(UsageMetrics.State(20_000, 1, 1, 20_000), launch.state) + } + + @Test + fun furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() { + var state = UsageMetrics.State() + var props: Map = emptyMap() + repeat(3) { index -> + val launch = UsageMetrics.advance(state, isFirstLaunch = index == 0, today = 20_000) + state = launch.state + props = launch.props + } + assertEquals( + props("tenure_days" to 0, "launch_no" to 3, "active_days" to 1, "dormant_days" to 0), + props, + ) + } + + @Test + fun tenureAndActiveDaysAdvanceAcrossDays() { + var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000) + launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_001) + assertEquals( + props("tenure_days" to 1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 1), + launch.props, + ) + launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_007) + assertEquals( + props("tenure_days" to 7, "launch_no" to 3, "active_days" to 3, "dormant_days" to 6), + launch.props, + ) + } + + /** + * The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly one + * launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in the + * export measure launches instead of installs. + */ + @Test + fun exactlyOneLaunchPerDayMarksTheDayBoundary() { + var state = UsageMetrics.State() + var installMarkers = 0 + var dayMarkers = 0 + var isFirst = true + var props: Map = emptyMap() + for (day in 20_000..20_004) { + repeat(3) { + val launch = UsageMetrics.advance(state, isFirst, day) + isFirst = false + state = launch.state + props = launch.props + if (props["launch_no"] == 1) installMarkers++ + if ((props["dormant_days"] as Int) >= 1) dayMarkers++ + } + } + assertEquals(1, installMarkers) + // Day 0 is counted by the install marker, so the boundary marks days 1..4. + assertEquals(4, dayMarkers) + assertEquals(5, props["active_days"]) + assertEquals(15, props["launch_no"]) + } + + /** + * Installs that predate the counters have no install date. They must stay distinguishable from + * fresh installs forever, otherwise the update inflates the new-install cohort. + */ + @Test + fun installsPredatingTheCountersReportUnknownTenure() { + var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = false, today = 20_000) + assertEquals( + props("tenure_days" to -1, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1), + launch.props, + ) + launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_003) + assertEquals( + props("tenure_days" to -1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 3), + launch.props, + ) + } + + @Test + fun aBackwardsClockNeverProducesNegativeCounts() { + val first = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_010) + val second = UsageMetrics.advance(first.state, isFirstLaunch = false, today = 20_002) + assertEquals(0, second.props["tenure_days"]) + assertEquals(0, second.props["dormant_days"]) + } + + @Test + fun dayIndexIsAUtcDayCount() { + assertEquals(0, UsageMetrics.dayIndex(0L)) + assertEquals(0, UsageMetrics.dayIndex(86_399_000L)) + assertEquals(1, UsageMetrics.dayIndex(86_400_000L)) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 00ac540..c7a8843 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -46,6 +46,7 @@ play-review-ktx = { group = "com.google.android.play", name = "review-ktx", vers androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" } +junit = { group = "junit", name = "junit", version = "4.13.2" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" } [plugins]