Skip to content

Commit d9dfa1d

Browse files
committed
Fix: Stop presenting a failed battery poll as the current reading
A throwing read re-emitted the last known readout forever, so a reader that stayed broken froze the UI on whatever it last saw — still claiming "Charging · 82%" long after the cable came out. That was tolerable while the reading was incidental; it is not now that a surface labels it "Now". The repeat is capped at two consecutive failures (~6s at the default interval), after which the flow emits BatteryReadout.UNKNOWN and every field honestly reads "Not reported" until a read succeeds. A transient blip stays invisible, a sustained failure stops being asserted, and a recovery emits fresh data rather than a stale copy. The loop moved to an internal batteryReadouts(interval, read) so the failure/recovery behaviour is JVM-testable against a scripted reader instead of a real device. CancellationException still propagates rather than being absorbed as a failed read.
1 parent b51dcf0 commit d9dfa1d

2 files changed

Lines changed: 144 additions & 20 deletions

File tree

app/src/main/java/eu/darken/amply/battery/core/BatteryReadoutSource.kt

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,33 +17,73 @@ import javax.inject.Singleton
1717
* is controlled by the collector (the dashboard's `WhileSubscribed`), which means no polling runs
1818
* while the UI is gone — there is no background work here at all.
1919
*
20-
* Failure isolation: a throwing read emits the last-known readout (or [BatteryReadout.UNKNOWN]
21-
* before the first success) instead of terminating the flow, so a single bad poll can never tear
22-
* down the combined dashboard state or strand onboarding. Reads run on [Dispatchers.IO] so the
23-
* sticky-broadcast query and property reads never touch the main thread.
20+
* Failure isolation, bounded: a throwing read repeats the last-known readout instead of terminating
21+
* the flow, so a single bad poll can never tear down the combined dashboard state or strand
22+
* onboarding. But repeating it *forever* would turn a dead reader into a frozen display that still
23+
* says "Charging · 82%" long after the cable came out — the surfaces that render this label it
24+
* "Now". So the repeat is capped at [STALE_TOLERANCE_READS] consecutive failures, after which the
25+
* flow emits [BatteryReadout.UNKNOWN] and every field honestly reads "Not reported" until a read
26+
* succeeds again.
27+
*
28+
* Reads run on [Dispatchers.IO] so the sticky-broadcast query and property reads never touch the
29+
* main thread.
2430
*/
2531
@Singleton
2632
class BatteryReadoutSource @Inject constructor(
2733
private val reader: BatteryReader,
2834
) {
29-
fun readouts(intervalMillis: Long = DEFAULT_INTERVAL_MILLIS): Flow<BatteryReadout> = flow {
30-
var last = BatteryReadout.UNKNOWN
31-
while (true) {
32-
last = try {
33-
reader.read()
34-
} catch (e: CancellationException) {
35-
throw e
36-
} catch (e: Exception) {
37-
log(TAG, Logging.Priority.WARN) { "Battery read failed, keeping last value: ${e.message}" }
38-
last
39-
}
40-
emit(last)
41-
delay(intervalMillis)
42-
}
43-
}.flowOn(Dispatchers.IO)
35+
fun readouts(intervalMillis: Long = DEFAULT_INTERVAL_MILLIS): Flow<BatteryReadout> =
36+
batteryReadouts(intervalMillis, reader::read).flowOn(Dispatchers.IO)
4437

4538
private companion object {
46-
val TAG = logTag("Battery", "ReadoutSource")
4739
const val DEFAULT_INTERVAL_MILLIS = 3_000L
4840
}
4941
}
42+
43+
/**
44+
* The polling/staleness loop, extracted from the Android-bound [BatteryReadoutSource] so the failure
45+
* behaviour above is JVM-testable against a scripted [read] rather than a real device.
46+
*/
47+
internal fun batteryReadouts(
48+
intervalMillis: Long,
49+
read: () -> BatteryReadout,
50+
): Flow<BatteryReadout> = flow {
51+
// The last *successful* read, so a recovery always emits fresh data rather than a stale copy.
52+
var lastSuccess: BatteryReadout? = null
53+
var consecutiveFailures = 0
54+
while (true) {
55+
val fresh = try {
56+
read()
57+
} catch (e: CancellationException) {
58+
throw e
59+
} catch (e: Exception) {
60+
log(TAG, Logging.Priority.WARN) { "Battery read failed (#${consecutiveFailures + 1}): ${e.message}" }
61+
null
62+
}
63+
if (fresh != null) {
64+
lastSuccess = fresh
65+
consecutiveFailures = 0
66+
} else {
67+
consecutiveFailures++
68+
}
69+
emit(
70+
when {
71+
fresh != null -> fresh
72+
// Ride out a blip on the last good reading…
73+
consecutiveFailures <= STALE_TOLERANCE_READS -> lastSuccess ?: BatteryReadout.UNKNOWN
74+
// …but stop asserting it once the reader is properly broken.
75+
else -> BatteryReadout.UNKNOWN
76+
},
77+
)
78+
delay(intervalMillis)
79+
}
80+
}
81+
82+
/**
83+
* Consecutive failed reads that may still show the previous reading. At the default interval that is
84+
* ~6s of tolerance — long enough to hide a transient failure, short enough that a genuinely stale
85+
* value is never presented as the current state for more than a glance.
86+
*/
87+
private const val STALE_TOLERANCE_READS = 2
88+
89+
private val TAG = logTag("Battery", "ReadoutSource")
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package eu.darken.amply.battery.core
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.matchers.shouldBe
5+
import kotlinx.coroutines.CancellationException
6+
import kotlinx.coroutines.flow.first
7+
import kotlinx.coroutines.flow.take
8+
import kotlinx.coroutines.flow.toList
9+
import kotlinx.coroutines.test.runTest
10+
import org.junit.jupiter.api.Test
11+
12+
class BatteryReadoutSourceTest {
13+
14+
private val charging = BatteryReadout(levelPercent = 82, plugged = 1, temperatureTenthsC = 310)
15+
private val later = BatteryReadout(levelPercent = 60, plugged = 0, temperatureTenthsC = 295)
16+
17+
/** Replays a scripted sequence of reads; `null` entries throw, standing in for a broken reader. */
18+
private fun script(vararg reads: BatteryReadout?): () -> BatteryReadout {
19+
var index = 0
20+
return {
21+
val next = reads[minOf(index, reads.lastIndex)]
22+
index++
23+
next ?: error("reader is broken")
24+
}
25+
}
26+
27+
private suspend fun emissions(read: () -> BatteryReadout, count: Int): List<BatteryReadout> =
28+
batteryReadouts(intervalMillis = 0L, read = read).take(count).toList()
29+
30+
@Test
31+
fun `successful reads pass straight through`() = runTest {
32+
emissions(script(charging, later), 2) shouldBe listOf(charging, later)
33+
}
34+
35+
@Test
36+
fun `a blip keeps the last good reading rather than flashing empty`() = runTest {
37+
val states = emissions(script(charging, null, null), 3)
38+
states[1] shouldBe charging
39+
states[2] shouldBe charging
40+
}
41+
42+
@Test
43+
fun `a sustained failure stops asserting a stale reading`() = runTest {
44+
// Tolerance is 2 consecutive failures; the third must not still claim "Charging · 82%".
45+
val states = emissions(script(charging, null, null, null, null), 5)
46+
states[0] shouldBe charging
47+
states[1] shouldBe charging
48+
states[2] shouldBe charging
49+
states[3] shouldBe BatteryReadout.UNKNOWN
50+
states[4] shouldBe BatteryReadout.UNKNOWN
51+
}
52+
53+
@Test
54+
fun `a failure before any success is unknown, never invented`() = runTest {
55+
emissions(script(null), 1) shouldBe listOf(BatteryReadout.UNKNOWN)
56+
}
57+
58+
@Test
59+
fun `recovery emits the fresh reading, not the pre-failure copy`() = runTest {
60+
val states = emissions(script(charging, null, null, null, later), 5)
61+
states[3] shouldBe BatteryReadout.UNKNOWN
62+
states[4] shouldBe later
63+
}
64+
65+
@Test
66+
fun `cancellation propagates instead of being swallowed as a failed read`() = runTest {
67+
// The catch-all that absorbs reader failures must not also absorb collector cancellation, or
68+
// the polling loop would survive its own scope being cancelled.
69+
shouldThrow<CancellationException> {
70+
batteryReadouts(intervalMillis = 0L, read = { throw CancellationException("collector gone") })
71+
.first()
72+
}
73+
}
74+
75+
@Test
76+
fun `the failure budget resets after a success`() = runTest {
77+
// fail, fail, succeed, fail, fail — the trailing pair is within budget again, so the last
78+
// good reading may still be shown rather than collapsing to unknown.
79+
val states = emissions(script(charging, null, null, later, null, null), 6)
80+
states[3] shouldBe later
81+
states[4] shouldBe later
82+
states[5] shouldBe later
83+
}
84+
}

0 commit comments

Comments
 (0)