Skip to content

Commit 40192d2

Browse files
authored
fix: report the app's own version in every heartbeat (#42)
* fix: report the app's own version in every heartbeat system_info carried os, arch, os_version and device_type but never a version. The server reads system_info.version to decide whether a worker is on a different release series, so every Android device read as "version unknown" and there was no way to see which phones were on an outdated build. That is how two devices sat on a pre-enrollment build for weeks: per-worker key enrollment shipped in 0.2.0, both phones were older, both were still using the shared bootstrap key, and the fleet page had no version to show. It surfaced only when the server started bounding how long an unconfirmed worker may keep using that key. Defaults to empty on purpose. An absent version must read as unknown, not as a match -- the server only calls something a mismatch when both sides are known releases. * fix: declare version last so positional destructuring is unchanged DataClassContractTest destructures SystemInfo positionally, so member order is part of this class's contract. Adding `version` before `apps` silently changed what component5() means and broke compilation -- caught by CI, which is exactly what that test is for. version now sits after apps, and the destructuring test covers it as component6 so the ordering is a deliberate contract rather than an accident.
1 parent 5d4abb3 commit 40192d2

5 files changed

Lines changed: 63 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- **The app now reports its own version to the server.** Every heartbeat's `system_info` carried `os`, `arch`, `os_version` and `device_type`, but never a `version`. The CashPilot server reads exactly that key to tell whether a worker is on a different release series from the UI, so every Android device showed as "version unknown" on the fleet page and there was no way to see which phones were running an outdated build.
13+
14+
That is not cosmetic. Per-worker key enrollment shipped in 0.2.0, but two devices stayed on an older build for weeks, still authenticating with the shared bootstrap key, and nothing surfaced it — the fleet page had no version to show. It only came to light when the server began bounding how long an unconfirmed worker may keep using the shared key.
15+
16+
An absent version still reads as *unknown*, never as a match: the server only reports a mismatch when both sides are known releases.
17+
818
## [0.2.0] - 2026-07-11
919

1020
### Added

app/src/main/java/com/cashpilot/android/model/Heartbeat.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ data class SystemInfo(
3636
@SerialName("os_version") val osVersion: String = "",
3737
@SerialName("device_type") val deviceType: String = "android",
3838
val apps: List<AppStatus> = emptyList(),
39+
/**
40+
* This app's own version, e.g. "0.2.1".
41+
*
42+
* The server reads `system_info.version` to decide whether a worker is on a
43+
* different release series from the UI. Android never sent it, so every
44+
* device read as "version unknown" and there was no way to see which phones
45+
* were on an outdated build -- which is how two devices sat on a
46+
* pre-enrollment release for weeks without anyone noticing.
47+
*
48+
* Defaults to empty on purpose: an ABSENT version must read as unknown, not
49+
* as a match. The server's own rule is that both sides must be known
50+
* releases before it will call anything a mismatch.
51+
*
52+
* Declared LAST on purpose: DataClassContractTest destructures SystemInfo
53+
* positionally, so member order is part of this class's contract and
54+
* inserting anywhere else silently changes what component5() means.
55+
*/
56+
val version: String = "",
3957
)
4058

4159
@Serializable

app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.provider.Settings.Secure
1111
import android.util.Log
1212
import androidx.core.app.NotificationCompat
1313
import com.cashpilot.android.R
14+
import com.cashpilot.android.BuildConfig
1415
import com.cashpilot.android.model.AppContainer
1516
import com.cashpilot.android.model.Settings
1617
import com.cashpilot.android.model.SystemInfo
@@ -118,6 +119,7 @@ class HeartbeatService : Service() {
118119
arch = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
119120
osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
120121
deviceType = "android",
122+
version = BuildConfig.VERSION_NAME,
121123
),
122124
)
123125

app/src/test/java/com/cashpilot/android/DataClassContractTest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,18 @@ class DataClassContractTest {
9191
osVersion = "Android 15",
9292
deviceType = "android",
9393
apps = listOf(AppStatus(slug = "test", running = true)),
94+
version = "9.9.9",
9495
)
95-
val (os, arch, osVersion, deviceType, apps) = info
96+
// version is component6, declared after apps so that adding it did not
97+
// shift what component5 means. This test is what makes that ordering a
98+
// deliberate contract rather than an accident.
99+
val (os, arch, osVersion, deviceType, apps, version) = info
96100
assertEquals("Android", os)
97101
assertEquals("arm64-v8a", arch)
98102
assertEquals("Android 15", osVersion)
99103
assertEquals("android", deviceType)
100104
assertEquals(1, apps.size)
105+
assertEquals("9.9.9", version)
101106
}
102107

103108
@Test

app/src/test/java/com/cashpilot/android/SerializationTest.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ class SerializationTest {
131131
assertFalse(encoded.contains("\"deviceType\""))
132132
}
133133

134+
// --- version reporting (the app was invisible to the server's skew check) ---
135+
136+
@Test
137+
fun `SystemInfo carries a version field the server can read`() {
138+
// The server reads system_info.version to decide whether a worker is on
139+
// a different release series. Android never sent it, so every device
140+
// read as "version unknown" and two phones sat on a pre-enrollment
141+
// build for weeks unnoticed.
142+
val encoded = json.encodeToString(SystemInfo(version = "0.2.1"))
143+
assertTrue(encoded.contains("\"version\":\"0.2.1\""), encoded)
144+
}
145+
146+
@Test
147+
fun `SystemInfo version round-trips`() {
148+
val original = SystemInfo(os = "Android", version = "1.2.3")
149+
assertEquals(original, json.decodeFromString<SystemInfo>(json.encodeToString(original)))
150+
}
151+
152+
@Test
153+
fun `an absent version decodes as empty, never as a match`() {
154+
// Absent must read as UNKNOWN. The server only calls something a
155+
// mismatch when BOTH sides are known releases, so an empty string here
156+
// is the safe value -- inventing one would fabricate agreement.
157+
val decoded = json.decodeFromString<SystemInfo>("""{"os":"Android"}""")
158+
assertEquals("", decoded.version)
159+
}
160+
134161
@Test
135162
fun `SystemInfo defaults round-trip`() {
136163
val original = SystemInfo()

0 commit comments

Comments
 (0)