Compare commits
1 Commits
61e0cb061f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5476209c50 |
@@ -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: ", ")
|
||||
|
||||
80
Cable/UsageMetrics.swift
Normal file
80
Cable/UsageMetrics.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
128
CableTests/UsageMetricsTests.swift
Normal file
128
CableTests/UsageMetricsTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<String, Any?> = 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)
|
||||
|
||||
@@ -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<String, Any>)
|
||||
|
||||
/** Merged into every event by [Analytics.log]. Empty until [beginLaunch] has run. */
|
||||
@Volatile
|
||||
var eventProps: Map<String, Any> = 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<String, Any> = 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()
|
||||
}
|
||||
@@ -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<String, Any>) = 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<String, Any> = 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<String, Any> = 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))
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user