Skip to content

Commit b15d58a

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/charge-conditions
2 parents 780a0f9 + 8b2f429 commit b15d58a

21 files changed

Lines changed: 774 additions & 43 deletions

.claude/skills/device-qualification/SKILL.md

Lines changed: 207 additions & 7 deletions
Large diffs are not rendered by default.

app/src/main/java/eu/darken/amply/charging/core/ChargePolicy.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ sealed interface ChargePolicy {
1212
val allowsFullCharge: Boolean
1313
get() = this == Unrestricted || this == PauseAtFull || (this is FixedLimit && percent >= 100)
1414

15+
/**
16+
* Whether the OEM decides *when* this policy acts, rather than acting whenever it is configured.
17+
* A conditional policy that is configured and read back proves the mode is selected — never that
18+
* the battery is being protected right now. Every OEM "adaptive"/"smart"/"intelligent" mode
19+
* engages only inside a learned window: a HyperOS overnight window on Xiaomi (a 13T with
20+
* Intelligent charging configured and verified charged 59%→100% with no hold, 2026-08-16), and
21+
* shortly before the usual unplug on Pixel/ColorOS.
22+
*
23+
* Consumed by [ChargeObservation.provesPolicyInEffect] for presentation only.
24+
*/
25+
val enforcementIsConditional: Boolean
26+
get() = when (this) {
27+
Adaptive -> true
28+
Unrestricted, PauseAtFull -> false
29+
is FixedLimit -> false
30+
}
31+
1532
data object Unrestricted : ChargePolicy {
1633
override val stableId = "unrestricted"
1734
override val label = R.string.charging_policy_unrestricted_label.toCaString()

app/src/main/java/eu/darken/amply/charging/core/ChargeStatus.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ fun ChargingState.isSettling(now: Long): Boolean {
2424
obs.policy == p.target)
2525
}
2626

27+
/**
28+
* Whether the observed policy is what the charger is actually *doing*, as opposed to what has merely
29+
* been *selected*. Note this is a statement about knowledge, not about safety: `Unrestricted` is in
30+
* effect exactly as verifiably as a fixed limit, it just protects nothing.
31+
*
32+
* For an unconditional policy the two coincide — a fixed limit caps, and no limit charges to full,
33+
* whenever configured. For a policy whose engagement the OEM decides
34+
* ([ChargePolicy.enforcementIsConditional]) they come apart: a readback proves the mode is selected
35+
* and nothing more, so only a [BackendKind.BATTERY_HARDWARE] reading can show it is engaged.
36+
*
37+
* [isSettling] asks whether the write landed; this asks whether the configuration describes reality.
38+
* Those are different questions, which is why this is **presentation only**. It must never be adopted
39+
* by pending, settling, recovery, session, or gesture logic: those all ask "is the configuration what
40+
* we asked for?", which a conditional policy answers fully. In particular `ChargingRepository`'s
41+
* `settled` computation and `computeRefreshPending`'s sync-readback arm must keep clearing on any
42+
* matching readback — adopting this there would spin a Xiaomi adaptive write for the full settling
43+
* window on every apply, on that adapter's own protective default.
44+
*/
45+
fun ChargeObservation.provesPolicyInEffect(): Boolean =
46+
this is ChargeObservation.Verified &&
47+
(backend == BackendKind.BATTERY_HARDWARE || !policy.enforcementIsConditional)
48+
2749
/** The policy a settling request is converging on, or null when nothing is pending. Surfaces choose their own copy. */
2850
fun ChargingState.settlingTarget(): ChargePolicy? = pending?.target
2951

app/src/main/java/eu/darken/amply/charging/core/ChargingRepository.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package eu.darken.amply.charging.core
22

33
import android.content.Context
4+
import android.content.Intent
45
import dagger.hilt.android.qualifiers.ApplicationContext
56
import android.os.BatteryManager
67
import eu.darken.amply.R
@@ -13,6 +14,7 @@ import eu.darken.amply.charging.core.access.shizuku.ShizukuController
1314
import eu.darken.amply.charging.core.adapter.AdapterRegistry
1415
import eu.darken.amply.charging.core.adapter.AdapterSelection
1516
import eu.darken.amply.charging.core.adapter.ChargingAdapter
17+
import eu.darken.amply.charging.core.adapter.OemChargingShortcuts
1618
import eu.darken.amply.charging.core.adapter.VerificationStrategy
1719
import eu.darken.amply.charging.core.ChargingPreferences
1820
import eu.darken.amply.common.ca.CaString
@@ -258,7 +260,13 @@ class ChargingRepository @Inject constructor(
258260
}
259261
}
260262

261-
fun nativeSettingsIntent() = registry.select().adapter?.nativeSettingsIntent(context)
263+
/**
264+
* Never null: a device with no adapter still gets the generic battery-settings chain. Returning null here made
265+
* every unmapped device (any brand Amply carries no adapter for) land on Battery Saver, because the only caller
266+
* substituted that directly — while every lab adapter deliberately prefers the battery-usage screen.
267+
*/
268+
fun nativeSettingsIntent(): Intent = registry.select().adapter?.nativeSettingsIntent(context)
269+
?: OemChargingShortcuts.genericBatterySettings(context)
262270

263271
fun currentAdapter(): ChargingAdapter? = registry.select().adapter
264272

app/src/main/java/eu/darken/amply/charging/core/adapter/LabChargingAdapters.kt

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package eu.darken.amply.charging.core.adapter
22

33
import android.content.Context
44
import android.content.Intent
5-
import android.provider.Settings
65
import eu.darken.amply.R
76
import eu.darken.amply.charging.core.access.AccessBackend
87
import eu.darken.amply.charging.core.ChargeObservation
@@ -33,17 +32,8 @@ abstract class DisabledLabAdapter : ChargingAdapter {
3332

3433
override suspend fun apply(policy: ChargePolicy, backend: AccessBackend) = false
3534

36-
override fun nativeSettingsIntent(context: Context): Intent {
37-
// Prefer the system battery-usage screen, which on most OEM skins is the entry point that
38-
// also holds the built-in charge-protection toggle; fall back to Battery Saver settings
39-
// where it isn't resolvable. Both are generic AOSP actions — no brittle OEM ComponentNames.
40-
val powerUsage = Intent(Intent.ACTION_POWER_USAGE_SUMMARY).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
41-
return if (powerUsage.resolveActivity(context.packageManager) != null) {
42-
powerUsage
43-
} else {
44-
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
45-
}
46-
}
35+
override fun nativeSettingsIntent(context: Context): Intent =
36+
OemChargingShortcuts.genericBatterySettings(context)
4737
}
4838

4939
@Singleton

app/src/main/java/eu/darken/amply/charging/core/adapter/OemChargingShortcuts.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eu.darken.amply.charging.core.adapter
22

33
import android.content.Context
44
import android.content.Intent
5+
import android.provider.Settings
56
import eu.darken.amply.charging.core.DeviceInfo
67

78
/**
@@ -25,4 +26,23 @@ object OemChargingShortcuts {
2526
candidate.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
2627
return candidate.takeIf { it.resolveActivity(context.packageManager) != null }
2728
}
29+
30+
/**
31+
* The generic chain every adapter falls back to: the system battery-usage screen, which on most OEM skins is
32+
* the entry point that also holds the built-in charge-protection toggle, then Battery Saver where it isn't
33+
* resolvable. Both are AOSP actions — no brittle OEM ComponentNames. `POWER_USAGE_SUMMARY` visibility is
34+
* declared in the manifest's `<queries>`, so it resolves on any ROM that ships the screen.
35+
*
36+
* A device Amply has **no adapter at all** for resolves through here too, which is the point: an unmapped
37+
* device is the one most likely to need the OEM's own screen, and it used to land straight on Battery Saver
38+
* because there was no adapter object to ask (`ChargingRepository.nativeSettingsIntent`).
39+
*/
40+
fun genericBatterySettings(context: Context): Intent {
41+
val powerUsage = Intent(Intent.ACTION_POWER_USAGE_SUMMARY).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
42+
return if (powerUsage.resolveActivity(context.packageManager) != null) {
43+
powerUsage
44+
} else {
45+
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
46+
}
47+
}
2848
}

app/src/main/java/eu/darken/amply/charging/core/adapter/XiaomiChargingAdapter.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ class XiaomiChargingAdapter @Inject constructor() : ChargingAdapter {
4444
ChargePolicy.Unrestricted,
4545
)
4646

47+
/**
48+
* The only adapter whose protective default is conditional ([ChargePolicy.enforcementIsConditional]),
49+
* and unavoidably so: the HyperOS 2 key domain is `{0,1}`, so Adaptive is the sole protective mode
50+
* this ROM offers. HyperOS only engages it inside a learned overnight window — a 13T with Adaptive
51+
* configured and read back charged 59%→100% untouched (2026-08-16) — so the honesty burden lands on
52+
* presentation, which refuses to claim active protection for it. The HyperOS 3 adapter has an
53+
* unconditional mode available and defaults to `FixedLimit(80)` instead.
54+
*/
4755
override val defaultProtectivePolicy = ChargePolicy.Adaptive
4856
override val verification = VerificationStrategy.SYNC_READBACK
4957

@@ -152,7 +160,9 @@ class XiaomiHyperOs3ChargingAdapter @Inject constructor() : ChargingAdapter {
152160
detail = when {
153161
!matched -> R.string.adapter_detail_requires_xiaomi_hyperos3
154162
!device.isSystemUser -> R.string.adapter_detail_secondary_user
155-
else -> R.string.adapter_detail_xiaomi_ready
163+
// Distinct from the HyperOS 2 string: Battery protection has demonstrated hardware
164+
// enforcement (issue #48), so the stronger claim stays accurate here.
165+
else -> R.string.adapter_detail_xiaomi_hyperos3_ready
156166
},
157167
contributionWanted = false,
158168
)

app/src/main/java/eu/darken/amply/main/ui/dashboard/DashboardScreen.kt

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import eu.darken.amply.charging.core.access.AccessSnapshot
6868
import eu.darken.amply.charging.core.access.BackendStatus
6969
import eu.darken.amply.charging.core.isAwaitingReplug
7070
import eu.darken.amply.charging.core.isSettling
71+
import eu.darken.amply.charging.core.provesPolicyInEffect
7172
import eu.darken.amply.charging.core.settlingTarget
7273
import eu.darken.amply.common.ca.CaString
7374
import eu.darken.amply.common.ca.caString
@@ -441,7 +442,9 @@ private fun StatusCard(
441442
}
442443
}
443444
val settling = state.charging.isSettling(now)
444-
val verified = observation is ChargeObservation.Verified
445+
// Not "is it Verified" — a readback of a conditional policy (adaptive) proves the mode is set,
446+
// not that the charger is acting on it, so it must not earn the confirmed checkmark.
447+
val policyInEffect = observation.provesPolicyInEffect()
445448

446449
// Not clickable. The card states the policy; the measurements (and the way through to them) live
447450
// in the charging card below. A whole-card tap here used to land on voltage and cycle counts,
@@ -461,9 +464,9 @@ private fun StatusCard(
461464
)
462465
} else {
463466
Icon(
464-
imageVector = if (verified) Icons.Default.CheckCircle else Icons.Default.Security,
467+
imageVector = if (policyInEffect) Icons.Default.CheckCircle else Icons.Default.Security,
465468
contentDescription = null,
466-
tint = if (verified) Color(0xFF1A7F5A) else MaterialTheme.colorScheme.tertiary,
469+
tint = if (policyInEffect) Color(0xFF1A7F5A) else MaterialTheme.colorScheme.tertiary,
467470
)
468471
}
469472
Text(
@@ -930,10 +933,16 @@ private fun ChargeObservation.title(): CaString = when (this) {
930933
}
931934

932935
private fun ChargeObservation.detail(): CaString = when (this) {
933-
is ChargeObservation.Verified -> if (backend == BackendKind.BATTERY_HARDWARE) {
934-
R.string.dashboard_detail_hw_confirmed.toCaString()
935-
} else {
936-
caString {
936+
// The backend placeholder stays in both readback strings: this line's job is provenance.
937+
is ChargeObservation.Verified -> when {
938+
backend == BackendKind.BATTERY_HARDWARE -> R.string.dashboard_detail_hw_confirmed.toCaString()
939+
policy.enforcementIsConditional -> caString {
940+
it.getString(
941+
R.string.dashboard_detail_readback_conditional,
942+
backend.name.replace('_', ' ').lowercase(),
943+
)
944+
}
945+
else -> caString {
937946
it.getString(R.string.dashboard_detail_readback, backend.name.replace('_', ' ').lowercase())
938947
}
939948
}
@@ -1222,6 +1231,81 @@ private fun DashboardScreenHwUnconfirmedPreview() = PreviewWrapper {
12221231
)
12231232
}
12241233

1234+
// The conditional-enforcement state, reproducing the real failure: a Xiaomi 13T sitting at 100% on
1235+
// the charger with Adaptive configured and verified. HyperOS engages Intelligent charging only in a
1236+
// learned overnight window, so the card must name the mode without claiming it is holding anything —
1237+
// no protection checkmark, and a detail line saying the system chooses when it applies.
1238+
@AmplyPreview
1239+
@Composable
1240+
private fun DashboardScreenConditionalPolicyPreview() = PreviewWrapper {
1241+
DashboardScreen(
1242+
state = DashboardUiState(
1243+
onboardingComplete = true,
1244+
batteryReadout = BatteryReadout(
1245+
levelPercent = 100,
1246+
status = android.os.BatteryManager.BATTERY_STATUS_FULL,
1247+
plugged = android.os.BatteryManager.BATTERY_PLUGGED_AC,
1248+
temperatureTenthsC = 316,
1249+
),
1250+
charging = ChargingState(
1251+
device = DeviceInfo("Xiaomi", "23078RKD5G", 35, "preview"),
1252+
adapterName = "Xiaomi charging protection".toCaString(),
1253+
adapterId = "xiaomi-hyperos2-v1",
1254+
adapterDetail = "Setting changes apply immediately; no reconnect needed".toCaString(),
1255+
supportedPolicies = listOf(
1256+
ChargePolicy.Adaptive,
1257+
ChargePolicy.Unrestricted,
1258+
),
1259+
defaultProtectivePolicy = ChargePolicy.Adaptive,
1260+
syncVerification = true,
1261+
controlEnabled = true,
1262+
access = AccessSnapshot(
1263+
direct = BackendStatus(
1264+
available = true,
1265+
granted = true,
1266+
detail = "Charge-control access granted".toCaString(),
1267+
),
1268+
shizuku = BackendStatus(
1269+
available = true,
1270+
granted = true,
1271+
detail = "Shizuku ready".toCaString(),
1272+
),
1273+
),
1274+
observation = ChargeObservation.Verified(ChargePolicy.Adaptive, BackendKind.SHIZUKU),
1275+
),
1276+
),
1277+
adbCommand = "adb shell pm grant eu.darken.amply android.permission.WRITE_SECURE_SETTINGS",
1278+
onRefresh = {},
1279+
onSettings = {},
1280+
onStartFull = {},
1281+
onRestore = {},
1282+
onApply = {},
1283+
onQuickFullChargeChange = {},
1284+
onAlarmEnabledChange = {},
1285+
onAlarmTargetChange = {},
1286+
onFixNotifications = {},
1287+
onOpenBatteryHub = {},
1288+
onRetryCapture = {},
1289+
onPinWidget = {},
1290+
onAddTile = {},
1291+
onDismissQuickAccess = {},
1292+
onDismissInterruption = {},
1293+
onNativeSettings = {},
1294+
onOpenShizuku = {},
1295+
onAllowShizuku = {},
1296+
onGrantWss = {},
1297+
onCopyAdb = {},
1298+
onCopyWebUsbLink = {},
1299+
onPrepareSupportReport = {},
1300+
onCopySupportReport = {},
1301+
onOpenContribution = {},
1302+
onOpenSupportIssue = {},
1303+
onEmailSupport = {},
1304+
onHelp = {},
1305+
onOpenConditions = {},
1306+
)
1307+
}
1308+
12251309
@AmplyPreview
12261310
@Composable
12271311
private fun DashboardScreenApplyingPreview() = PreviewWrapper {

app/src/main/java/eu/darken/amply/main/ui/dashboard/DashboardViewModel.kt

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -662,14 +662,22 @@ class DashboardViewModel @Inject constructor(
662662
}
663663

664664
fun openNativeSettings() {
665-
val intent = repository.nativeSettingsIntent() ?: Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
666-
runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
667-
.onFailure {
668-
context.startActivity(
669-
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
670-
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
671-
)
672-
}
665+
// Resolution (including the unmapped-device fallback) lives in the repository; this only handles a
666+
// launch that throws despite having resolved — resolveActivity is advisory, so an activity can be
667+
// disabled or reject the launch between the check and the start.
668+
val intent = repository.nativeSettingsIntent()
669+
val failure = runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
670+
.exceptionOrNull() ?: return
671+
log(TAG, Logging.Priority.WARN) { "Native settings ${intent.action} failed: ${failure.asLog()}" }
672+
// Battery Saver is the repository's own last resort, so retrying it here would just throw again —
673+
// and unguarded, that second throw escaped and took the action down with it.
674+
if (intent.action == Settings.ACTION_BATTERY_SAVER_SETTINGS) return
675+
runCatching {
676+
context.startActivity(
677+
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
678+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
679+
)
680+
}.onFailure { log(TAG, Logging.Priority.WARN) { "Battery-saver fallback failed: ${it.asLog()}" } }
673681
}
674682

675683
fun openShizuku() = viewModelScope.launch {

app/src/main/java/eu/darken/amply/stats/core/StatsPowerCalculator.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ import kotlin.math.abs
1010
* not encode direction (charge vs discharge) — the caller derives direction from plug/charging state.
1111
*
1212
* `mV × µA = 10⁻⁹ W = 10⁻⁶ mW`, so `mW = mV × |µA| / 1_000_000`, computed in [Long] to avoid the
13-
* `Int` overflow that `4300 mV × 3_000_000 µA` would hit. Values outside a plausible phone/tablet
14-
* range are rejected as `null` because some OEM firmwares report current in the wrong unit (mA, or
15-
* deci-units) and would otherwise poison the session average.
13+
* `Int` overflow that `4300 mV × 3_000_000 µA` would hit. Results above [MAX_PLAUSIBLE_MILLIWATTS] are
14+
* rejected as `null`, which catches a grossly over-reporting firmware before it poisons the session
15+
* average. It is a backstop, not a unit check: a 10× over-report of a real 4 A at 4.551 V lands at
16+
* ~182 W and is accepted, as the 20 A case in the tests shows.
17+
*
18+
* **The opposite error is not detectable here, and deliberately is not guarded.** A firmware that
19+
* reports `CURRENT_NOW` in mA where Android documents µA makes every reading 1000× too *small*: a real
20+
* 4 A arrives as the integer `4000`, renders as "4 mA", and computes to 18 mW — which formats as
21+
* "0.0 W". No lower bound can separate that from a legitimate end-of-charge trickle, because a phone
22+
* genuinely drawing single-digit mA at 100% produces the identical value. Distinguishing them needs
23+
* context this function does not have (charge level, or a second property known to be misscaled), so
24+
* the fix belongs wherever that context lives, not in a magnitude clamp here. Observed on HONOR
25+
* MagicOS 10 (unconfirmed) — see the HONOR entry in the device-qualification skill.
1626
*/
1727
object StatsPowerCalculator {
1828

0 commit comments

Comments
 (0)