Skip to content

Commit ca8a126

Browse files
committed
FullCharge: Offer the reconnect gesture on adapters without a hold signal
The gesture was hidden everywhere but stock Pixel, via a boolean that dated from when its only arming basis was the limit hold. That basis reads the charging-policy hardware state (EXTRA_CHARGING_STATUS == 4), which only Pixel and GrapheneOS decode at all, so it genuinely cannot arm elsewhere. The any-level basis added later needs no hardware signal, but the boolean gated the feature before that path was ever consulted. Replace it with ReconnectSupport (NONE / ANY_LEVEL_ONLY / FULL). Samsung (both generations), OnePlus/ColorOS, Xiaomi HyperOS 3 and LineageOS have a real cap that reads back synchronously and no observable hold, so they get ANY_LEVEL_ONLY, where the any-level basis is implied rather than opt-in: honouring a stored "off" would leave the master toggle on with no basis that could ever arm. The settings screen drops the sub-switch there and states the behaviour instead. GrapheneOS stays NONE because the ROM samples the key at plug-session start and the gesture's write lands strictly after the replug it would need to beat. Xiaomi HyperOS 2 stays NONE because Adaptive is its only protective mode and its hold is unobserved on that generation, so there would be nothing to lift on every tick; being enforcementIsConditional is not itself disqualifying, since Pixel arms on Adaptive today and OnePlus/HyperOS 3 now will too. GestureBasis gains the synchronous settings readback as a second conclusive source. Without it these adapters would arm off the write journal alone, so a limit removed in the OEM's own settings would keep arming the gesture. Null there means the adapter has no sync source and still hands the question to the journal; a sync-readback adapter always contributes an observation, substituting an explicit Unknown, because syncReadback() returns null for both "no source" and "read failed" and answering a failed readback from the journal it exists to override is the exact error the source was added to prevent. A non-Verified hardware decode deliberately keeps falling through, being an ordinary reading. The readback runs on the dispatch path, so a timeout degrades to no evidence rather than propagating: TimeoutCancellationException is a CancellationException, and DispatchCoordinator rethrows those, which would kill the battery-evaluation consumer for the life of the service and strand a later session with no restore ticks.
1 parent 227c03c commit ca8a126

26 files changed

Lines changed: 337 additions & 67 deletions

.claude/rules/architecture.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ single shared store, every write reaches every collector, so the deduplication h
2121

2222
- `AdapterRegistry` selects an OEM adapter from **immutable device information**. Live adapters declare a
2323
capability surface (`sessionOverridePolicy`, `defaultProtectivePolicy`, `verification`,
24-
`reconnectGestureSupported`) that the session/recovery/UI layers consume instead of hardcoding Pixel behavior.
24+
`reconnectGestureSupport`) that the session/recovery/UI layers consume instead of hardcoding Pixel behavior.
2525
- `AccessResolver` independently probes direct WSS and Shizuku.
2626
- `ChargingRepository` selects the strongest backend per operation: **Shizuku for reads, direct WSS for durable
2727
writes, then Shizuku for verification** when both are available.
@@ -89,10 +89,29 @@ Opt-in, with two arming bases:
8989
- **Limit hold (default)**: the public battery broadcast **simultaneously** reports external power, charging-policy
9090
hardware state `4`, a non-charging battery status, and an expected limit-range level. Once latched during a plug
9191
period it survives option flips (the evidence was the hardware hold itself).
92-
- **Any level (opt-in sub-option)**: plugged AND Amply's *persistent* configured policy
93-
(`ChargingPreferences.lastPersistentPolicy`, never updated by temporary session writes) is protective. Percent,
94-
battery status, and hardware hold are deliberately ignored. This basis is revoked immediately — including an open
95-
reconnect window — when the option is switched off or the persistent policy stops being protective.
92+
- **Any level (opt-in sub-option)**: plugged AND the configured policy is conclusively protective. Evidence comes
93+
from `GestureBasis.evidence()` in order: the hardware decode, then the synchronous settings readback
94+
(`ChargingRepository.syncReadback()`, null on async-hardware adapters), then Amply's *persistent* write journal
95+
(`ChargingPreferences.lastPersistentPolicy`, never updated by temporary session writes). Percent, battery status,
96+
and hardware hold are deliberately ignored. This basis is revoked immediately — including an open reconnect
97+
window — when the option is switched off or the policy stops being conclusively protective.
98+
99+
Which bases exist is per-adapter, via `ChargingAdapter.reconnectGestureSupport` (`ReconnectSupport`):
100+
101+
- `FULL` — Pixel only. It is the only adapter reporting the charging-policy hardware state the limit-hold basis
102+
needs while also applying writes mid-plug-session.
103+
- `ANY_LEVEL_ONLY` — Samsung (both generations), OnePlus/ColorOS, Xiaomi HyperOS 3, LineageOS. A real cap that
104+
reads back synchronously, but no observable hold signal, so the limit-hold basis can never arm. The any-level
105+
basis is **implied on** here: the sub-option is not consulted and the settings screen hides it, because honouring
106+
an "off" would leave the master switch on with no basis that could ever arm. The settings readback is what keeps
107+
these off journal-only evidence, so a limit removed in the OEM's own settings revokes the basis.
108+
- `NONE` — everything else, for two distinct reasons. GrapheneOS (and any future `policyLatchesAtPlug` adapter)
109+
because the gesture's write lands strictly after the replug the ROM already sampled, so it cannot take effect
110+
until the *next* replug. Xiaomi HyperOS 2 because Adaptive is its only protective mode *and* that
111+
mode's hold is unobserved on the generation (13T, 59%→100% with it configured), so the gesture
112+
would have nothing to lift on every tick. Being `enforcementIsConditional` is NOT itself
113+
disqualifying: Pixel arms on Adaptive today, and OnePlus/HyperOS 3 will too, since adaptive
114+
charging does hold below full before the usual unplug.
96115

97116
A powered→unpowered transition opens a reconnect window of **2–10 seconds** (`elapsedRealtime`-based): the 2s
98117
debounce floor filters momentary power cuts (car ignition, connector jostle), and a rejected too-fast/too-late replug

.claude/skills/oem-adapters/SKILL.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ Two live adapters, gated by `ro.build.version.oneui` ranges plus `protect_batter
2424

2525
Writes apply **synchronously** (`VerificationStrategy.SYNC_READBACK`): `apply()` requires read-back equality, no
2626
pending-settle window, boot recovery converges on settings readback, and no reapply-inversion trick is needed.
27-
The reconnect gesture is Pixel-only (`reconnectGestureSupported`). One UI 6/7 and 9+ fall through to the
27+
The reconnect gesture runs in `ReconnectSupport.ANY_LEVEL_ONLY` mode: One UI publishes no charging-policy hold
28+
signal, so the limit-hold basis can never arm and the any-level basis is implied on (the sub-option is hidden).
29+
One UI 6/7 and 9+ fall through to the
2830
diagnostics-only lab adapter. An external `protect_battery=0` makes One UI forget the user's prior mode (it falls back
2931
to the OEM default on re-enable), so Amply restores the exact prior policy itself rather than trusting Samsung's
3032
bookkeeping. Verified devices + coverage: see the qualification ledger (`device-qualification` skill).
@@ -92,7 +94,8 @@ NOT any AOSP `settings` namespace: `charging_control_enabled` (0/1), `charging_c
9294
unprivileged** (`LineageSettingsClient` via ContentResolver, shared by both backends); **writes require Shizuku**
9395
(`content insert`; the shell UID holds `lineageos.permission.WRITE_SETTINGS`, which `WRITE_SECURE_SETTINGS` cannot
9496
cover — `preferShizukuForWrites`, and the WSS auto-grant is skipped). `SYNC_READBACK` with read-back equality;
95-
session override = Unrestricted; protective default = FixedLimit(80); reconnect gesture unsupported.
97+
session override = Unrestricted; protective default = FixedLimit(80); reconnect gesture
98+
`ANY_LEVEL_ONLY` (reachable only once the enforcement gate enables control, since it rides `canApply`).
9699

97100
LineageOS's own `ChargingControlController` observes these keys and re-drives the `vendor.lineage.health.
98101
IChargingControl` HAL, so an external write is honored. But the HAL is **device-dependent** (the setting can flip

app/src/debug/java/eu/darken/amply/screenshots/ScreenshotContent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ internal fun ReconnectGestureContent() = PreviewWrapper {
117117
ChargingSettingsScreen(
118118
gestureEnabled = true,
119119
anyLevelEnabled = false,
120+
// The Play Store shot shows a Pixel, which has both arming bases, so the sub-option renders.
121+
anyLevelOnly = false,
120122
canEnableGesture = true,
121123
// A two-policy device, so the shot stays on the gesture options themselves.
122124
availablePolicies = listOf(ChargePolicy.FixedLimit(80), ChargePolicy.Unrestricted),

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ data class ChargingState(
7979
*/
8080
val defaultProtectivePolicy: ChargePolicy? = null,
8181
val reconnectSupported: Boolean = false,
82+
/** The limit-hold basis cannot arm here, so surfaces must not offer it as a choice. */
83+
val reconnectAnyLevelOnly: Boolean = false,
8284
/** True when the adapter's configured state is directly readable — Shizuku adds nothing for verification. */
8385
val syncVerification: Boolean = false,
8486
/** True when applying a policy needs Shizuku (system-namespace adapter WSS can't write). */
@@ -740,7 +742,8 @@ class ChargingRepository @Inject constructor(
740742
supportedPolicies = selection.support.licensedPolicies
741743
?: adapter?.supportedPolicies.orEmpty(),
742744
defaultProtectivePolicy = adapter?.defaultProtectivePolicy,
743-
reconnectSupported = adapter?.reconnectGestureSupported == true,
745+
reconnectSupported = adapter?.reconnectGestureSupport?.available == true,
746+
reconnectAnyLevelOnly = adapter?.reconnectGestureSupport?.impliesAnyLevel == true,
744747
syncVerification = adapter?.verification == VerificationStrategy.SYNC_READBACK,
745748
writeRequiresShizuku = adapter?.preferShizukuForWrites == true,
746749
controlEnabled = selection.support.controlEnabled,

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@ enum class VerificationStrategy {
6262
SYNC_READBACK,
6363
}
6464

65+
/**
66+
* How much of the reconnect gesture an adapter can support.
67+
*
68+
* [ANY_LEVEL_ONLY] is not a degraded [FULL]: it is the honest description of a device that enforces
69+
* a cap but publishes no observable hold signal, so Amply can know the cap is *configured* and never
70+
* that it is *holding right now*. Surfaces must not offer the limit-hold sub-option there — it
71+
* could never arm — and the gesture runs on the any-level basis regardless of the user's setting.
72+
*/
73+
enum class ReconnectSupport {
74+
/** No gesture. Either no cap to lift (Adaptive-only adapters) or the write cannot land in time
75+
* (adapters with `policyLatchesAtPlug`, where the gesture's write always misses the plug event). */
76+
NONE,
77+
78+
/** Any-level arming only — the hardware hold signal this adapter cannot report is not required. */
79+
ANY_LEVEL_ONLY,
80+
81+
/** Both bases, including arming off an observed hardware limit hold. */
82+
FULL,
83+
;
84+
85+
val available: Boolean get() = this != NONE
86+
val impliesAnyLevel: Boolean get() = this == ANY_LEVEL_ONLY
87+
}
88+
6589
interface ChargingAdapter {
6690
val id: String
6791
val displayName: CaString
@@ -76,8 +100,18 @@ interface ChargingAdapter {
76100

77101
val verification: VerificationStrategy get() = VerificationStrategy.ASYNC_HARDWARE
78102

79-
/** Whether the powered→unpowered reconnect gesture's hardware preconditions exist on this adapter. */
80-
val reconnectGestureSupported: Boolean get() = false
103+
/**
104+
* Which arming bases the powered→unpowered reconnect gesture can actually use here.
105+
*
106+
* The gesture's *limit-hold* basis reads Android's charging-policy hardware state
107+
* (`EXTRA_CHARGING_STATUS` == 4), which only [PixelChargingAdapter] and
108+
* [GrapheneOsChargingAdapter] decode at all — every other adapter inherits
109+
* [decodeHardware]'s null and can never satisfy it. That is why this started life as a
110+
* Pixel-only boolean. The *any-level* basis needs no hardware signal (see [GestureBasis]), so
111+
* on an adapter whose protective policy is a real cap it works fine; three states instead of
112+
* two is what lets those devices have the gesture without claiming a hold they cannot observe.
113+
*/
114+
val reconnectGestureSupport: ReconnectSupport get() = ReconnectSupport.NONE
81115

82116
/**
83117
* Prefer Shizuku over direct WSS for writes. Two adapter classes set it: keys in the `system`

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ class GrapheneOsChargingAdapter @Inject constructor() : ChargingAdapter {
7373
override val verification = VerificationStrategy.SYNC_READBACK
7474
override val policyLatchesAtPlug = true
7575

76+
// Structural, not a missing signal: this ROM samples the key at plug-session start, and the
77+
// gesture's override write lands strictly after the replug broadcast it would have to beat. It
78+
// would read back correctly and change nothing until the *next* replug. See the class doc.
79+
override val reconnectGestureSupport = ReconnectSupport.NONE
80+
7681
// Not because the namespace needs it (global is WSS-writable elsewhere) but because the key is
7782
// @Protected: a direct write throws SecurityException no matter which permissions Amply holds,
7883
// while the shell UID is exempt. Reads share the restriction; readSyncDirectFirst's direct

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ class LineageChargingAdapter @Inject constructor(
7878
override val defaultProtectivePolicy = ChargePolicy.FixedLimit(80)
7979
override val sessionOverridePolicy = ChargePolicy.Unrestricted
8080
override val verification = VerificationStrategy.SYNC_READBACK
81+
82+
// Reachability is still decided by the enforcement gate: with control off, `canApply` is
83+
// false and no surface offers the gesture, so this never widens what an unqualified build does.
84+
override val reconnectGestureSupport = ReconnectSupport.ANY_LEVEL_ONLY
8185
override val preferShizukuForWrites = true
8286
override val enforcementEvidenceRequired = true
8387

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ class OnePlusChargingAdapter @Inject constructor() : ChargingAdapter {
4848

4949
override val defaultProtectivePolicy = ChargePolicy.FixedLimit(LIMIT_PERCENT)
5050
override val verification = VerificationStrategy.SYNC_READBACK
51+
52+
// FixedLimit(80) is a real cap and reads back synchronously; ColorOS publishes no hold signal.
53+
override val reconnectGestureSupport = ReconnectSupport.ANY_LEVEL_ONLY
5154
override val preferShizukuForWrites = true
5255

5356
override fun probe(device: DeviceInfo): AdapterSupport {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class PixelChargingAdapter @Inject constructor() : ChargingAdapter {
2828
ChargePolicy.Adaptive,
2929
ChargePolicy.Unrestricted,
3030
)
31-
override val reconnectGestureSupported = true
31+
override val reconnectGestureSupport = ReconnectSupport.FULL
3232
override val observedSettingUris
3333
get() = listOf(
3434
Settings.Secure.getUriFor(KEY_MODE),

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ abstract class SamsungChargingAdapter : ChargingAdapter {
2828

2929
override val verification = VerificationStrategy.SYNC_READBACK
3030

31+
// No `EXTRA_CHARGING_STATUS` hold signal on One UI, so the limit-hold basis could never
32+
// arm; the cap itself is real and read-backable, which is all the any-level basis needs.
33+
override val reconnectGestureSupport = ReconnectSupport.ANY_LEVEL_ONLY
34+
3135
override fun probe(device: DeviceInfo): AdapterSupport {
3236
val oneUi = device.oneUiVersion
3337
val matched = device.manufacturer.equals("Samsung", ignoreCase = true) &&

0 commit comments

Comments
 (0)