Skip to content

Commit c33775b

Browse files
committed
Battery: Correct milli-scaled telemetry on MagicOS
HONOR MagicOS 10 reports BATTERY_PROPERTY_CURRENT_NOW and CHARGE_COUNTER in milli-units where the API documents micro-units, so every reading is 1000x too small. On a Magic8 Pro (issue #66) the charge counter read 6978 for a 7100 mAh cell, which the battery screen rendered as "7 mAh", and current showed 0 mA while the phone was discharging with the screen on, because a real ~300 mA arrives as 300 and rounds away. While charging it showed "4 mA" and "0.0 W" for a ~4 A charge. Correction requires two independent conditions: the ROM is recognised by system feature, and the reading itself shows an implied full-charge capacity below 100 mAh (counter * 100 / percent, which normalizes out the charge level and separates a correct phone from a milli-reporting one by an order of magnitude either side). A correctly reporting MagicOS build is therefore untouched, and no other ROM can be affected at all. Two purely data-driven designs were tried first and both were unsound; the reasoning is recorded in the qualification ledger so it is not repeated. The second is the instructive one: corroborating the counter anomaly against an implausibly small charging current fails precisely because a device at a charge-limit hold reports exactly that, and this app creates holds deliberately (StatsLimitHitDetector uses that same signature to recognise one). A healthy phone holding at 80% with a broken charge counter would have had its real readings multiplied by a thousand, and at ~50 mA that computes to ~200 W, under the plausibility ceiling and so recorded as a credible lie rather than a visible error. Generalising from one device is deliberate here. Being wrong about a ROM's units shows wrong numbers; being wrong about a healthy device corrupts good ones, and the gate can only ever do the former. Other affected ROMs stay uncorrected until one is confirmed. The charger-advertised max_charging_* extras are not rescaled: they are separate intent extras and were never observed populated on the device.
1 parent 8b2f429 commit c33775b

10 files changed

Lines changed: 267 additions & 24 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import javax.inject.Inject
1919
*/
2020
class BatteryReader @Inject constructor(
2121
@ApplicationContext private val context: Context,
22+
private val unitCalibration: BatteryUnitCalibration,
2223
) {
2324
/** Reads its own sticky [Intent.ACTION_BATTERY_CHANGED] broadcast (for callers without one). */
2425
fun read(): BatteryReadout {
@@ -53,6 +54,7 @@ class BatteryReader @Inject constructor(
5354
cycleCount = cycleCount(battery),
5455
maxChargingCurrentMicroamps = battery.getIntExtra(EXTRA_MAX_CHARGING_CURRENT, ABSENT),
5556
maxChargingVoltageMicrovolts = battery.getIntExtra(EXTRA_MAX_CHARGING_VOLTAGE, ABSENT),
57+
romMisreportsUnits = unitCalibration.romMisreportsUnits,
5658
)
5759
}
5860

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

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ object BatteryReadoutFactory {
1515
/** Sentinel a caller passes when a sticky-intent extra is entirely absent. */
1616
const val ABSENT = Int.MIN_VALUE
1717

18+
/**
19+
* Floor for a device's implied full-charge capacity, below which its charge counter is taken to be
20+
* milli-scaled. 100 mAh sits an order of magnitude below the smallest plausible phone or tablet cell
21+
* (1000 mAh ⇒ 1_000_000 µAh) and an order of magnitude above what a milli-reporting device implies
22+
* (HONOR's 7100 mAh cell ⇒ ~7_100 µAh), so the two populations cannot overlap.
23+
* See [chargeCounterLooksMilliScaled].
24+
*/
25+
private const val MIN_PLAUSIBLE_FULL_CAPACITY_MICROAMP_HOURS = 100_000L
26+
27+
1828
@Suppress("LongParameterList")
1929
fun build(
2030
level: Int = ABSENT,
@@ -31,27 +41,68 @@ object BatteryReadoutFactory {
3141
cycleCount: Int = ABSENT,
3242
maxChargingCurrentMicroamps: Int = ABSENT,
3343
maxChargingVoltageMicrovolts: Int = ABSENT,
34-
): BatteryReadout = BatteryReadout(
35-
levelPercent = percentOf(level, scale),
36-
status = status.orNull(),
37-
chargingStatus = chargingStatus.orNull(),
38-
plugged = plugged.orNull(),
39-
health = health.orNull(),
40-
technology = technology?.trim()?.ifEmpty { null },
41-
temperatureTenthsC = temperatureTenths.orNull(),
42-
voltageMillivolts = voltageMillivolts.orNull(),
43-
// Current is signed; only the MIN_VALUE/absent sentinel is dropped, negatives are kept.
44-
currentNowMicroamps = currentNowMicroamps.orNull(),
45-
chargeCounterMicroampHours = chargeCounterMicroampHours.orNull(),
46-
cycleCount = cycleCount.orNull(),
47-
// The charger extras are advertised capabilities: a device that reports them while nothing is
48-
// connected reports 0, which is "no charger" rather than "a 0 W charger".
49-
maxChargingCurrentMicroamps = maxChargingCurrentMicroamps.positiveOrNull(),
50-
maxChargingVoltageMicrovolts = maxChargingVoltageMicrovolts.positiveOrNull(),
51-
)
44+
romMisreportsUnits: Boolean = false,
45+
): BatteryReadout {
46+
val percent = percentOrNull(level, scale)
47+
val chargeCounter = chargeCounterMicroampHours.orNull()
48+
// Both conditions, never one: the ROM must be a known misreporter (see BatteryUnitCalibration)
49+
// AND the anomaly must be visible in this reading, so a correctly-reporting build is left alone.
50+
val milliScaled = romMisreportsUnits && chargeCounterLooksMilliScaled(chargeCounter, percent)
51+
return BatteryReadout(
52+
levelPercent = percent,
53+
status = status.orNull(),
54+
chargingStatus = chargingStatus.orNull(),
55+
plugged = plugged.orNull(),
56+
health = health.orNull(),
57+
technology = technology?.trim()?.ifEmpty { null },
58+
temperatureTenthsC = temperatureTenths.orNull(),
59+
voltageMillivolts = voltageMillivolts.orNull(),
60+
// Current is signed; only the MIN_VALUE/absent sentinel is dropped, negatives are kept.
61+
currentNowMicroamps = currentNowMicroamps.orNull()?.toMicroUnits(milliScaled),
62+
chargeCounterMicroampHours = chargeCounter?.toMicroUnits(milliScaled),
63+
cycleCount = cycleCount.orNull(),
64+
// The charger extras are advertised capabilities: a device that reports them while nothing is
65+
// connected reports 0, which is "no charger" rather than "a 0 W charger". Deliberately NOT
66+
// rescaled: they come from different extras than the two properties the inference is drawn
67+
// from, and no misreporting device has been observed populating them at all.
68+
maxChargingCurrentMicroamps = maxChargingCurrentMicroamps.positiveOrNull(),
69+
maxChargingVoltageMicrovolts = maxChargingVoltageMicrovolts.positiveOrNull(),
70+
)
71+
}
72+
73+
/**
74+
* Whether the **charge counter** is reported in milli-units where [BatteryManager] documents
75+
* micro-units. Confirmed on HONOR MagicOS 10 (issue #66): `6978` at 100% on a 7100 mAh cell, i.e. the
76+
* UI rendered "7 mAh".
77+
*
78+
* Inferred from the data rather than a device allowlist, because branding is not the cause and the
79+
* affected population is unknown. The discriminator is the **implied full-charge capacity**,
80+
* `counter × 100 / percent`, which normalizes out the charge level: a correctly reporting phone implies
81+
* at least ~1_000_000 µAh (a 1000 mAh cell), a milli-reporting one single-digit thousands, and the floor
82+
* sits an order of magnitude from both.
83+
*
84+
* **Never sufficient on its own.** `CHARGE_COUNTER` and `CURRENT_NOW` are independent HAL fields, so an
85+
* impossible counter says nothing about current, and a broken or freshly-reset counter on an otherwise
86+
* healthy device must not license multiplying its real current by a thousand. The caller therefore also
87+
* requires a known-misreporting ROM — see [BatteryUnitCalibration] for why corroborating from the
88+
* current reading instead turned out to be unsound.
89+
*/
90+
internal fun chargeCounterLooksMilliScaled(chargeCounterMicroampHours: Int?, levelPercent: Int?): Boolean {
91+
if (chargeCounterMicroampHours == null || chargeCounterMicroampHours <= 0) return false
92+
if (levelPercent == null || levelPercent <= 0) return false
93+
val impliedFullCapacity = chargeCounterMicroampHours.toLong() * 100L / levelPercent.toLong()
94+
return impliedFullCapacity < MIN_PLAUSIBLE_FULL_CAPACITY_MICROAMP_HOURS
95+
}
96+
97+
98+
/** Milli → micro, in [Long] so a large correct-but-misdetected value could not wrap into nonsense. */
99+
private fun Int.toMicroUnits(milliScaled: Boolean): Int {
100+
if (!milliScaled) return this
101+
return (toLong() * 1000L).coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
102+
}
52103

53104
/** Percent only when the level/scale pair is internally consistent; otherwise `null`. */
54-
private fun percentOf(level: Int, scale: Int): Int? {
105+
private fun percentOrNull(level: Int, scale: Int): Int? {
55106
if (scale <= 0 || level < 0 || level > scale) return null
56107
return level * 100 / scale
57108
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package eu.darken.amply.battery.core
2+
3+
import android.content.Context
4+
import dagger.hilt.android.qualifiers.ApplicationContext
5+
import eu.darken.amply.common.debug.logging.Logging
6+
import eu.darken.amply.common.debug.logging.log
7+
import eu.darken.amply.common.debug.logging.logTag
8+
import javax.inject.Inject
9+
import javax.inject.Singleton
10+
11+
/**
12+
* Whether this device's ROM is known to report battery telemetry in **milli**-units where
13+
* [android.os.BatteryManager] documents micro-units, making `CURRENT_NOW` and `CHARGE_COUNTER` 1000× too
14+
* small. Confirmed on HONOR MagicOS 10 (issue #66): a 7100 mAh cell reported a charge counter of `6978`
15+
* (rendered "7 mAh") and `Current now` of 0 mA while visibly discharging.
16+
*
17+
* **Why a ROM gate rather than pure inference.** Detecting this from the numbers alone was tried twice and
18+
* abandoned both times, because the states that look like the defect are states Amply itself creates. An
19+
* impossibly small charge counter proves nothing about current — they are independent HAL fields — so
20+
* corroboration has to come from current, and "charging while drawing almost nothing" is exactly what a
21+
* device does *at a charge-limit hold* (see `StatsLimitHitDetector`, which uses that as its hold signal).
22+
* A healthy phone holding at 80% with a broken counter would have satisfied any such rule and had its real
23+
* readings multiplied by a thousand, turning ~50 mA into ~200 W: under the plausibility ceiling, and so
24+
* recorded as a believable lie. Being wrong about a ROM's units shows wrong numbers; being wrong about a
25+
* healthy device corrupts good ones. This gate can only ever affect the former.
26+
*
27+
* The ROM check is **necessary but not sufficient**: [BatteryReadoutFactory] additionally requires the
28+
* anomaly to be visible in the reading, so a MagicOS build that reports correctly is left alone.
29+
*
30+
* Generalizing "all MagicOS" from one device is a deliberate, bounded bet, and a much cheaper one than the
31+
* equivalent for charge control: the failure mode is a wrong battery figure, not a false claim that a
32+
* battery is protected. Other affected ROMs stay uncorrected until one is confirmed and added here.
33+
*/
34+
@Singleton
35+
class BatteryUnitCalibration @Inject constructor(
36+
@ApplicationContext private val context: Context,
37+
) {
38+
39+
/**
40+
* Resolved once: ROM identity cannot change while the process lives. System features need no
41+
* `<queries>` entry and no permission, and are not subject to package-visibility filtering.
42+
* Fails closed, so an unreadable package manager means "correct the nothing".
43+
*/
44+
val romMisreportsUnits: Boolean by lazy {
45+
val detected = MAGICOS_FEATURES.any { feature ->
46+
runCatching { context.packageManager.hasSystemFeature(feature) }.getOrDefault(false)
47+
}
48+
if (detected) {
49+
log(TAG, Logging.Priority.INFO) { "MagicOS detected; battery telemetry units will be corrected" }
50+
}
51+
detected
52+
}
53+
54+
companion object {
55+
private val TAG = logTag("Battery", "UnitCalibration")
56+
57+
/**
58+
* Any one match is enough, so a slimmed or renamed component cannot break detection. Reported from
59+
* a Magic8 Pro `HNBKQ` on MagicOS 10.0.0.193 via `pm list features`.
60+
*/
61+
private val MAGICOS_FEATURES = listOf(
62+
"com.hihonor.software.features.honor",
63+
"com.hihonor.system.feature",
64+
)
65+
}
66+
}

app/src/test/java/eu/darken/amply/battery/core/BatteryReadoutFactoryTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package eu.darken.amply.battery.core
22

3+
import android.os.BatteryManager
34
import io.kotest.matchers.shouldBe
45
import org.junit.jupiter.api.Test
56

@@ -53,4 +54,80 @@ class BatteryReadoutFactoryTest {
5354
BatteryReadoutFactory.build(status = 99).status shouldBe 99
5455
BatteryReadoutFactory.build(health = 42).health shouldBe 42
5556
}
57+
58+
@Test
59+
fun `a milli-reporting ROM has its charge and current corrected`() {
60+
// HONOR MagicOS 10 shape (issue #66): a 7100 mAh cell at 100% reported 6978 where the API
61+
// documents microamp-hours, so the UI rendered "7 mAh", and a real ~300 mA arrived as 300.
62+
val readout = BatteryReadoutFactory.build(
63+
level = 100,
64+
scale = 100,
65+
chargeCounterMicroampHours = 6_978,
66+
currentNowMicroamps = -300,
67+
romMisreportsUnits = true,
68+
)
69+
readout.chargeCounterMicroampHours shouldBe 6_978_000
70+
readout.currentNowMicroamps shouldBe -300_000
71+
}
72+
73+
@Test
74+
fun `an anomalous reading on an unlisted ROM is never corrected`() {
75+
// The safety property: only a ROM known to misreport may be rescaled. Otherwise a device with a
76+
// broken or freshly-reset counter would have its healthy current multiplied by a thousand, which
77+
// at ~50 mA computes to ~200 W, under the plausibility ceiling and so recorded as a credible lie.
78+
val readout = BatteryReadoutFactory.build(
79+
level = 100,
80+
scale = 100,
81+
chargeCounterMicroampHours = 1,
82+
currentNowMicroamps = 50_000,
83+
)
84+
readout.chargeCounterMicroampHours shouldBe 1
85+
readout.currentNowMicroamps shouldBe 50_000
86+
}
87+
88+
@Test
89+
fun `a correctly reporting build of a listed ROM is left alone`() {
90+
// The ROM gate is necessary, not sufficient: without the anomaly there is nothing to correct.
91+
val readout = BatteryReadoutFactory.build(
92+
level = 50,
93+
scale = 100,
94+
chargeCounterMicroampHours = 3_500_000,
95+
currentNowMicroamps = 1_500_000,
96+
romMisreportsUnits = true,
97+
)
98+
readout.chargeCounterMicroampHours shouldBe 3_500_000
99+
readout.currentNowMicroamps shouldBe 1_500_000
100+
}
101+
102+
@Test
103+
fun `a charge-limit hold on a listed ROM is not mistaken for the defect`() {
104+
// Amply deliberately creates holds: CHARGING with current near zero. The counter is what decides,
105+
// and a healthy counter means no correction even though the current looks tiny.
106+
val readout = BatteryReadoutFactory.build(
107+
level = 80,
108+
scale = 100,
109+
status = BatteryManager.BATTERY_STATUS_CHARGING,
110+
chargeCounterMicroampHours = 5_600_000,
111+
currentNowMicroamps = 3_000,
112+
romMisreportsUnits = true,
113+
)
114+
readout.currentNowMicroamps shouldBe 3_000
115+
}
116+
117+
@Test
118+
fun `a nearly empty healthy battery is not mistaken for a milli-reporting one`() {
119+
// The closest the two populations come: 1% of a 7100 mAh cell is 71_000 uAh, a small absolute
120+
// number. Normalizing by level is what keeps it separable — implied capacity is still ~7.1 Ah.
121+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(71_000, levelPercent = 1) shouldBe false
122+
// The same device milli-scaled reports 71, implying ~7.1 mAh.
123+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(71, levelPercent = 1) shouldBe true
124+
}
125+
126+
@Test
127+
fun `counter detection needs a positive counter and a usable level`() {
128+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(null, levelPercent = 100) shouldBe false
129+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(0, levelPercent = 100) shouldBe false
130+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(6_978, levelPercent = null) shouldBe false
131+
BatteryReadoutFactory.chargeCounterLooksMilliScaled(6_978, levelPercent = 0) shouldBe false
132+
}
56133
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package eu.darken.amply.battery.core
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import io.kotest.matchers.shouldBe
6+
import org.junit.Test
7+
import org.junit.runner.RunWith
8+
import org.robolectric.RobolectricTestRunner
9+
import org.robolectric.Shadows.shadowOf
10+
11+
@RunWith(RobolectricTestRunner::class)
12+
class BatteryUnitCalibrationTest {
13+
14+
private val context: Context = ApplicationProvider.getApplicationContext()
15+
16+
@Test
17+
fun `an ordinary device is never flagged, so its telemetry is never touched`() {
18+
BatteryUnitCalibration(context).romMisreportsUnits shouldBe false
19+
}
20+
21+
@Test
22+
fun `MagicOS is recognised by its honor system feature`() {
23+
shadowOf(context.packageManager).setSystemFeature("com.hihonor.software.features.honor", true)
24+
25+
BatteryUnitCalibration(context).romMisreportsUnits shouldBe true
26+
}
27+
28+
@Test
29+
fun `any one of the known features is enough`() {
30+
// A slimmed or renamed component must not break detection, so the list is an OR.
31+
shadowOf(context.packageManager).setSystemFeature("com.hihonor.system.feature", true)
32+
33+
BatteryUnitCalibration(context).romMisreportsUnits shouldBe true
34+
}
35+
36+
@Test
37+
fun `a lookalike feature name does not match`() {
38+
shadowOf(context.packageManager).setSystemFeature("com.hihonor.software.features.oversea", true)
39+
40+
BatteryUnitCalibration(context).romMisreportsUnits shouldBe false
41+
}
42+
}

app/src/test/java/eu/darken/amply/charging/core/ChargingRepositoryPersistenceTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.os.Build
1111
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
1212
import androidx.test.core.app.ApplicationProvider
1313
import eu.darken.amply.battery.core.BatteryReader
14+
import eu.darken.amply.battery.core.BatteryUnitCalibration
1415
import eu.darken.amply.charging.core.access.AccessResolver
1516
import eu.darken.amply.charging.core.access.DirectSettingsBackend
1617
import eu.darken.amply.charging.core.access.LineageSettingsClient
@@ -126,7 +127,7 @@ class ChargingRepositoryPersistenceTest {
126127
settleScheduler = object : SettleScheduler {
127128
override fun schedule(requestedAtMillis: Long) = Unit
128129
},
129-
batteryReader = BatteryReader(context),
130+
batteryReader = BatteryReader(context, BatteryUnitCalibration(context)),
130131
)
131132
}
132133

app/src/test/java/eu/darken/amply/charging/core/UnmappedDeviceSettingsIntentTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.provider.Settings
99
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
1010
import androidx.test.core.app.ApplicationProvider
1111
import eu.darken.amply.battery.core.BatteryReader
12+
import eu.darken.amply.battery.core.BatteryUnitCalibration
1213
import eu.darken.amply.charging.core.access.AccessResolver
1314
import eu.darken.amply.charging.core.access.DirectSettingsBackend
1415
import eu.darken.amply.charging.core.access.LineageSettingsClient
@@ -101,7 +102,7 @@ class UnmappedDeviceSettingsIntentTest {
101102
settleScheduler = object : SettleScheduler {
102103
override fun schedule(requestedAtMillis: Long) = Unit
103104
},
104-
batteryReader = BatteryReader(context),
105+
batteryReader = BatteryReader(context, BatteryUnitCalibration(context)),
105106
)
106107
}
107108

app/src/test/java/eu/darken/amply/stats/core/ChargeStatsRecorderTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
88
import androidx.room.Room
99
import androidx.test.core.app.ApplicationProvider
1010
import eu.darken.amply.battery.core.BatteryReader
11+
import eu.darken.amply.battery.core.BatteryUnitCalibration
1112
import eu.darken.amply.common.AppDataStore
1213
import eu.darken.amply.stats.core.db.ChargeSessionEntity
1314
import eu.darken.amply.stats.core.db.StatsDatabase
@@ -103,7 +104,7 @@ class ChargeStatsRecorderTest {
103104
database = { databaseAccessCount++; database },
104105
preferences = preferences,
105106
bootIdSource = BootIdSource(context),
106-
batteryReader = BatteryReader(context),
107+
batteryReader = BatteryReader(context, BatteryUnitCalibration(context)),
107108
dispatcher = Dispatchers.Unconfined,
108109
)
109110

app/src/test/java/eu/darken/amply/stats/core/ChargeStatsRepositoryLiveTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
77
import androidx.room.Room
88
import androidx.test.core.app.ApplicationProvider
99
import eu.darken.amply.battery.core.BatteryReader
10+
import eu.darken.amply.battery.core.BatteryUnitCalibration
1011
import eu.darken.amply.common.AppDataStore
1112
import eu.darken.amply.stats.core.db.BatterySampleEntity
1213
import eu.darken.amply.stats.core.db.ChargeSessionEntity
@@ -73,7 +74,7 @@ class ChargeStatsRepositoryLiveTest {
7374
),
7475
),
7576
bootIdSource = bootIdSource,
76-
batteryReader = BatteryReader(context),
77+
batteryReader = BatteryReader(context, BatteryUnitCalibration(context)),
7778
dispatcher = Dispatchers.IO,
7879
)
7980
repository = ChargeStatsRepository(

0 commit comments

Comments
 (0)