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
20 changes: 13 additions & 7 deletions app/src/main/java/eu/darken/amply/stats/core/BootIdSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import javax.inject.Singleton
/**
* Reads the device boot count, used to tell a same-boot process restart (resume the open session)
* from a reboot (seal it — elapsed-time continuity can't survive a power-off). The value can only
* change across a reboot, which restarts this process, so it is read once and cached. A device that
* doesn't report [Settings.Global.BOOT_COUNT] yields a constant sentinel; reboots then look like
* same-boot restarts, which degrades to a partial/interrupted seal rather than anything unsafe.
* change across a reboot, which restarts this process, so it is read once and cached.
*
* A device that doesn't report [Settings.Global.BOOT_COUNT] yields the [UNAVAILABLE] sentinel, which
* two different boots would compare *equal* on. So the sentinel is not merely a degraded id — it is
* explicitly disqualifying: [StatsSessionEngine.evaluateResume] refuses to resume an open session
* when either side is [UNAVAILABLE], because a resume across an unnoticed reboot would splice two
* boots' [android.os.SystemClock.elapsedRealtime] readings into one bogus duration. Such a device
* always falls back to sealing, which is lossy but never wrong.
*/
@Singleton
class BootIdSource @Inject constructor(
Expand All @@ -22,10 +27,11 @@ class BootIdSource @Inject constructor(
fun current(): Long = cached

private fun read(): Long = runCatching {
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, UNAVAILABLE).toLong()
}.getOrDefault(UNAVAILABLE.toLong())
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, UNAVAILABLE.toInt()).toLong()
}.getOrDefault(UNAVAILABLE)

private companion object {
const val UNAVAILABLE = -1
companion object {
/** Boot identity could not be determined — never usable to prove two observations share a boot. */
const val UNAVAILABLE = -1L
}
}
87 changes: 69 additions & 18 deletions app/src/main/java/eu/darken/amply/stats/core/ChargeStatsRecorder.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eu.darken.amply.stats.core

import android.os.BatteryManager
import android.os.SystemClock
import androidx.room.withTransaction
import dagger.Lazy
import eu.darken.amply.battery.core.BatteryReadout
Expand All @@ -10,10 +11,11 @@ import eu.darken.amply.common.debug.logging.log
import eu.darken.amply.common.debug.logging.logTag
import eu.darken.amply.stats.core.db.BatterySampleEntity
import eu.darken.amply.stats.core.db.ChargeSessionEntity
import eu.darken.amply.stats.core.db.StatsDao
import eu.darken.amply.stats.core.db.StatsDatabase
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -41,8 +43,9 @@ class ChargeStatsRecorder @Inject constructor(
private val preferences: StatsPreferences,
private val bootIdSource: BootIdSource,
private val batteryReader: BatteryReader,
@StatsDispatcher dispatcher: CoroutineDispatcher,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val commands = Channel<Command>(Channel.UNLIMITED)

// Mutated only by the single consumer coroutine below — no locking needed.
Expand All @@ -54,8 +57,8 @@ class ChargeStatsRecorder @Inject constructor(

init {
scope.launch {
// Runs before any command: seals sessions left open by an unclean shutdown, and seeds the
// capturing flag from the durable preference.
// Runs before any command: reconciles sessions left open by an unclean shutdown, and seeds
// the capturing flag from the durable preference.
startupRepair()
for (command in commands) {
try {
Expand Down Expand Up @@ -92,10 +95,12 @@ class ChargeStatsRecorder @Inject constructor(
try {
capturing = preferences.isCaptureEnabledNow()
// Only touch the DB when we might have data — avoids creating an empty stats.db for users
// who never enabled statistics. A dangling open row can only exist after prior recording,
// which always stamps lastCapture.
if (preferences.lastCaptureWallMillis.first() == null) return
sealDanglingSessions()
// who never enabled statistics. The lastCapture stamp alone is not a sound existence test:
// it is written after the row is committed, so a process death in between leaves an open
// row with no stamp. Capture being enabled is therefore also sufficient — such a user gets
// a stats.db on the next tick anyway, so opening it here costs nothing.
if (!capturing && preferences.lastCaptureWallMillis.first() == null) return
reconcileDanglingSessions()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Expand Down Expand Up @@ -243,24 +248,70 @@ class ChargeStatsRecorder @Inject constructor(
}

/**
* Seal every session left open by an unclean shutdown (process kill / reboot). Never resumes:
* losing the monitor mid-charge breaks curve/elapsed continuity, so the session is closed
* honestly and the next plug opens a fresh one. This also sidesteps having to trust the boot
* count — a resumed session would risk a negative post-reboot duration.
* Reconcile sessions left open by an unclean shutdown (process kill / reboot). The newest row is
* offered to [StatsSessionEngine.evaluateResume] against the battery state observed right now; if
* the same plug event is still underway it is reattached, keeping the true plug-in time and one
* history row per physical charge. Everything else is sealed as before.
*
* The probe happens here rather than on the first watcher tick on purpose. A held-open row would
* be rendered as a live session by the dashboard for as long as no tick arrives — which also
* suppresses the "couldn't start capture" retry when the foreground service fails to start — and
* would need leak guards on the disable/clear paths. Resolving synchronously costs one sticky
* broadcast read and leaves [onSample] untouched.
*/
private suspend fun sealDanglingSessions() {
private suspend fun reconcileDanglingSessions() {
val dao = database.get().statsDao()
val open = dao.openSessions()
if (open.isEmpty()) return
val currentBoot = bootIdSource.current()
open.forEach { row ->
val sealed = sealFromLastKnown(row, currentBoot)
// Drop a dangling row that never captured anything (same rule as a live seal).
if (StatsSessionEngine.isDiscardable(sealed)) dao.deleteSession(sealed.id) else dao.updateSession(sealed)
}
// Capture is off, so no tick is coming to advance a resumed row — seal everything, as before.
// Ordered by id (monotonic), matching the row `openSessionFlow` shows as live; `openSessions`
// orders by elapsed-realtime, which disagrees across a reboot.
val candidate = if (capturing) open.maxByOrNull { it.id } else null
val resumed = candidate?.let { resumeOrNull(it, currentBoot) }
open.filter { it.id != resumed?.id }.forEach { row -> sealDangling(dao, row, currentBoot) }
open.maxOfOrNull { it.runningLastWallMillis ?: it.startedAtWallMillis }?.let { purgeOldSamples(it) }
}

/**
* Reattach [row] if the charge it recorded is still running, else null. In-memory state is
* installed only *after* the durable write returns: command failures are logged and swallowed
* (see the loop above), and a remembered-but-unwritten session would let a later tick open a
* second row against a row the database still has open.
*/
private suspend fun resumeOrNull(row: ChargeSessionEntity, currentBoot: Long): ChargeSessionEntity? {
val readout = batteryReader.read()
val probe = ResumeProbe(
elapsedRealtimeMillis = SystemClock.elapsedRealtime(),
bootId = currentBoot,
plugged = (readout.plugged ?: 0) != 0,
percent = readout.levelPercent,
)
return when (val decision = StatsSessionEngine.evaluateResume(row, probe)) {
is ResumeDecision.Reject -> {
log(TAG) { "Not resuming session ${row.id}: ${decision.reason}" }
null
}

is ResumeDecision.Resume -> {
database.get().statsDao().updateSession(decision.session)
openSession = decision.session
// Continue the existing cadence instead of restarting it, so the resumed session
// doesn't force an extra curve point on top of the one it already has.
lastRecordedElapsed = decision.session.runningLastElapsedRealtimeMillis
lastRecordedPercent = decision.session.runningLastPercent
log(TAG, Logging.Priority.INFO) { "Resumed charge session ${decision.session.id}" }
decision.session
}
}
}

private suspend fun sealDangling(dao: StatsDao, row: ChargeSessionEntity, currentBoot: Long) {
val sealed = sealFromLastKnown(row, currentBoot)
// Drop a dangling row that never captured anything (same rule as a live seal).
if (StatsSessionEngine.isDiscardable(sealed)) dao.deleteSession(sealed.id) else dao.updateSession(sealed)
}

private fun sealFromLastKnown(row: ChargeSessionEntity, currentBoot: Long): ChargeSessionEntity {
val reason = if (row.bootId != currentBoot) StatsSealReason.REBOOT else StatsSealReason.INTERRUPTED
return StatsSessionEngine.seal(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,17 @@ class ChargeStatsRepository @Inject constructor(
* The in-progress charge session of the current boot, or null when nothing is open, as a live flow
* for the dashboard card. The curve is a bounded recent window (decimated) so a session that stays
* open for days at an OEM charge limit never triggers an unbounded reload on every appended sample.
* `distinctUntilChangedBy(id)` keeps the inner sample flow subscribed across the per-tick session-row
* updates (the open row's start fields are immutable, so only a change of session identity matters),
* avoiding a redundant curve re-query on every fold.
* `distinctUntilChangedBy` keeps the inner sample flow subscribed across the per-tick session-row
* updates, avoiding a redundant curve re-query on every fold. It keys on identity **plus every row
* field this flow actually renders**: `partial` flips when a session is resumed after a process
* restart, and since the row is captured inside `flatMapLatest`, an id-only key would pin the stale
* copy for the lifetime of the subscription. The remaining projected fields (the start columns) are
* immutable once the row exists.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun currentSession(): Flow<StatsLiveSession?> =
database.get().statsDao().openSessionFlow(bootIdSource.current())
.distinctUntilChangedBy { it?.id }
.distinctUntilChangedBy { row -> row?.let { it.id to it.partial } }
.flatMapLatest { row ->
if (row == null) {
flowOf(null)
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/eu/darken/amply/stats/core/StatsModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package eu.darken.amply.stats.core

import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.amply.monitor.core.ChargeMonitorWatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier

/**
* Contributes [ChargeStatsWatcher] into the shared [Set] of [ChargeMonitorWatcher]s the
Expand All @@ -18,4 +22,20 @@ abstract class StatsModule {
@Binds
@IntoSet
abstract fun bindStatsWatcher(impl: ChargeStatsWatcher): ChargeMonitorWatcher

companion object {
@Provides
@StatsDispatcher
fun statsDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
}

/**
* The dispatcher [ChargeStatsRecorder] runs its serialized command loop on. Injected rather than
* hardcoded purely so tests can substitute a deterministic scheduler; production is always
* [Dispatchers.IO]. Qualified (and scoped to the stats feature) so this doesn't become an
* unqualified app-wide `CoroutineDispatcher` binding.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StatsDispatcher
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ enum class StatsSealReason {
/** Charger removed — the normal, complete end of a session. */
UNPLUGGED,

/** Reopened an open session after a process restart within the same boot; curve has a gap. */
/**
* Found open after a process restart and *not* resumable — the charge it recorded could not be
* shown to still be running (unplugged since, level dropped, unknown boot). A restart that is
* consistent with the same plug event reattaches the row instead of sealing it, so this reason
* means the evidence failed, not merely that the process died.
*/
INTERRUPTED,

/** Open session found after a reboot; elapsed-time continuity across power-off can't be trusted. */
Expand Down
88 changes: 88 additions & 0 deletions app/src/main/java/eu/darken/amply/stats/core/StatsSessionEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,49 @@ sealed interface StatsTransition {
data object Ignore : StatsTransition
}

/**
* Current battery state observed at process start, used to reconcile a session left open by a
* process death. Deliberately *not* a [StatsSample]: that type's contract is "built from the exact
* intent the charge-session service evaluated", while this comes from an independent sticky read
* before any service tick exists.
*/
data class ResumeProbe(
val elapsedRealtimeMillis: Long,
val bootId: Long,
val plugged: Boolean,
val percent: Int?,
)

/** Outcome of reconciling a dangling open session against a [ResumeProbe]. */
sealed interface ResumeDecision {

/** Reattach [session] — the same plug event is still in progress, as far as can be told. */
data class Resume(val session: ChargeSessionEntity) : ResumeDecision

/** Seal the row instead; [reason] is for diagnostics only. */
data class Reject(val reason: Reason) : ResumeDecision

enum class Reason {
/** Row is already sealed — nothing to reconcile. */
CLOSED,

/** No external power now, so whatever charge was underway has ended. */
UNPLUGGED,

/** Boot identity is unknown on either side, so a same-boot claim can't be made. */
BOOT_UNKNOWN,

/** A reboot happened; elapsed-realtime readings from two boots can't be compared. */
BOOT_MISMATCH,

/** Probe predates the row's last sample — the clock base can't be the one we recorded against. */
TIME_WENT_BACKWARDS,

/** Charge level fell while we weren't looking, so the device discharged in the gap. */
LEVEL_DROPPED,
}
}

/**
* Pure charge-session segmentation and online aggregation. Holds no state itself: the open session
* is a [ChargeSessionEntity] the recorder loads from (and persists to) Room, so every decision
Expand Down Expand Up @@ -69,6 +112,51 @@ object StatsSessionEngine {
return zeroDuration && noLevelGain && sealed.runningSampleCount <= 1
}

/**
* Reconcile a session left open by a process death against the battery state observed at the next
* process start. Resuming keeps the real plug-in time and one history row for one physical charge;
* the alternative (always sealing) restarts the session at process-launch time, which reads as a
* wrong "Since …" and splits one charge into two rows.
*
* Continuity here is **inferred, not observed** — nothing survived the gap to witness it. A replug
* at an equal-or-higher level is indistinguishable from an uninterrupted plug, and a level *drop*
* while plugged is possible (heavy load, weak charger, thermal throttling, an OEM hold). So these
* guards are best-effort and deliberately biased toward merging: an occasional merged plug cycle
* beats fragmenting real sessions. Two things keep that bias honest, both applied on [Resume]:
* the row is flagged [ChargeSessionEntity.partial], and the last power/temperature readings are
* dropped so [fold] credits **nothing** for the unobserved gap (see the null handling in
* [creditInterval]). A wrong merge therefore costs an over-long duration — never invented
* power/temperature averages.
*/
fun evaluateResume(row: ChargeSessionEntity, probe: ResumeProbe): ResumeDecision {
val lastObserved = row.runningLastElapsedRealtimeMillis ?: row.startedElapsedRealtimeMillis
val reason = when {
row.endedAtWallMillis != null -> ResumeDecision.Reason.CLOSED
!probe.plugged -> ResumeDecision.Reason.UNPLUGGED
// Checked before equality: the sentinel compares equal to itself across *different* boots.
probe.bootId == BootIdSource.UNAVAILABLE ||
row.bootId == BootIdSource.UNAVAILABLE -> ResumeDecision.Reason.BOOT_UNKNOWN
probe.bootId != row.bootId -> ResumeDecision.Reason.BOOT_MISMATCH
probe.elapsedRealtimeMillis < lastObserved -> ResumeDecision.Reason.TIME_WENT_BACKWARDS
droppedLevel(row.runningLastPercent, probe.percent) -> ResumeDecision.Reason.LEVEL_DROPPED
else -> null
}
if (reason != null) return ResumeDecision.Reject(reason)
return ResumeDecision.Resume(
row.copy(
// The curve has a hole and the continuity is inferred — never present this as a clean
// plug→unplug history.
partial = true,
runningLastPowerMilliwatts = null,
runningLastTemperatureTenthsC = null,
),
)
}

/** True only when both readings exist and the level fell — a missing reading is not evidence. */
private fun droppedLevel(lastPercent: Int?, probePercent: Int?): Boolean =
lastPercent != null && probePercent != null && probePercent < lastPercent

fun open(sample: StatsSample, partial: Boolean): ChargeSessionEntity = ChargeSessionEntity(
startedAtWallMillis = sample.wallMillis,
startedElapsedRealtimeMillis = sample.elapsedRealtimeMillis,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ data class ChargeSessionEntity(

/**
* True when the session does not represent a clean plug→unplug: capture was enabled mid-charge,
* started already at 100%, or the session was sealed by process recovery / reboot. The UI labels
* these as partial rather than presenting them as complete histories.
* started already at 100%, the session was sealed by process recovery / reboot, or it was resumed
* after a process restart (start time and totals are real, but the curve has a gap and continuity
* across that gap is inferred rather than observed). The UI labels these as partial rather than
* presenting them as complete histories.
*/
val partial: Boolean = false,

Expand Down
Loading
Loading