Skip to content

Commit 05a587b

Browse files
authored
feat: the app has a concept of money (#46)
grep for earning|balance|payout across this source returned nothing. The app is named CashPilot and showed no money at all -- it pushed a heartbeat and never read anything back. The server now returns per-platform earnings on the heartbeat, which is the one call this client is already authenticated for, so no second credential is needed. This adds the model, keeps the last known figures in a StateFlow beside the existing lastHeartbeat state, and adds the display rules. The rules are the point, and each is a thing the server took care to express that a client can easily throw away: - null usd means NOTHING WAS EVER READ -> em-dash, never "$0.00" - a genuine 0.0 is a measurement -> "$0.00" - no total when no platform has been read -> no line at all, not "$0.00" - a platform also running on another machine cannot be attributed to this device, and the payload says so - a phone is offline often, so the last figures are KEPT rather than blanked on every blip -- but timestamped, so the UI can say they are stale instead of passing them off as current
1 parent f7332c6 commit 05a587b

4 files changed

Lines changed: 239 additions & 0 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,36 @@ data class WorkerHeartbeatResponse(
8080
val status: String = "",
8181
@SerialName("worker_id") val workerId: Long? = null,
8282
@SerialName("worker_key") val workerKey: String? = null,
83+
/**
84+
* What the platforms on this device have earned, when the server chose to
85+
* send it. Null means UNKNOWN — an older server, or one that could not
86+
* produce the figures — and must never be rendered as zero.
87+
*/
88+
val earnings: Earnings? = null,
89+
)
90+
91+
/**
92+
* Earnings for the platforms this device is running.
93+
*
94+
* Deliberately NOT "what this device earned". Providers report one balance per
95+
* account, so when the same app runs on two machines nothing can split it — see
96+
* [PlatformEarnings.sharedWithOtherWorkers]. Claiming a per-device figure would
97+
* be inventing one.
98+
*/
99+
@Serializable
100+
data class Earnings(
101+
@SerialName("window_days") val windowDays: Int = 30,
102+
val currency: String = "USD",
103+
val platforms: List<PlatformEarnings> = emptyList(),
104+
/** Sum of the platforms that HAVE a reading. Null when none do. */
105+
@SerialName("total_usd") val totalUsd: Double? = null,
106+
@SerialName("platforms_without_readings") val platformsWithoutReadings: List<String> = emptyList(),
107+
)
108+
109+
@Serializable
110+
data class PlatformEarnings(
111+
val slug: String = "",
112+
/** Null means nothing has ever been read for this platform. Not zero. */
113+
val usd: Double? = null,
114+
@SerialName("shared_with_other_workers") val sharedWithOtherWorkers: Boolean = false,
83115
)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.util.Log
1212
import androidx.core.app.NotificationCompat
1313
import com.cashpilot.android.R
1414
import com.cashpilot.android.BuildConfig
15+
import com.cashpilot.android.model.Earnings
1516
import com.cashpilot.android.model.AppContainer
1617
import com.cashpilot.android.model.Settings
1718
import com.cashpilot.android.model.SystemInfo
@@ -143,6 +144,7 @@ class HeartbeatService : Service() {
143144
SettingsStore.update(applicationContext) { updated }
144145
Log.i(TAG, "Enrolled: received and persisted this device's own fleet key")
145146
}
147+
recordEarnings(body, System.currentTimeMillis())
146148
}
147149
val runningCount = apps.count { it.running }
148150
updateNotification("$runningCount/${apps.size} apps running")
@@ -217,6 +219,30 @@ class HeartbeatService : Service() {
217219
private val _lastHeartbeatFailed = MutableStateFlow(false)
218220
val lastHeartbeatFailed: StateFlow<Boolean> = _lastHeartbeatFailed.asStateFlow()
219221

222+
/**
223+
* Earnings from the most recent heartbeat that carried them, or null.
224+
*
225+
* Null is UNKNOWN, not zero: an older server, a server that could not
226+
* produce the figures, or no heartbeat yet. The last known value is kept
227+
* when a later heartbeat omits it, because a phone is offline often and
228+
* blanking the figure on every blip would be worse than showing a stale
229+
* one -- but see [earningsAsOf], which is what lets the UI say it is stale
230+
* rather than pretending it is current.
231+
*/
232+
private val _earnings = MutableStateFlow<Earnings?>(null)
233+
val earnings: StateFlow<Earnings?> = _earnings.asStateFlow()
234+
235+
/** When [earnings] was received (0 = never). */
236+
private val _earningsAsOf = MutableStateFlow(0L)
237+
val earningsAsOf: StateFlow<Long> = _earningsAsOf.asStateFlow()
238+
239+
/**
240+
* The earnings to keep after a heartbeat: the newly received ones, or the
241+
* previous value when this response carried none. Pure, so it is
242+
* unit-tested without a service.
243+
*/
244+
fun earningsToKeep(current: Earnings?, received: Earnings?): Earnings? = received ?: current
245+
220246
/**
221247
* The per-worker key to newly persist, given the currently stored key and the
222248
* one the server returned on this heartbeat — or `null` if nothing should
@@ -232,5 +258,17 @@ class HeartbeatService : Service() {
232258
*/
233259
fun settingsAfterHeartbeat(settings: Settings, body: WorkerHeartbeatResponse): Settings? =
234260
keyToPersist(settings.workerKey, body.workerKey)?.let { newKey -> settings.copy(workerKey = newKey) }
261+
262+
/**
263+
* Record the earnings a heartbeat carried. Separate from
264+
* [settingsAfterHeartbeat] because earnings are ephemeral display state,
265+
* not settings -- persisting them to DataStore would mean writing on every
266+
* heartbeat for a value that is meaningless once stale.
267+
*/
268+
fun recordEarnings(body: WorkerHeartbeatResponse, now: Long) {
269+
val kept = earningsToKeep(_earnings.value, body.earnings)
270+
_earnings.value = kept
271+
if (body.earnings != null) _earningsAsOf.value = now
272+
}
235273
}
236274
}

app/src/main/java/com/cashpilot/android/util/FormatUtils.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,39 @@ object FormatUtils {
1818
} catch (_: Exception) {
1919
0L
2020
}
21+
22+
/**
23+
* How a platform's earnings should read, or null when there is nothing to say.
24+
*
25+
* The rules here are the whole point, and every one of them is a thing the
26+
* server took care to express and a client can easily throw away:
27+
*
28+
* - `null` usd means NOTHING HAS EVER BEEN READ for that platform. It renders
29+
* as an em-dash, never as "$0.00". A confident zero next to a service the
30+
* user is running is a lie, and it is the exact defect class the server side
31+
* of this project has spent dozens of fixes removing.
32+
* - a genuine 0.0 IS a measurement and renders as $0.00.
33+
* - a platform whose app also runs on another machine cannot be attributed to
34+
* this device, so the caller is told to say so rather than implying it.
35+
*/
36+
fun formatPlatformEarnings(usd: Double?): String = if (usd == null) "\u2014" else "$" + String.format("%.2f", usd)
37+
38+
/**
39+
* The device-level total line, or null when there is nothing honest to show.
40+
*
41+
* Null total means no platform on this device has ever been read; showing
42+
* "$0.00" there would state a measurement nobody took.
43+
*/
44+
fun formatEarningsTotal(totalUsd: Double?, windowDays: Int): String? =
45+
totalUsd?.let { "$" + String.format("%.2f", it) + " in the last " + windowDays + " days" }
46+
47+
/**
48+
* Whether the figures are stale enough that the UI must say so.
49+
*
50+
* A phone is offline often. Showing the last known figure is kinder than
51+
* blanking it on every blip — but only if it is labelled, so the user is never
52+
* told a stale number is current.
53+
*/
54+
fun earningsAreStale(asOfMillis: Long, nowMillis: Long, maxAgeMillis: Long = 60 * 60 * 1000L): Boolean =
55+
asOfMillis <= 0L || (nowMillis - asOfMillis) > maxAgeMillis
2156
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.cashpilot.android
2+
3+
import com.cashpilot.android.model.Earnings
4+
import com.cashpilot.android.model.PlatformEarnings
5+
import com.cashpilot.android.model.WorkerHeartbeatResponse
6+
import com.cashpilot.android.service.HeartbeatService
7+
import com.cashpilot.android.util.FormatUtils
8+
import kotlinx.serialization.json.Json
9+
import org.junit.jupiter.api.Assertions.assertEquals
10+
import org.junit.jupiter.api.Assertions.assertFalse
11+
import org.junit.jupiter.api.Assertions.assertNull
12+
import org.junit.jupiter.api.Assertions.assertTrue
13+
import org.junit.jupiter.api.Test
14+
15+
/**
16+
* CashPilot-android-35t (client half): the app now has a concept of money.
17+
*
18+
* The server sends per-platform earnings on the heartbeat — the one call this
19+
* client is already authenticated for. What matters here is that the client
20+
* does not throw away the care the server took:
21+
*
22+
* - `null` usd means NOTHING WAS EVER READ. It must render as an em-dash, not
23+
* as "$0.00". A confident zero beside a service the user is running is a lie.
24+
* - a genuine `0.0` IS a measurement and renders as $0.00.
25+
* - a platform also running on another machine cannot be attributed to this
26+
* device, and the payload says so.
27+
* - a phone is offline often, so the last figures are kept — but they must be
28+
* labelled stale rather than passed off as current.
29+
*/
30+
class EarningsDisplayTest {
31+
32+
private val json = Json { ignoreUnknownKeys = true }
33+
34+
// --- unknown is never zero -------------------------------------------
35+
36+
@Test
37+
fun `a platform with no reading renders as an em-dash`() {
38+
assertEquals("", FormatUtils.formatPlatformEarnings(null))
39+
}
40+
41+
@Test
42+
fun `a genuine zero renders as a real figure`() {
43+
assertEquals("$0.00", FormatUtils.formatPlatformEarnings(0.0))
44+
}
45+
46+
@Test
47+
fun `a real figure renders with two decimals`() {
48+
assertEquals("$12.50", FormatUtils.formatPlatformEarnings(12.5))
49+
}
50+
51+
@Test
52+
fun `no total is shown when nothing has been read`() {
53+
assertNull(FormatUtils.formatEarningsTotal(null, 30))
54+
}
55+
56+
@Test
57+
fun `a total names its window so it cannot be misread`() {
58+
assertEquals("$3.75 in the last 30 days", FormatUtils.formatEarningsTotal(3.75, 30))
59+
}
60+
61+
// --- staleness --------------------------------------------------------
62+
63+
@Test
64+
fun `figures never received are stale`() {
65+
assertTrue(FormatUtils.earningsAreStale(asOfMillis = 0L, nowMillis = 1_000_000L))
66+
}
67+
68+
@Test
69+
fun `fresh figures are not stale`() {
70+
val now = 10_000_000L
71+
assertFalse(FormatUtils.earningsAreStale(asOfMillis = now - 60_000L, nowMillis = now))
72+
}
73+
74+
@Test
75+
fun `figures older than the window are stale`() {
76+
val now = 10_000_000L
77+
assertTrue(FormatUtils.earningsAreStale(asOfMillis = now - (2 * 60 * 60 * 1000L), nowMillis = now))
78+
}
79+
80+
// --- keeping the last known value ------------------------------------
81+
82+
@Test
83+
fun `a response without earnings keeps the previous figures`() {
84+
val previous = Earnings(totalUsd = 5.0)
85+
assertEquals(previous, HeartbeatService.earningsToKeep(previous, null))
86+
}
87+
88+
@Test
89+
fun `a response with earnings replaces them`() {
90+
val fresh = Earnings(totalUsd = 9.0)
91+
assertEquals(fresh, HeartbeatService.earningsToKeep(Earnings(totalUsd = 5.0), fresh))
92+
}
93+
94+
@Test
95+
fun `nothing known stays nothing known`() {
96+
assertNull(HeartbeatService.earningsToKeep(null, null))
97+
}
98+
99+
// --- the wire format --------------------------------------------------
100+
101+
@Test
102+
fun `the client parses what the server actually sends`() {
103+
val body = json.decodeFromString<WorkerHeartbeatResponse>(
104+
"""
105+
{"status":"ok","worker_id":3,"earnings":{
106+
"window_days":30,"currency":"USD",
107+
"platforms":[
108+
{"slug":"grass","usd":12.5,"shared_with_other_workers":true},
109+
{"slug":"titan","usd":null,"shared_with_other_workers":false}
110+
],
111+
"total_usd":12.5,"platforms_without_readings":["titan"]}}
112+
""".trimIndent(),
113+
)
114+
val earnings = requireNotNull(body.earnings)
115+
assertEquals(30, earnings.windowDays)
116+
assertEquals(12.5, earnings.totalUsd)
117+
assertEquals(listOf("titan"), earnings.platformsWithoutReadings)
118+
assertEquals(2, earnings.platforms.size)
119+
assertNull(earnings.platforms[1].usd, "an unread platform must stay null, not become 0.0")
120+
assertTrue(earnings.platforms[0].sharedWithOtherWorkers)
121+
}
122+
123+
@Test
124+
fun `an older server that sends no earnings parses as unknown`() {
125+
val body = json.decodeFromString<WorkerHeartbeatResponse>("""{"status":"ok","worker_id":1}""")
126+
assertNull(body.earnings, "a missing key must read as unknown, never as an empty set of figures")
127+
}
128+
129+
@Test
130+
fun `a shared platform is flagged so the UI cannot imply device attribution`() {
131+
val p = PlatformEarnings(slug = "grass", usd = 10.0, sharedWithOtherWorkers = true)
132+
assertTrue(p.sharedWithOtherWorkers)
133+
}
134+
}

0 commit comments

Comments
 (0)