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
14 changes: 14 additions & 0 deletions .claude/skills/device-qualification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ only after adding a row here. Detailed run narratives live in each adapter's lan
session/recovery closed, pending-until-replug hint shown — and the exposure is one charge cycle,
bounded by the plug session the user is already in. Deliberately NOT treated as a defect.
- **Wireless charging and secondary users**: NOT RUN (gated to system user).
- **Oplus (OnePlus/Oppo/Realme)** — the live gate reads `ro.build.version.oplusrom`, which **does not exist on
pre-rebrand ColorOS**, so every ColorOS 11-era build is invisible to it and lands on `OnePlusLabAdapter`. This is
correct behaviour (those builds are unqualified either way), but it made reports from them uninformative.
- **Oppo F11 Pro `CPH1969` (`OP4863`), Android 11 / ColorOS 11 — device-support report only, 2026-08-15. NOT a
qualification run: no physical test, no Shizuku, no settings capture.** The report carried
`oplus_rom_version=none` and nothing else about the family, because the report probed only the Samsung,
GrapheneOS, and LineageOS keys — the two ColorOS keys Amply already knows were never read. Absence of the
property could not be distinguished from an SELinux-denied read either (`SystemPropertyReader` returns `""` on
denial). Prompted two report changes: an unprivileged presence probe of the two `system` keys, and tri-state
probe results (`present|absent|read_denied`) so a refused read stops rendering as a proven negative. **Still
open:** whether pre-rebrand ColorOS carries those keys under any name is unknown, and no legacy property
(`ro.build.version.opporom`) is read, so pre-ColorOS-12 builds remain undetectable as Oplus by ROM version. A
device with a *working* pre-15 ColorOS charge-protection feature would need a new gate signal, not a widened
version constant — and the usual bar applies: physically observed charging cessation, not a settings mapping.
- **Xiaomi** — adaptive hardware enforcement of external writes unconfirmed; treat the adapter as provisional until
the 80% hold is physically observed.
- **HyperOS 3 candidate mapping (contribution report, 2026-08-07 — unqualified at the time; since landed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import eu.darken.amply.charging.core.ChargeObservation
import eu.darken.amply.charging.core.ChargePolicy
import eu.darken.amply.charging.core.ChargingState
import eu.darken.amply.charging.core.DeviceInfo
import eu.darken.amply.charging.core.SettingProbe
import eu.darken.amply.charging.core.access.AccessSnapshot
import eu.darken.amply.charging.core.access.BackendStatus
import eu.darken.amply.common.ca.toCaString
Expand Down Expand Up @@ -244,7 +245,14 @@ private fun samsungState() = DashboardUiState(
batteryReadout = holdingAtLimit(),
stats = liveStats(),
charging = ChargingState(
device = DeviceInfo("samsung", "SM-X210", 36, "preview", oneUiVersion = 80000, hasProtectBattery = true),
device = DeviceInfo(
"samsung",
"SM-X210",
36,
"preview",
oneUiVersion = 80000,
protectBatteryProbe = SettingProbe.PRESENT,
),
adapterName = "Samsung battery protection".toCaString(),
adapterId = "samsung-oneui8-v1",
supportedPolicies = listOf(
Expand Down
41 changes: 24 additions & 17 deletions app/src/main/java/eu/darken/amply/charging/core/DeviceInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ data class DeviceInfo(
val lineageOsVersion: String? = null,
val hasLineageFeature: Boolean = false,
val hasGrapheneOsPackages: Boolean = false,
val hasProtectBattery: Boolean = false,
val hasBatteryChargeLimit: Boolean = false,
val protectBatteryProbe: SettingProbe = SettingProbe.ABSENT,
val batteryChargeLimitProbe: SettingProbe = SettingProbe.ABSENT,
val hasLineageSettingsProvider: Boolean = false,
val isSystemUser: Boolean = true,
) {
/**
* Whether Samsung's battery-protection key was read back. Gates device-wide Samsung writes, so it fails closed:
* a refused read counts as "no key", exactly as the previous Boolean field did.
*/
val hasProtectBattery: Boolean get() = protectBatteryProbe.isPresent

/** Whether a `battery_charge_limit` key was read back. Diagnostic only — see the probe site below. */
val hasBatteryChargeLimit: Boolean get() = batteryChargeLimitProbe.isPresent
/**
* 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
Expand Down Expand Up @@ -83,21 +91,20 @@ data class DeviceInfo(
}.getOrDefault(false)
}
} ?: false,
hasProtectBattery = context?.let {
runCatching {
Settings.Global.getString(it.contentResolver, KEY_PROTECT_BATTERY) != null
}.getOrDefault(false)
} ?: false,
// GrapheneOS's charge-limit key. NOT a gate: GrapheneOS marks the key @Protected, so
// this unprivileged read throws SecurityException (→ false) on real GrapheneOS whether
// the key exists or not — verified via the issue-#49 beta report. Kept as a diagnostic
// signal only: true would indicate a ROM exposing a same-named key WITHOUT the
// GrapheneOS restriction, which is worth seeing in a device-support report.
hasBatteryChargeLimit = context?.let {
runCatching {
Settings.Global.getString(it.contentResolver, KEY_BATTERY_CHARGE_LIMIT) != null
}.getOrDefault(false)
} ?: false,
protectBatteryProbe = context?.let {
probeSetting { Settings.Global.getString(it.contentResolver, KEY_PROTECT_BATTERY) }
} ?: SettingProbe.ABSENT,
// GrapheneOS's charge-limit key. NOT a gate: GrapheneOS marks the key @Protected, so this
// unprivileged read is refused on real GrapheneOS whether the key exists or not — verified via
// the issue-#49 beta report, which is exactly why the outcome is tri-state and not a Boolean.
// Kept as a diagnostic signal only: PRESENT would indicate a ROM exposing a same-named key
// WITHOUT the GrapheneOS restriction, which is worth seeing in a device-support report.
batteryChargeLimitProbe = context?.let {
probeSetting { Settings.Global.getString(it.contentResolver, KEY_BATTERY_CHARGE_LIMIT) }
} ?: SettingProbe.ABSENT,
// NOTE: the Oplus charge-protection keys are deliberately NOT probed here. They gate nothing, and
// this snapshot is rebuilt on every dashboard refresh — a diagnostics-only key belongs on the
// report's IO path (see OplusKeyProbes), not in two extra synchronous binder calls per refresh.
// Whether LineageOS's private settings provider is installed (the charge-control settings
// surface). Fail closed; requires the <queries> provider entry so package visibility on
// API 30+ doesn't false-negative resolution. Provider presence is not HAL-enforcement proof.
Expand Down
57 changes: 57 additions & 0 deletions app/src/main/java/eu/darken/amply/charging/core/SettingProbe.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package eu.darken.amply.charging.core

/**
* Outcome of an unprivileged presence probe for a single settings key.
*
* A Boolean cannot carry this. The public `Settings.*.getString` getters throw [SecurityException] for keys the
* caller may not read — GrapheneOS marks its charge-limit key `@Protected` and denies it to every third-party
* package, and from API 31 the platform denies non-`@Readable` keys to apps generally — so folding the throw into
* `false` claims "this OEM has no such setting" when the truth is "we were not allowed to look".
*
* That misread a real device: the issue-#49 GrapheneOS report carried `has_battery_charge_limit=false` in the same
* submission that showed the limit actively enforcing. Reports are the only evidence available for devices nobody
* owns, so an absent-vs-denied ambiguity there costs a qualification lead.
*/
enum class SettingProbe {
/** The key exists and its value was read back. */
PRESENT,

/**
* No value came back. Usually means the key does not exist on this build, but it is not proof: the platform
* also returns null when it cannot reach the settings provider, and this state additionally absorbs any
* non-security failure of the read itself. Treat it as "nothing found", not as a demonstrated negative.
*/
ABSENT,

/** The read was refused. Says nothing either way about whether the key exists. */
READ_DENIED,
;

/**
* Fail closed. Only an actual read-back counts as presence, so a capability gate never opens on a denied
* probe — matching the previous Boolean fields, where a refused read also read as false.
*/
val isPresent: Boolean get() = this == PRESENT

/** Stable lowercase token for the device-support report. */
val reportValue: String get() = name.lowercase()
}

/**
* Runs an unprivileged key read and classifies the outcome.
*
* Only [SecurityException] maps to [SettingProbe.READ_DENIED]; other exceptions fall to [SettingProbe.ABSENT], which
* keeps the fail-closed behaviour of the `runCatching { … }.getOrDefault(false)` call sites this replaced. They are
* not split into a fourth "read failed" state because that state could not be trusted anyway: the platform swallows
* provider-acquisition and `RemoteException` failures internally and simply returns null, so an operational failure
* frequently never surfaces as an exception here at all.
*
* Unlike `runCatching`, this catches [Exception] rather than [Throwable], so an [Error] propagates.
*/
internal inline fun probeSetting(read: () -> String?): SettingProbe = try {
if (read() != null) SettingProbe.PRESENT else SettingProbe.ABSENT
} catch (_: SecurityException) {
SettingProbe.READ_DENIED
} catch (_: Exception) {
SettingProbe.ABSENT
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,13 @@ class SamsungLabAdapter @Inject constructor() : DisabledLabAdapter() {
override val id = "samsung-lab"
override val displayName = R.string.adapter_name_samsung.toCaString()
override fun matches(device: DeviceInfo) = device.manufacturer.equals("Samsung", ignoreCase = true)

companion object {
val CANDIDATE_KEYS = setOf("protect_battery", "battery_protection_threshold")
}
}

@Singleton
class XiaomiLabAdapter @Inject constructor() : DisabledLabAdapter() {
override val id = "xiaomi-lab"
override val displayName = R.string.adapter_name_xiaomi.toCaString()
override fun matches(device: DeviceInfo) = device.manufacturer.equals("Xiaomi", ignoreCase = true)

companion object {
val CANDIDATE_KEYS = setOf("security_pc_secure_protect_mode_key")
}
}

@Singleton
Expand All @@ -82,14 +74,6 @@ class LineageLabAdapter @Inject constructor() : DisabledLabAdapter() {
// observation of which provider LineageOS bound — useful triage context, but it decides nothing: no value
// qualifies or disqualifies a device, only physical charging observation does.
override val guidedCaptureUseful = false

companion object {
val CANDIDATE_KEYS = setOf(
"charging_control_enabled",
"charging_control_mode",
"charging_control_charging_limit",
)
}
}

@Singleton
Expand All @@ -104,11 +88,4 @@ class OnePlusLabAdapter @Inject constructor() : DisabledLabAdapter() {
device.manufacturer.equals("OnePlus", ignoreCase = true) ||
device.manufacturer.equals("Oppo", ignoreCase = true) ||
device.manufacturer.equals("realme", ignoreCase = true)

companion object {
val CANDIDATE_KEYS = setOf(
"regular_charge_protection_switch_state",
"smart_charge_protection_switch_state",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ object ContributionAllowlist {
// Samsung battery protection
SettingId(SettingNamespace.GLOBAL, "protect_battery") to setOf("0", "1", "3"),
SettingId(SettingNamespace.GLOBAL, "battery_protection_threshold") to setOf("80", "85", "90", "95"),
// OnePlus/Oppo candidate
// OnePlus/Oppo candidates. Mutually exclusive on ColorOS: regular = FixedLimit(80), smart = Adaptive.
// Both are listed, or a wizard run discloses one half of the pair and redacts the other, which reads as
// a device that only has the fixed cap.
SettingId(SettingNamespace.SYSTEM, "regular_charge_protection_switch_state") to setOf("0", "1"),
SettingId(SettingNamespace.SYSTEM, "smart_charge_protection_switch_state") to setOf("0", "1"),
)

/** Public value domain for a known charging key, or null if the key is not a known charge-protection setting. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,59 @@
package eu.darken.amply.main.core

import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.amply.BuildConfig
import eu.darken.amply.charging.core.DeviceInfo
import eu.darken.amply.charging.core.SettingProbe
import eu.darken.amply.charging.core.probeSetting
import eu.darken.amply.charging.core.access.LineageHealthSummary
import eu.darken.amply.charging.core.access.SettingsSnapshotSource
import eu.darken.amply.charging.core.adapter.AdapterRegistry
import eu.darken.amply.charging.core.adapter.OnePlusChargingAdapter
import eu.darken.amply.common.AmplyLinks
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton

/**
* Presence of the two mutually-exclusive Oplus (OnePlus/Oppo/Realme) charge-protection keys.
*
* Diagnostics only — live Oplus control is gated on the ColorOS 15 ROM version and never consults these. They are
* collected because that ROM property is otherwise the *sole* Oplus signal in a report, and it reads "none" on every
* pre-rebrand build, so a report from an unqualified Oppo/OnePlus/Realme device carries nothing at all about the
* family it just matched. Observed on an Oppo F11 Pro (CPH1969, ColorOS 11), whose report could not distinguish
* "this ROM has no charge protection" from "we never looked".
*
* Deliberately not part of [eu.darken.amply.charging.core.DeviceInfo]: that snapshot is rebuilt on every dashboard
* refresh from the main thread, and these are two synchronous provider calls that gate nothing.
*/
data class OplusKeyProbes(
val regular: SettingProbe,
val smart: SettingProbe,
) {
companion object {
val UNPROBED = OplusKeyProbes(SettingProbe.ABSENT, SettingProbe.ABSENT)

/** Call from an IO context. Reads presence only — never a value. */
fun read(resolver: ContentResolver) = OplusKeyProbes(
regular = probeSetting {
Settings.System.getString(resolver, OnePlusChargingAdapter.KEY_REGULAR)
},
smart = probeSetting {
Settings.System.getString(resolver, OnePlusChargingAdapter.KEY_SMART)
},
)
}
}

/**
* Immutable snapshot of best-effort, non-privileged device metadata used to ask the developer to add
* charge-control support for an OEM. Deliberately carries no OEM charging-setting values — those come
Expand Down Expand Up @@ -45,8 +81,14 @@ data class DeviceSupportReport(
* be said about HAL limit support. Null when unknown (not LineageOS, or no Shizuku) — never read as a negative.
*/
val lineageHealth: LineageHealthSummary?,
val hasProtectBattery: Boolean,
val hasBatteryChargeLimit: Boolean,
/**
* Unprivileged key-presence probes. Tri-state on purpose: a refused read is not evidence of absence, and
* these reports are frequently the only evidence available for a device nobody owns.
*/
val protectBatteryProbe: SettingProbe,
val batteryChargeLimitProbe: SettingProbe,
val oplusKeys: OplusKeyProbes,
/** Provider resolution, not a settings read — it cannot be refused, so it stays a Boolean. */
val hasLineageSettingsProvider: Boolean,
val adapterId: String?,
val adapterMatched: Boolean,
Expand Down Expand Up @@ -93,8 +135,9 @@ class DeviceSupportReporter @Inject constructor(
isLineageOs = device.isLineageOs,
isGrapheneOs = device.isGrapheneOs,
lineageHealth = lineageHealth,
hasProtectBattery = device.hasProtectBattery,
hasBatteryChargeLimit = device.hasBatteryChargeLimit,
protectBatteryProbe = device.protectBatteryProbe,
batteryChargeLimitProbe = device.batteryChargeLimitProbe,
oplusKeys = OplusKeyProbes.read(context.contentResolver),
hasLineageSettingsProvider = device.hasLineageSettingsProvider,
adapterId = selection.adapter?.id,
adapterMatched = selection.support.matched,
Expand Down Expand Up @@ -127,7 +170,7 @@ internal fun sanitizeReportValue(value: String?, max: Int = 120): String {
/** Deterministic, single stable schema. Keep field order fixed so reports are diff-friendly. */
internal fun formatReport(report: DeviceSupportReport): String = buildString {
appendLine("Amply device-support request")
appendLine("report_schema=9")
appendLine("report_schema=10")
appendLine("app_version=${report.appVersionName} (${report.appVersionCode})")
appendLine("distribution=${report.flavor}/${report.buildType}")
appendLine("manufacturer=${report.manufacturer}")
Expand Down Expand Up @@ -157,8 +200,15 @@ internal fun formatReport(report: DeviceSupportReport): String = buildString {
appendLine("lineage_cc_provider=${report.lineageHealth?.provider?.name ?: "unknown"}")
appendLine("lineage_cc_mode=${report.lineageHealth?.mode ?: "unknown"}")
appendLine("lineage_cc_limit_mechanism=${report.lineageHealth?.limitMechanism?.name ?: "UNKNOWN"}")
appendLine("has_protect_battery=${report.hasProtectBattery}")
appendLine("has_battery_charge_limit=${report.hasBatteryChargeLimit}")
// present|absent|read_denied. "read_denied" means the platform refused the read, so the key may well exist —
// never read it as evidence the OEM lacks the setting. "absent" means nothing came back, which is usually but
// not provably a real negative (see SettingProbe).
appendLine("probe_protect_battery=${report.protectBatteryProbe.reportValue}")
appendLine("probe_battery_charge_limit=${report.batteryChargeLimitProbe.reportValue}")
// The Oplus pair. Live control is gated on the ColorOS 15 ROM version, so these decide nothing; they exist so
// a report from an unqualified Oppo/OnePlus/Realme build says something about the family at all.
appendLine("probe_regular_charge_protection=${report.oplusKeys.regular.reportValue}")
appendLine("probe_smart_charge_protection=${report.oplusKeys.smart.reportValue}")
appendLine("has_lineage_settings_provider=${report.hasLineageSettingsProvider}")
appendLine("adapter=${report.adapterId ?: "none"}")
appendLine("adapter_matched=${report.adapterMatched}")
Expand Down
Loading