Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .claude/rules/privileged-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,23 @@ long-term observation (see Known gaps below).

### LineageOS

LineageOS control requires **all** of: LineageOS (`ro.lineage.build.version` present), a **physically-qualified
LineageOS control requires **all** of: LineageOS (`DeviceInfo.isLineageOs`), a **physically-qualified
device codename** (`Build.DEVICE` ∈ `LineageChargingAdapter.QUALIFIED_CODENAMES`), the `lineagesettings` provider
present, and the system user. It is **manufacturer-agnostic** (LineageOS runs on many OEMs), so the Lineage
present, and the system user.

**Detect LineageOS with the `org.lineageos.android` system feature, never `ro.lineage.build.version`.** All five
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
`untrusted_app`. `SystemProperties.get` returns an *empty string* on denial instead of throwing, so the read fails
silently and a LineageOS device is indistinguishable from stock — which routed every LineageOS build to an OEM
adapter (verified on oriole / LineageOS 23.2 / Android 16). `hasSystemFeature` needs no `<queries>` entry and no
permission. `lineageOsVersion` remains a **secondary identity signal** — `isLineageOs` ORs it in so derivatives that
relabel the property still match — and is normally null on real hardware; it is not "diagnostics only".

**Blocker before the first codename is added to `QUALIFIED_CODENAMES`:** the live gate is codename-scoped, but HAL
capability is *build*-scoped — oriole exposed the LIMIT mode bit on LineageOS 20 and dropped it on 23.2 on identical
hardware. A bare codename entry would therefore claim more than any single qualification run proves. Scope the entry
by codename **plus** the qualified Lineage generation / API level (and treat a property-only or derivative match as
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
live/lab adapters are ordered **before all OEM adapters** in `AdapterRegistry` — a LineageOS build on Samsung/
Xiaomi/OnePlus hardware must never be swallowed by a manufacturer-based lab adapter. Unqualified LineageOS builds
fall to `LineageLabAdapter` (diagnostics/contribution).
Expand Down
25 changes: 25 additions & 0 deletions .claude/skills/device-qualification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,31 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
`apply(FixedLimit)` writes `mode=3`, reads back `mode=1 ≠ 3` → decodes `Unknown(unrecognizedValue=true)` → `apply`
returns false, so it **refuses without a false claim of control**. Reinforces the allowlist bar: a device must both
expose the LIMIT mode bit **and** actually cut charging. `charging_control_*` again **absent in factory state**.
- **Pixel 6 (oriole) on LineageOS 23.2-20260720-NIGHTLY / Android 16 — app-level compatibility pass 2026-08-03.**
HAL qualification **not re-run** (same build as the run above, verdict stands). This pass found a defect that made
the whole Lineage path unreachable in production: **Amply never detected LineageOS at all.** All five
`ro.lineage.*` properties are labelled `u:object_r:custom_version_prop:s0`, which SELinux denies to
`untrusted_app` (`avc: denied { read } … tcontext=custom_version_prop … app=eu.darken.amply`).
`SystemProperties.get` returns `""` on denial instead of throwing, so `SystemPropertyReader`'s `runCatching`
never fired, nothing was logged, and `LineageOsDetector.detect()` silently returned null — `getprop` over adb had
always worked because it runs as `shell`. Both Lineage adapters skipped and selection fell through to
`google-pixel-lab-v1`, which (a) made `QUALIFIED_CODENAMES` dead — a qualified codename could never activate,
(b) hid the "Help add support" wizard (`contributionWanted` defaults false on the Pixel adapter, true on the lab
adapter), and (c) pointed "open battery settings" at Battery Saver, since the Pixel intent targets the absent
Google Settings-Intelligence component and never tries `POWER_USAGE_SUMMARY` (which *does* resolve on LOS).
Fixed by gating on the app-readable `org.lineageos.android` system feature (`DeviceInfo.isLineageOs`); the
version property is kept as a **secondary identity signal** (OR-ed in, normally null on real hardware). The
same run added a `dumpsys lineagehealth` probe to the device-support report. **It is an observation, never a
verdict, and never qualifies a device.** Two reasons: selection is mode-dependent (upstream picks Deadline
before Limit for `MODE_AUTO`/`MODE_MANUAL`, so this device's `Provider: Deadline` at `Mode: 1` merely means
nothing was learned), and there is no negative case at all — `Toggle` also accepts `MODE_LIMIT` and enforces
the cap itself, so binding it is a capable mechanism rather than a rejection. Even `NATIVE_LIMIT` proves
nothing: oriole bound `Limit` on LOS 20 and still charged past the cap. Only physical observation of the
charge current qualifies a device. Oriole's NO-GO rests on the `mode=3` write reading back as `1` plus the
LOS 20 charge-past observation — both separate evidence from this probe. Note LineageOS **spoofs `Build.FINGERPRINT`** to stock
(`google/oriole/oriole:16/…/release-keys`), so fingerprint sniffing is not a fallback. Otherwise clean on this
ROM: install/launch/onboarding/dashboard/settings with no crashes, honest "Unsupported device" reporting, live
battery monitoring across simulated plug/level transitions, and the charge alarm firing at threshold.
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
the 80% hold is physically observed.
- **Pixel** — wireless at-threshold hold/charge-past and the widget under Shizuku-only remain unexercised (both share
Expand Down
13 changes: 9 additions & 4 deletions .claude/skills/oem-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,15 @@ observable (device holds at 80%). See the qualification ledger (`device-qualific
One live adapter (`lineageos-chargingcontrol-v1`) plus a `LineageLabAdapter`, for LineageOS's native Charging
Control. **Manufacturer-agnostic** — the ROM changes charging control regardless of the OEM hardware — so both are
registered **first** in `AdapterRegistry`, ahead of every OEM adapter; a LineageOS build on Samsung/Xiaomi/OnePlus/
Pixel hardware is handled by these, never the OEM lab adapters (stock devices have `lineageOsVersion == null` and
skip both). Gate: `ro.lineage.build.version` present + `Build.DEVICE` in a **physically-qualified codename
allowlist** (`QUALIFIED_CODENAMES`) + `lineagesettings` provider present + system user. Unqualified LineageOS builds
fall to `LineageLabAdapter`.
Pixel hardware is handled by these, never the OEM lab adapters (stock devices are not `isLineageOs` and skip both).
Gate: `DeviceInfo.isLineageOs` + `Build.DEVICE` in a **physically-qualified codename allowlist**
(`QUALIFIED_CODENAMES`) + `lineagesettings` provider present + system user. Unqualified LineageOS builds fall to
`LineageLabAdapter`.

`isLineageOs` comes from the **`org.lineageos.android` system feature**, not `ro.lineage.build.version`: the
`ro.lineage.*` properties are SELinux-denied to `untrusted_app` (`custom_version_prop`) and read back empty, which
previously made every LineageOS device look like stock and fall through to an OEM adapter. See
`rules/privileged-access.md`.

The three keys live in the private `content://lineagesettings/system` provider (`SettingNamespace.LINEAGE_SYSTEM`),
NOT any AOSP `settings` namespace: `charging_control_enabled` (0/1), `charging_control_mode` (3=LIMIT), and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,11 @@ interface IChargingControlService {
// /system/bin/settings cannot write. Dedicated op: `content insert` (Shizuku shell UID holds the
// Lineage write permission). Reads are unprivileged (LineageSettingsClient), so there is no read op.
boolean writeLineageSetting(String key, String value) = 5;
// Read-only capability probe for LineageOS charge control (`dumpsys lineagehealth`). Takes NO arguments —
// the command is a compile-time constant, so there is nothing for a caller to influence. Needs the shell UID
// because android.permission.DUMP is not grantable to a normal app. Returns the reduced `PROVIDER|mode`
// form, never the raw dump: that text carries the user's charging schedule and battery level, so it is
// parsed inside this process and only the two non-sensitive fields cross the boundary.
String dumpLineageChargingControl() = 6;
void destroy() = 16777114;
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ data class ChargingState(
val writeRequiresShizuku: Boolean = false,
val controlEnabled: Boolean = false,
val contributionWanted: Boolean = false,
/** See [eu.darken.amply.charging.core.adapter.AdapterSupport.guidedCaptureUseful]. */
val guidedCaptureUseful: Boolean = true,
/**
* False until adapter selection has actually run. Callers that *withhold* UI on an adapter capability must
* wait for this: the capability defaults are permissive, so acting on them before selection would briefly
* show something the resolved state forbids.
*/
val adapterResolved: Boolean = false,
val access: AccessSnapshot? = null,
val observation: ChargeObservation = ChargeObservation.Unknown(R.string.charging_reason_loading.toCaString()),
val pending: PendingRequest? = null,
Expand Down Expand Up @@ -114,7 +122,9 @@ data class ChargingState(
device.oneUiVersion != null ||
device.hyperOsVersion != null ||
device.oplusRomVersion != null ||
device.lineageOsVersion != null
// isLineageOs, not lineageOsVersion: the version property is SELinux-denied to apps and reads back
// empty on every real LineageOS build, so it would contribute nothing here.
device.isLineageOs
}

@Singleton
Expand Down Expand Up @@ -439,6 +449,8 @@ class ChargingRepository @Inject constructor(
writeRequiresShizuku = adapter?.preferShizukuForWrites == true,
controlEnabled = selection.support.controlEnabled,
contributionWanted = selection.support.contributionWanted,
guidedCaptureUseful = selection.support.guidedCaptureUseful,
adapterResolved = true,
access = access,
observation = observation,
pending = pending,
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/eu/darken/amply/charging/core/DeviceInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@ data class DeviceInfo(
val hyperOsVersion: Int? = null,
val oplusRomVersion: Int? = null,
val lineageOsVersion: String? = null,
val hasLineageFeature: Boolean = false,
val hasProtectBattery: Boolean = false,
val hasLineageSettingsProvider: Boolean = false,
val isSystemUser: Boolean = true,
) {
/**
* Whether this is a LineageOS build. [lineageOsVersion] alone must never gate this: every
* `ro.lineage.*` property lives in the SELinux context `custom_version_prop`, which
* `untrusted_app` cannot read, so on a real LineageOS device the property reads back empty and
* the version is null (see [LineageOsDetector]). [hasLineageFeature] is the app-readable
* identity signal; the version is kept as an OR so derivatives that expose the property but not
* the feature still match, and so unit tests can construct either shape.
*/
val isLineageOs: Boolean get() = hasLineageFeature || lineageOsVersion != null

companion object {
fun current(context: Context? = null) = DeviceInfo(
manufacturer = Build.MANUFACTURER.orEmpty(),
Expand All @@ -39,6 +50,13 @@ data class DeviceInfo(
hyperOsVersion = HyperOsVersionDetector.detect(),
oplusRomVersion = OplusRomVersionDetector.detect(),
lineageOsVersion = LineageOsDetector.detect(),
// The app-readable LineageOS identity signal. System features are not subject to package
// visibility filtering, so this needs no <queries> entry and no permission — unlike the
// ro.lineage.* properties, which SELinux denies to untrusted_app. Fail closed.
hasLineageFeature = context?.let {
runCatching { it.packageManager.hasSystemFeature(FEATURE_LINEAGE_OS) }
.getOrDefault(false)
} ?: false,
hasProtectBattery = context?.let {
runCatching {
Settings.Global.getString(it.contentResolver, KEY_PROTECT_BATTERY) != null
Expand Down Expand Up @@ -69,5 +87,8 @@ data class DeviceInfo(

/** Authority of LineageOS's private settings provider (`content://lineagesettings/...`). */
const val LINEAGE_SETTINGS_AUTHORITY = "lineagesettings"

/** LineageOS's core platform system feature, declared by `lineageos.platform`. */
const val FEATURE_LINEAGE_OS = "org.lineageos.android"
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package eu.darken.amply.charging.core

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

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

Expand Down
Loading