Skip to content

Commit 4457b46

Browse files
committed
Charging: Detect LineageOS via the system feature, not a denied property
LineageOS was never detected in the app process, so every LineageOS device was routed to an OEM adapter. All five ro.lineage.* properties are SELinux-labelled custom_version_prop, which untrusted_app cannot read, and SystemProperties.get returns an empty string on denial rather than throwing — so SystemPropertyReader's runCatching never fired, nothing was logged, and LineageOsDetector silently returned null. getprop over adb had always masked this by running as the shell UID. That made QUALIFIED_CODENAMES dead code (a qualified codename could never activate), hid the contribution card, and pointed "open battery settings" at Battery Saver instead of Battery. Detection now uses the app-readable org.lineageos.android system feature via DeviceInfo.isLineageOs; the version property is kept as a secondary OR-input for derivatives that relabel it, and for diagnostics. Also in the LineageOS contribution path: - The guided settings-diff wizard is withheld (AdapterSupport .guidedCaptureUseful). Its capture set excludes the lineagesettings provider, so a run there always diffs to empty and cannot be delivered — users were walked into a dead end after entering details and capturing twice. Both entry points and the destination itself are gated; the destination waits for adapterResolved so a saved-state restore cannot flash the wizard on the permissive default. - The direct report carries a dumpsys lineagehealth probe (new zero-argument AIDL op; constant binary and argument, read-only, no write allowlist involved). It is an observation, never a verdict: provider selection branches on the configured mode before capability, and Toggle also accepts MODE_LIMIT and enforces the cap itself, so no value disqualifies a device and none proves enforcement. - The raw dump is reduced to PROVIDER|mode inside the user service, so the user's charging schedule (StartTime/TargetTime) and battery level never cross Binder. - Withholding the wizard removed these users' only Shizuku grant entry point, which the probe needs; the card now offers it, branching on BackendStatus so a stopped server opens Shizuku instead of firing a request that returns false immediately. Fixes found alongside: the wizard's bottom bar lacked a navigation-bar inset and sat under the 3-button nav bar, and the unsupported card interpolated Build.MANUFACTURER, telling a LineageOS Pixel user that "Google devices" are unmapped. Verified on Pixel 6 (oriole) / LineageOS 23.2 / Android 16: adapter selection is lineageos-lab, the report reads is_lineageos=true, lineage_cc_provider=DEADLINE, lineage_cc_mode=1, lineage_cc_limit_mechanism=NOT_OBSERVED, and every field degrades to unknown with Shizuku stopped. Regression tests drive DeviceInfo.current against a real PackageManager, the seam the previous unit tests bypassed by injecting lineageOsVersion directly.
1 parent cc4ef84 commit 4457b46

31 files changed

Lines changed: 856 additions & 43 deletions

.claude/rules/privileged-access.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,23 @@ long-term observation (see Known gaps below).
8989

9090
### LineageOS
9191

92-
LineageOS control requires **all** of: LineageOS (`ro.lineage.build.version` present), a **physically-qualified
92+
LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), a **physically-qualified
9393
device codename** (`Build.DEVICE``LineageChargingAdapter.QUALIFIED_CODENAMES`), the `lineagesettings` provider
94-
present, and the system user. It is **manufacturer-agnostic** (LineageOS runs on many OEMs), so the Lineage
94+
present, and the system user.
95+
96+
**Detect LineageOS with the `org.lineageos.android` system feature, never `ro.lineage.build.version`.** All five
97+
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
98+
`untrusted_app`. `SystemProperties.get` returns an *empty string* on denial instead of throwing, so the read fails
99+
silently and a LineageOS device is indistinguishable from stock — which routed every LineageOS build to an OEM
100+
adapter (verified on oriole / LineageOS 23.2 / Android 16). `hasSystemFeature` needs no `<queries>` entry and no
101+
permission. `lineageOsVersion` remains a **secondary identity signal**`isLineageOs` ORs it in so derivatives that
102+
relabel the property still match — and is normally null on real hardware; it is not "diagnostics only".
103+
104+
**Blocker before the first codename is added to `QUALIFIED_CODENAMES`:** the live gate is codename-scoped, but HAL
105+
capability is *build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical
106+
hardware. A bare codename entry would therefore claim more than any single qualification run proves. Scope the entry
107+
by codename **plus** the qualified Lineage generation / API level (and treat a property-only or derivative match as
108+
insufficient for the live gate), or qualify every build you intend to cover. It is **manufacturer-agnostic** (LineageOS runs on many OEMs), so the Lineage
95109
live/lab adapters are ordered **before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/
96110
Xiaomi/OnePlus hardware must never be swallowed by a manufacturer-based lab adapter. Unqualified LineageOS builds
97111
fall to `LineageLabAdapter` (diagnostics/contribution).

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,31 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
8989
`apply(FixedLimit)` writes `mode=3`, reads back `mode=1 ≠ 3` → decodes `Unknown(unrecognizedValue=true)``apply`
9090
returns false, so it **refuses without a false claim of control**. Reinforces the allowlist bar: a device must both
9191
expose the LIMIT mode bit **and** actually cut charging. `charging_control_*` again **absent in factory state**.
92+
- **Pixel 6 (oriole) on LineageOS 23.2-20260720-NIGHTLY / Android 16 — app-level compatibility pass 2026-08-03.**
93+
HAL qualification **not re-run** (same build as the run above, verdict stands). This pass found a defect that made
94+
the whole Lineage path unreachable in production: **Amply never detected LineageOS at all.** All five
95+
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
96+
`untrusted_app` (`avc: denied { read } … tcontext=custom_version_prop … app=eu.darken.amply`).
97+
`SystemProperties.get` returns `""` on denial instead of throwing, so `SystemPropertyReader`'s `runCatching`
98+
never fired, nothing was logged, and `LineageOsDetector.detect()` silently returned null — `getprop` over adb had
99+
always worked because it runs as `shell`. Both Lineage adapters skipped and selection fell through to
100+
`google-pixel-lab-v1`, which (a) made `QUALIFIED_CODENAMES` dead — a qualified codename could never activate,
101+
(b) hid the "Help add support" wizard (`contributionWanted` defaults false on the Pixel adapter, true on the lab
102+
adapter), and (c) pointed "open battery settings" at Battery Saver, since the Pixel intent targets the absent
103+
Google Settings-Intelligence component and never tries `POWER_USAGE_SUMMARY` (which *does* resolve on LOS).
104+
Fixed by gating on the app-readable `org.lineageos.android` system feature (`DeviceInfo.isLineageOs`); the
105+
version property is kept as a **secondary identity signal** (OR-ed in, normally null on real hardware). The
106+
same run added a `dumpsys lineagehealth` probe to the device-support report. **It is an observation, never a
107+
verdict, and never qualifies a device.** Two reasons: selection is mode-dependent (upstream picks Deadline
108+
before Limit for `MODE_AUTO`/`MODE_MANUAL`, so this device's `Provider: Deadline` at `Mode: 1` merely means
109+
nothing was learned), and there is no negative case at all — `Toggle` also accepts `MODE_LIMIT` and enforces
110+
the cap itself, so binding it is a capable mechanism rather than a rejection. Even `NATIVE_LIMIT` proves
111+
nothing: oriole bound `Limit` on LOS 20 and still charged past the cap. Only physical observation of the
112+
charge current qualifies a device. Oriole's NO-GO rests on the `mode=3` write reading back as `1` plus the
113+
LOS 20 charge-past observation — both separate evidence from this probe. Note LineageOS **spoofs `Build.FINGERPRINT`** to stock
114+
(`google/oriole/oriole:16/…/release-keys`), so fingerprint sniffing is not a fallback. Otherwise clean on this
115+
ROM: install/launch/onboarding/dashboard/settings with no crashes, honest "Unsupported device" reporting, live
116+
battery monitoring across simulated plug/level transitions, and the charge alarm firing at threshold.
92117
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
93118
the 80% hold is physically observed.
94119
- **Pixel** — wireless at-threshold hold/charge-past and the widget under Shizuku-only remain unexercised (both share

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,15 @@ observable (device holds at 80%). See the qualification ledger (`device-qualific
6161
One live adapter (`lineageos-chargingcontrol-v1`) plus a `LineageLabAdapter`, for LineageOS's native Charging
6262
Control. **Manufacturer-agnostic** — the ROM changes charging control regardless of the OEM hardware — so both are
6363
registered **first** in `AdapterRegistry`, ahead of every OEM adapter; a LineageOS build on Samsung/Xiaomi/OnePlus/
64-
Pixel hardware is handled by these, never the OEM lab adapters (stock devices have `lineageOsVersion == null` and
65-
skip both). Gate: `ro.lineage.build.version` present + `Build.DEVICE` in a **physically-qualified codename
66-
allowlist** (`QUALIFIED_CODENAMES`) + `lineagesettings` provider present + system user. Unqualified LineageOS builds
67-
fall to `LineageLabAdapter`.
64+
Pixel hardware is handled by these, never the OEM lab adapters (stock devices are not `isLineageOs` and skip both).
65+
Gate: `DeviceInfo.isLineageOs` + `Build.DEVICE` in a **physically-qualified codename allowlist**
66+
(`QUALIFIED_CODENAMES`) + `lineagesettings` provider present + system user. Unqualified LineageOS builds fall to
67+
`LineageLabAdapter`.
68+
69+
`isLineageOs` comes from the **`org.lineageos.android` system feature**, not `ro.lineage.build.version`: the
70+
`ro.lineage.*` properties are SELinux-denied to `untrusted_app` (`custom_version_prop`) and read back empty, which
71+
previously made every LineageOS device look like stock and fall through to an OEM adapter. See
72+
`rules/privileged-access.md`.
6873

6974
The three keys live in the private `content://lineagesettings/system` provider (`SettingNamespace.LINEAGE_SYSTEM`),
7075
NOT any AOSP `settings` namespace: `charging_control_enabled` (0/1), `charging_control_mode` (3=LIMIT), and

app/src/main/aidl/eu/darken/amply/charging/core/access/shizuku/IChargingControlService.aidl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,11 @@ interface IChargingControlService {
99
// /system/bin/settings cannot write. Dedicated op: `content insert` (Shizuku shell UID holds the
1010
// Lineage write permission). Reads are unprivileged (LineageSettingsClient), so there is no read op.
1111
boolean writeLineageSetting(String key, String value) = 5;
12+
// Read-only capability probe for LineageOS charge control (`dumpsys lineagehealth`). Takes NO arguments —
13+
// the command is a compile-time constant, so there is nothing for a caller to influence. Needs the shell UID
14+
// because android.permission.DUMP is not grantable to a normal app. Returns the reduced `PROVIDER|mode`
15+
// form, never the raw dump: that text carries the user's charging schedule and battery level, so it is
16+
// parsed inside this process and only the two non-sensitive fields cross the boundary.
17+
String dumpLineageChargingControl() = 6;
1218
void destroy() = 16777114;
1319
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ data class ChargingState(
6464
val writeRequiresShizuku: Boolean = false,
6565
val controlEnabled: Boolean = false,
6666
val contributionWanted: Boolean = false,
67+
/** See [eu.darken.amply.charging.core.adapter.AdapterSupport.guidedCaptureUseful]. */
68+
val guidedCaptureUseful: Boolean = true,
69+
/**
70+
* False until adapter selection has actually run. Callers that *withhold* UI on an adapter capability must
71+
* wait for this: the capability defaults are permissive, so acting on them before selection would briefly
72+
* show something the resolved state forbids.
73+
*/
74+
val adapterResolved: Boolean = false,
6775
val access: AccessSnapshot? = null,
6876
val observation: ChargeObservation = ChargeObservation.Unknown(R.string.charging_reason_loading.toCaString()),
6977
val pending: PendingRequest? = null,
@@ -407,6 +415,8 @@ class ChargingRepository @Inject constructor(
407415
writeRequiresShizuku = adapter?.preferShizukuForWrites == true,
408416
controlEnabled = selection.support.controlEnabled,
409417
contributionWanted = selection.support.contributionWanted,
418+
guidedCaptureUseful = selection.support.guidedCaptureUseful,
419+
adapterResolved = true,
410420
access = access,
411421
observation = observation,
412422
pending = pending,

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,21 @@ data class DeviceInfo(
1919
val hyperOsVersion: Int? = null,
2020
val oplusRomVersion: Int? = null,
2121
val lineageOsVersion: String? = null,
22+
val hasLineageFeature: Boolean = false,
2223
val hasProtectBattery: Boolean = false,
2324
val hasLineageSettingsProvider: Boolean = false,
2425
val isSystemUser: Boolean = true,
2526
) {
27+
/**
28+
* Whether this is a LineageOS build. [lineageOsVersion] alone must never gate this: every
29+
* `ro.lineage.*` property lives in the SELinux context `custom_version_prop`, which
30+
* `untrusted_app` cannot read, so on a real LineageOS device the property reads back empty and
31+
* the version is null (see [LineageOsDetector]). [hasLineageFeature] is the app-readable
32+
* identity signal; the version is kept as an OR so derivatives that expose the property but not
33+
* the feature still match, and so unit tests can construct either shape.
34+
*/
35+
val isLineageOs: Boolean get() = hasLineageFeature || lineageOsVersion != null
36+
2637
companion object {
2738
fun current(context: Context? = null) = DeviceInfo(
2839
manufacturer = Build.MANUFACTURER.orEmpty(),
@@ -39,6 +50,13 @@ data class DeviceInfo(
3950
hyperOsVersion = HyperOsVersionDetector.detect(),
4051
oplusRomVersion = OplusRomVersionDetector.detect(),
4152
lineageOsVersion = LineageOsDetector.detect(),
53+
// The app-readable LineageOS identity signal. System features are not subject to package
54+
// visibility filtering, so this needs no <queries> entry and no permission — unlike the
55+
// ro.lineage.* properties, which SELinux denies to untrusted_app. Fail closed.
56+
hasLineageFeature = context?.let {
57+
runCatching { it.packageManager.hasSystemFeature(FEATURE_LINEAGE_OS) }
58+
.getOrDefault(false)
59+
} ?: false,
4260
hasProtectBattery = context?.let {
4361
runCatching {
4462
Settings.Global.getString(it.contentResolver, KEY_PROTECT_BATTERY) != null
@@ -69,5 +87,8 @@ data class DeviceInfo(
6987

7088
/** Authority of LineageOS's private settings provider (`content://lineagesettings/...`). */
7189
const val LINEAGE_SETTINGS_AUTHORITY = "lineagesettings"
90+
91+
/** LineageOS's core platform system feature, declared by `lineageos.platform`. */
92+
const val FEATURE_LINEAGE_OS = "org.lineageos.android"
7293
}
7394
}

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
package eu.darken.amply.charging.core
22

33
/**
4-
* Detects LineageOS (and close derivatives that keep the property) from `ro.lineage.build.version`
5-
* (e.g. "23.2"). The value is the ROM version string; presence alone is what gates the Lineage
6-
* charging-control adapter — the feature has shipped since LineageOS 20 and is not version-scoped.
7-
* Null on any failure or non-Lineage device; downstream gates treat null as "not LineageOS".
4+
* Best-effort read of the LineageOS ROM version string from `ro.lineage.build.version` (e.g. "23.2"),
5+
* for diagnostics and device-support reports only.
6+
*
7+
* **This is not a LineageOS detector — do not gate on it.** Every `ro.lineage.*` property is labelled
8+
* `u:object_r:custom_version_prop:s0`, which SELinux denies to `untrusted_app`. `SystemProperties.get`
9+
* returns an empty string on denial rather than throwing (see [SystemPropertyReader]), so on a real
10+
* LineageOS device this returns null and is indistinguishable from stock Android. Verified on
11+
* LineageOS 23.2 / Android 16 (oriole): `avc: denied { read } ... tcontext=custom_version_prop`.
12+
*
13+
* Use [DeviceInfo.isLineageOs], which is backed by the app-readable `org.lineageos.android` system
14+
* feature. Non-null here only on builds that relabel the property, so it stays an OR-input to that
15+
* flag rather than the sole signal.
816
*/
917
object LineageOsDetector {
1018

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import eu.darken.amply.common.debug.logging.logTag
77
* Reflective read of `android.os.SystemProperties` — there is no public API for vendor build
88
* properties. Any failure (hidden-API policy, missing property) yields null; callers must treat
99
* null as "not qualified", never as a default.
10+
*
11+
* **A null here does not mean the property is absent.** SELinux labels properties individually, and
12+
* `SystemProperties.get` returns an *empty string* when the app's domain lacks read access — it does
13+
* not throw, so nothing is logged and a denied read is indistinguishable from an unset one. A ROM
14+
* that hides its identity properties behind a custom label therefore reads exactly like stock. Never
15+
* use a property as the sole gate for a ROM whose properties you have not confirmed readable from an
16+
* `untrusted_app` process on real hardware; prefer a system feature or a PackageManager lookup.
1017
*/
1118
object SystemPropertyReader {
1219

0 commit comments

Comments
 (0)