Skip to content

Commit 938612b

Browse files
committed
Add build flavor split to device stats
1 parent 63b2bf5 commit 938612b

3 files changed

Lines changed: 91 additions & 15 deletions

File tree

.claude/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
scheduled_tasks.lock

src/main/kotlin/eu/darken/octi/server/device/DeviceActivityReporter.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class DeviceActivityReporter @Inject constructor(
2828
val label: String,
2929
val total: Int,
3030
val versions: List<VersionStats>,
31+
val buildFlavors: List<BuildFlavorStats>,
3132
)
3233

3334
data class VersionStats(
@@ -36,6 +37,12 @@ class DeviceActivityReporter @Inject constructor(
3637
val percent: Double,
3738
)
3839

40+
data class BuildFlavorStats(
41+
val flavor: String,
42+
val count: Int,
43+
val percent: Double,
44+
)
45+
3946
init {
4047
appScope.launchPeriodicJob(
4148
tag = TAG,
@@ -61,8 +68,11 @@ class DeviceActivityReporter @Inject constructor(
6168
private val ONE_HOUR: Duration = Duration.ofHours(1)
6269
private val TWENTY_FOUR_HOURS: Duration = Duration.ofHours(24)
6370
private const val UNKNOWN_VERSION = "<unknown>"
71+
private const val UNKNOWN_BUILD_FLAVOR = "<unknown>"
6472
private const val MAX_VERSION_DISPLAY_LENGTH = 80
6573
private val CONTROL_CHARS = Regex("[\\p{Cntrl}]+")
74+
private val BUILD_FLAVOR_TOKEN_SEPARATOR = Regex("[^A-Za-z0-9]+")
75+
private val BUILD_FLAVOR_ORDER = listOf("FOSS", "GPLAY", UNKNOWN_BUILD_FLAVOR)
6676
private val TAG = logTag("Device", "Activity")
6777

6878
internal fun buildReport(
@@ -92,6 +102,22 @@ class DeviceActivityReporter @Inject constructor(
92102
}
93103
}
94104

105+
internal fun buildFlavorForLog(raw: String?): String {
106+
val sanitized = raw
107+
?.replace(CONTROL_CHARS, " ")
108+
?.trim()
109+
?.ifBlank { null }
110+
?: return UNKNOWN_BUILD_FLAVOR
111+
112+
val tokens = sanitized.split(BUILD_FLAVOR_TOKEN_SEPARATOR)
113+
114+
return when {
115+
tokens.any { it.equals("FOSS", ignoreCase = true) } -> "FOSS"
116+
tokens.any { it.equals("GPLAY", ignoreCase = true) || it.equals("GPLATE", ignoreCase = true) } -> "GPLAY"
117+
else -> UNKNOWN_BUILD_FLAVOR
118+
}
119+
}
120+
95121
private fun buildWindowStats(
96122
label: String,
97123
window: Duration,
@@ -108,6 +134,10 @@ class DeviceActivityReporter @Inject constructor(
108134
.groupingBy { sanitizeVersionForLog(it.version) }
109135
.eachCount()
110136

137+
val buildFlavorCounts = activeDevices
138+
.groupingBy { buildFlavorForLog(it.version) }
139+
.eachCount()
140+
111141
val total = activeDevices.size
112142
val versions = versionCounts.entries
113143
.map { (version, count) ->
@@ -119,18 +149,32 @@ class DeviceActivityReporter @Inject constructor(
119149
}
120150
.sortedWith(compareByDescending<VersionStats> { it.count }.thenBy { it.version })
121151

152+
val buildFlavors = buildFlavorCounts.entries
153+
.map { (flavor, count) ->
154+
BuildFlavorStats(
155+
flavor = flavor,
156+
count = count,
157+
percent = if (total == 0) 0.0 else count * 100.0 / total,
158+
)
159+
}
160+
.sortedBy { BUILD_FLAVOR_ORDER.indexOf(it.flavor).takeIf { index -> index >= 0 } ?: Int.MAX_VALUE }
161+
122162
return WindowStats(
123163
label = label,
124164
total = total,
125165
versions = versions,
166+
buildFlavors = buildFlavors,
126167
)
127168
}
128169

129170
private fun formatWindow(stats: WindowStats): String {
130171
val versions = stats.versions.joinToString(prefix = "[", postfix = "]") {
131172
"${it.version}=${it.count} (${formatPercent(it.percent)}%)"
132173
}
133-
return "${stats.label} total=${stats.total} versions=$versions"
174+
val buildFlavors = stats.buildFlavors.joinToString(prefix = "[", postfix = "]") {
175+
"${it.flavor}=${it.count} (${formatPercent(it.percent)}%)"
176+
}
177+
return "${stats.label} total=${stats.total} versions=$versions flavors=$buildFlavors"
134178
}
135179

136180
private fun formatPercent(percent: Double): String = String.format(Locale.US, "%.1f", percent)

src/test/kotlin/eu/darken/octi/server/device/DeviceActivityReporterTest.kt

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ class DeviceActivityReporterTest {
1313

1414
@Test
1515
fun `report counts lastSeen windows and currently connected devices`() {
16-
val oneHour = device(version = "octi/1.0.0", lastSeen = now.minus(Duration.ofMinutes(10)))
17-
val twentyFourHours = device(version = "octi/2.0.0", lastSeen = now.minus(Duration.ofHours(2)))
18-
val connected = device(version = "octi/3.0.0", lastSeen = now.minus(Duration.ofHours(30)))
19-
val inactive = device(version = "octi/4.0.0", lastSeen = now.minus(Duration.ofHours(30)))
16+
val oneHour = device(version = "octi/1.0.0/FOSS", lastSeen = now.minus(Duration.ofMinutes(10)))
17+
val twentyFourHours = device(version = "octi/2.0.0/GPLAY", lastSeen = now.minus(Duration.ofHours(2)))
18+
val connected = device(version = "octi/3.0.0/GPLAY", lastSeen = now.minus(Duration.ofHours(30)))
19+
val inactive = device(version = "octi/4.0.0/FOSS", lastSeen = now.minus(Duration.ofHours(30)))
2020

2121
val report = DeviceActivityReporter.buildReport(
2222
devices = listOf(oneHour, twentyFourHours, connected, inactive),
@@ -26,33 +26,43 @@ class DeviceActivityReporterTest {
2626

2727
report.oneHour.total shouldBe 2
2828
report.oneHour.versionCounts() shouldBe mapOf(
29-
"octi/1.0.0" to 1,
30-
"octi/3.0.0" to 1,
29+
"octi/1.0.0/FOSS" to 1,
30+
"octi/3.0.0/GPLAY" to 1,
31+
)
32+
report.oneHour.buildFlavorCounts() shouldBe mapOf(
33+
"FOSS" to 1,
34+
"GPLAY" to 1,
3135
)
3236

3337
report.twentyFourHours.total shouldBe 3
3438
report.twentyFourHours.versionCounts() shouldBe mapOf(
35-
"octi/1.0.0" to 1,
36-
"octi/2.0.0" to 1,
37-
"octi/3.0.0" to 1,
39+
"octi/1.0.0/FOSS" to 1,
40+
"octi/2.0.0/GPLAY" to 1,
41+
"octi/3.0.0/GPLAY" to 1,
42+
)
43+
report.twentyFourHours.buildFlavorCounts() shouldBe mapOf(
44+
"FOSS" to 1,
45+
"GPLAY" to 2,
3846
)
3947
}
4048

4149
@Test
4250
fun `report formats counts and percentages sorted by count`() {
4351
val report = DeviceActivityReporter.buildReport(
4452
devices = listOf(
45-
device(version = "octi/1.0.0", lastSeen = now),
46-
device(version = "octi/1.0.0", lastSeen = now),
47-
device(version = "octi/2.0.0", lastSeen = now),
53+
device(version = "octi/1.0.0/FOSS", lastSeen = now),
54+
device(version = "octi/1.0.0/FOSS", lastSeen = now),
55+
device(version = "octi/2.0.0/GPLAY", lastSeen = now),
4856
),
4957
activeDeviceKeys = emptySet(),
5058
now = now,
5159
)
5260

5361
DeviceActivityReporter.formatReport(report) shouldBe
54-
"device-stats: 1h total=3 versions=[octi/1.0.0=2 (66.7%), octi/2.0.0=1 (33.3%)]; " +
55-
"24h total=3 versions=[octi/1.0.0=2 (66.7%), octi/2.0.0=1 (33.3%)]"
62+
"device-stats: 1h total=3 versions=[octi/1.0.0/FOSS=2 (66.7%), octi/2.0.0/GPLAY=1 (33.3%)] " +
63+
"flavors=[FOSS=2 (66.7%), GPLAY=1 (33.3%)]; " +
64+
"24h total=3 versions=[octi/1.0.0/FOSS=2 (66.7%), octi/2.0.0/GPLAY=1 (33.3%)] " +
65+
"flavors=[FOSS=2 (66.7%), GPLAY=1 (33.3%)]"
5666
}
5767

5868
@Test
@@ -64,6 +74,7 @@ class DeviceActivityReporterTest {
6474
)
6575
empty.oneHour.total shouldBe 0
6676
empty.oneHour.versions shouldBe emptyList()
77+
empty.oneHour.buildFlavors shouldBe emptyList()
6778

6879
val report = DeviceActivityReporter.buildReport(
6980
devices = listOf(
@@ -81,6 +92,13 @@ class DeviceActivityReporterTest {
8192
percent = 100.0,
8293
)
8394
)
95+
report.oneHour.buildFlavors shouldBe listOf(
96+
DeviceActivityReporter.BuildFlavorStats(
97+
flavor = "<unknown>",
98+
count = 2,
99+
percent = 100.0,
100+
)
101+
)
84102
}
85103

86104
@Test
@@ -121,10 +139,23 @@ class DeviceActivityReporterTest {
121139
sanitized.endsWith("...") shouldBe true
122140
}
123141

142+
@Test
143+
fun `build flavor is parsed from user-agent style versions`() {
144+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/FOSS") shouldBe "FOSS"
145+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/gplay/dev-a1b2c3d") shouldBe "GPLAY"
146+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/GPLATE") shouldBe "GPLAY"
147+
DeviceActivityReporter.buildFlavorForLog("1.2.3") shouldBe "<unknown>"
148+
DeviceActivityReporter.buildFlavorForLog(null) shouldBe "<unknown>"
149+
}
150+
124151
private fun DeviceActivityReporter.WindowStats.versionCounts(): Map<String, Int> {
125152
return versions.associate { it.version to it.count }
126153
}
127154

155+
private fun DeviceActivityReporter.WindowStats.buildFlavorCounts(): Map<String, Int> {
156+
return buildFlavors.associate { it.flavor to it.count }
157+
}
158+
128159
private fun device(
129160
version: String?,
130161
lastSeen: Instant,

0 commit comments

Comments
 (0)