Skip to content

Commit 8f9dfdf

Browse files
committed
Merge origin/main into the enforcement-evidence gate
Two conflicts, both places where this branch and the charge-conditions work independently addressed the same concern. DashboardScreen: main added provesPolicyInEffect() so a readback of a conditional policy cannot earn the confirmed checkmark; this branch withheld the same checkmark on a build whose enforcement was never confirmed. Both survive - either condition alone now withholds it. The tier-aware hero title and the UNVERIFIED provenance line are unchanged. ChargingRepository: main made nativeSettingsIntent() never null so an unmapped device reaches the generic battery-settings chain instead of Battery Saver; this branch routed adapter lookups through capabilityAdapter() because select() no longer has a default evidence state. Main's fallback now sits on top of that helper, since its registry.select() call no longer compiles. Three further breaks were invisible to the conflict markers and only surfaced by compiling: main's new conditional-policy preview did not pass onStartVerification, and its UnmappedDeviceSettingsIntentTest constructed ChargingRepository without evidenceStore/buildIdentity.
2 parents 0f37328 + 8b2f429 commit 8f9dfdf

21 files changed

Lines changed: 788 additions & 47 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
@@ -14,6 +15,7 @@ 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.AdapterSupport
1617
import eu.darken.amply.charging.core.adapter.ChargingAdapter
18+
import eu.darken.amply.charging.core.adapter.OemChargingShortcuts
1719
import eu.darken.amply.charging.core.adapter.VerificationStrategy
1820
import eu.darken.amply.charging.core.ChargingPreferences
1921
import eu.darken.amply.charging.core.enforcement.BuildIdentitySource
@@ -301,7 +303,13 @@ class ChargingRepository @Inject constructor(
301303
private fun capabilityAdapter(): ChargingAdapter? =
302304
registry.select(evidenceState = EnforcementEvidenceState.Loading).adapter
303305

304-
fun nativeSettingsIntent() = capabilityAdapter()?.nativeSettingsIntent(context)
306+
/**
307+
* Never null: a device with no adapter still gets the generic battery-settings chain. Returning null here made
308+
* every unmapped device (any brand Amply carries no adapter for) land on Battery Saver, because the only caller
309+
* substituted that directly — while every lab adapter deliberately prefers the battery-usage screen.
310+
*/
311+
fun nativeSettingsIntent(): Intent = capabilityAdapter()?.nativeSettingsIntent(context)
312+
?: OemChargingShortcuts.genericBatterySettings(context)
305313

306314
fun currentAdapter(): ChargingAdapter? = capabilityAdapter()
307315

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: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import eu.darken.amply.charging.core.access.BackendStatus
6969
import eu.darken.amply.charging.core.enforcement.EnforcementStatus
7070
import eu.darken.amply.charging.core.isAwaitingReplug
7171
import eu.darken.amply.charging.core.isSettling
72+
import eu.darken.amply.charging.core.provesPolicyInEffect
7273
import eu.darken.amply.charging.core.settlingTarget
7374
import eu.darken.amply.common.ca.CaString
7475
import eu.darken.amply.common.ca.caString
@@ -449,11 +450,13 @@ private fun StatusCard(
449450
val gatedTier = state.charging.enforcement
450451
?.takeIf { observation is ChargeObservation.Unsupported }
451452
?.takeIf { it == EnforcementStatus.CANDIDATE || it == EnforcementStatus.REFUTED }
452-
// A settings-level readback proves the ROM stored the limit, never that the hardware honours it.
453-
// On an unconfirmed build that difference is the whole point, so the green check — which reads as
454-
// "your battery is protected" — is withheld until enforcement is confirmed (which, on these
455-
// adapters, only physical qualification can do — see EnforcementVerdictEngine).
456-
val verified = observation is ChargeObservation.Verified && !enforcementUnverified
453+
// Two independent reasons to withhold the green check, both about the same gap between "the ROM
454+
// stored the policy" and "the charger is acting on it". Either one alone is disqualifying:
455+
// - the policy itself may be conditional — a readback of adaptive proves the mode is set, not
456+
// that it is doing anything right now;
457+
// - the build's enforcement may never have been confirmed, and on these adapters no readback can
458+
// confirm it (see EnforcementVerdictEngine — observation can only ever refute).
459+
val policyInEffect = observation.provesPolicyInEffect() && !enforcementUnverified
457460

458461
// Not clickable. The card states the policy; the measurements (and the way through to them) live
459462
// in the charging card below. A whole-card tap here used to land on voltage and cycle counts,
@@ -473,9 +476,9 @@ private fun StatusCard(
473476
)
474477
} else {
475478
Icon(
476-
imageVector = if (verified) Icons.Default.CheckCircle else Icons.Default.Security,
479+
imageVector = if (policyInEffect) Icons.Default.CheckCircle else Icons.Default.Security,
477480
contentDescription = null,
478-
tint = if (verified) Color(0xFF1A7F5A) else MaterialTheme.colorScheme.tertiary,
481+
tint = if (policyInEffect) Color(0xFF1A7F5A) else MaterialTheme.colorScheme.tertiary,
479482
)
480483
}
481484
Text(
@@ -954,10 +957,16 @@ private fun ChargeObservation.untieredTitle(): CaString = when (this) {
954957
}
955958

956959
private fun ChargeObservation.detail(): CaString = when (this) {
957-
is ChargeObservation.Verified -> if (backend == BackendKind.BATTERY_HARDWARE) {
958-
R.string.dashboard_detail_hw_confirmed.toCaString()
959-
} else {
960-
caString {
960+
// The backend placeholder stays in both readback strings: this line's job is provenance.
961+
is ChargeObservation.Verified -> when {
962+
backend == BackendKind.BATTERY_HARDWARE -> R.string.dashboard_detail_hw_confirmed.toCaString()
963+
policy.enforcementIsConditional -> caString {
964+
it.getString(
965+
R.string.dashboard_detail_readback_conditional,
966+
backend.name.replace('_', ' ').lowercase(),
967+
)
968+
}
969+
else -> caString {
961970
it.getString(R.string.dashboard_detail_readback, backend.name.replace('_', ' ').lowercase())
962971
}
963972
}
@@ -1247,6 +1256,81 @@ private fun DashboardScreenHwUnconfirmedPreview() = PreviewWrapper {
12471256
)
12481257
}
12491258

1259+
// The conditional-enforcement state, reproducing the real failure: a Xiaomi 13T sitting at 100% on
1260+
// the charger with Adaptive configured and verified. HyperOS engages Intelligent charging only in a
1261+
// learned overnight window, so the card must name the mode without claiming it is holding anything —
1262+
// no protection checkmark, and a detail line saying the system chooses when it applies.
1263+
@AmplyPreview
1264+
@Composable
1265+
private fun DashboardScreenConditionalPolicyPreview() = PreviewWrapper {
1266+
DashboardScreen(
1267+
state = DashboardUiState(
1268+
onboardingComplete = true,
1269+
batteryReadout = BatteryReadout(
1270+
levelPercent = 100,
1271+
status = android.os.BatteryManager.BATTERY_STATUS_FULL,
1272+
plugged = android.os.BatteryManager.BATTERY_PLUGGED_AC,
1273+
temperatureTenthsC = 316,
1274+
),
1275+
charging = ChargingState(
1276+
device = DeviceInfo("Xiaomi", "23078RKD5G", 35, "preview"),
1277+
adapterName = "Xiaomi charging protection".toCaString(),
1278+
adapterId = "xiaomi-hyperos2-v1",
1279+
adapterDetail = "Setting changes apply immediately; no reconnect needed".toCaString(),
1280+
supportedPolicies = listOf(
1281+
ChargePolicy.Adaptive,
1282+
ChargePolicy.Unrestricted,
1283+
),
1284+
defaultProtectivePolicy = ChargePolicy.Adaptive,
1285+
syncVerification = true,
1286+
controlEnabled = true,
1287+
access = AccessSnapshot(
1288+
direct = BackendStatus(
1289+
available = true,
1290+
granted = true,
1291+
detail = "Charge-control access granted".toCaString(),
1292+
),
1293+
shizuku = BackendStatus(
1294+
available = true,
1295+
granted = true,
1296+
detail = "Shizuku ready".toCaString(),
1297+
),
1298+
),
1299+
observation = ChargeObservation.Verified(ChargePolicy.Adaptive, BackendKind.SHIZUKU),
1300+
),
1301+
),
1302+
adbCommand = "adb shell pm grant eu.darken.amply android.permission.WRITE_SECURE_SETTINGS",
1303+
onRefresh = {},
1304+
onSettings = {},
1305+
onStartFull = {},
1306+
onRestore = {},
1307+
onApply = {},
1308+
onQuickFullChargeChange = {},
1309+
onAlarmEnabledChange = {},
1310+
onAlarmTargetChange = {},
1311+
onFixNotifications = {},
1312+
onOpenBatteryHub = {},
1313+
onRetryCapture = {},
1314+
onStartVerification = {},
1315+
onPinWidget = {},
1316+
onAddTile = {},
1317+
onDismissQuickAccess = {},
1318+
onDismissInterruption = {},
1319+
onNativeSettings = {},
1320+
onOpenShizuku = {},
1321+
onAllowShizuku = {},
1322+
onGrantWss = {},
1323+
onCopyAdb = {},
1324+
onCopyWebUsbLink = {},
1325+
onPrepareSupportReport = {},
1326+
onCopySupportReport = {},
1327+
onOpenContribution = {},
1328+
onOpenSupportIssue = {},
1329+
onEmailSupport = {},
1330+
onHelp = {},
1331+
)
1332+
}
1333+
12501334
@AmplyPreview
12511335
@Composable
12521336
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
@@ -640,14 +640,22 @@ class DashboardViewModel @Inject constructor(
640640
}
641641

642642
fun openNativeSettings() {
643-
val intent = repository.nativeSettingsIntent() ?: Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
644-
runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
645-
.onFailure {
646-
context.startActivity(
647-
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
648-
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
649-
)
650-
}
643+
// Resolution (including the unmapped-device fallback) lives in the repository; this only handles a
644+
// launch that throws despite having resolved — resolveActivity is advisory, so an activity can be
645+
// disabled or reject the launch between the check and the start.
646+
val intent = repository.nativeSettingsIntent()
647+
val failure = runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
648+
.exceptionOrNull() ?: return
649+
log(TAG, Logging.Priority.WARN) { "Native settings ${intent.action} failed: ${failure.asLog()}" }
650+
// Battery Saver is the repository's own last resort, so retrying it here would just throw again —
651+
// and unguarded, that second throw escaped and took the action down with it.
652+
if (intent.action == Settings.ACTION_BATTERY_SAVER_SETTINGS) return
653+
runCatching {
654+
context.startActivity(
655+
Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
656+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
657+
)
658+
}.onFailure { log(TAG, Logging.Priority.WARN) { "Battery-saver fallback failed: ${it.asLog()}" } }
651659
}
652660

653661
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)