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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import com.almothafar.simplebatterynotifier.R;
import com.almothafar.simplebatterynotifier.model.BatteryDO;
import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.BatteryRate;
import com.almothafar.simplebatterynotifier.service.SustainedConditionTracker.Outcome;
import com.almothafar.simplebatterynotifier.service.SustainedConditionTracker.Streak;
import com.almothafar.simplebatterynotifier.service.SustainedConditionTracker.StreakStore;

import static java.util.Objects.isNull;

Expand All @@ -28,12 +31,16 @@
*/
public final class FastDrainDetector {

// Persisted streak/hysteresis state (survives process restarts).
// Persisted streak/hysteresis state (survives process restarts). Keys unchanged from before the
// shared-core extraction (#163) so no in-progress episode is lost on upgrade.
private static final String PREF_STREAK_START = "_fast_drain_streak_start";
private static final String PREF_ALERTED = "_fast_drain_alerted";
private static final String PREF_LAST_REMINDER = "_fast_drain_last_reminder";
private static final String PREF_LAST_SEEN_ABOVE = "_fast_drain_last_seen_above";

private static final StreakStore STORE =
new StreakStore(PREF_STREAK_START, PREF_ALERTED, PREF_LAST_SEEN_ABOVE, PREF_LAST_REMINDER);

// Defaults and accepted ranges (user-tunable), matching the settings XML min/max — enforced when the
// preferences are read, so a corrupt/out-of-range value can't turn this into a spike alarm.
static final int DEFAULT_SUSTAINED_MINUTES = 5;
Expand All @@ -43,16 +50,8 @@ public final class FastDrainDetector {
static final int MIN_REMINDER_MINUTES = 5;
static final int MAX_REMINDER_MINUTES = 60;

// How long the streak survives without a fresh above-limit observation. The rate itself is smoothed
// over BatteryRateTracker.WINDOW_MS, so continuity beyond that window is unknowable — after a longer
// gap (process death, doze, unusable rate) the "sustained" claim has lapsed and the episode restarts,
// rather than instantly alerting with a wildly inflated "for the last N minutes".
static final long MAX_OBSERVATION_GAP_MS = BatteryRateTracker.WINDOW_MS;

private static final long MS_PER_MINUTE = 60_000L;

private static final FastDrainState CLEARED = new FastDrainState(0, false, 0, 0);

private FastDrainDetector() {
// Utility class - prevent instantiation
}
Expand All @@ -66,7 +65,7 @@ private FastDrainDetector() {
* @param batteryDO Current battery snapshot (may be null)
* @param rate The rate just computed by {@link BatteryRateTracker#record}
*/
public static void evaluate(final Context context, final BatteryDO batteryDO, final BatteryRate rate) {
public static void evaluate(Context context, BatteryDO batteryDO, BatteryRate rate) {
if (isNull(context) || isNull(batteryDO)) {
return;
}
Expand All @@ -77,7 +76,7 @@ public static void evaluate(final Context context, final BatteryDO batteryDO, fi
// Only while discharging, and only when enabled. Either way the episode is re-armed (charging, or
// the feature being off, ends any streak) so a later fast discharge starts fresh.
if (!enabled || !discharging) {
clearState(prefs);
STORE.clear(prefs);
return;
}

Expand All @@ -89,38 +88,26 @@ public static void evaluate(final Context context, final BatteryDO batteryDO, fi
final boolean activelyUsed = SystemService.isActivelyUsed(context);
final long now = System.currentTimeMillis();

final FastDrainState previous = loadState(prefs);
final FastDrainDecision decision = decide(previous, rate.hasRate(), rate.percentPerHour(),
final Streak previous = STORE.load(prefs);
final Outcome decision = decide(previous, rate.hasRate(), rate.percentPerHour(),
limit, sustainedMs, reminderGapMs, activelyUsed, now);

// Persist only on change: the common case (discharging below the limit) re-decides CLEARED on
// every broadcast, and rewriting identical state would churn SharedPreferences for nothing.
if (!decision.newState().equals(previous)) {
saveState(prefs, decision.newState());
}
STORE.saveIfChanged(prefs, decision.newState());
if (decision.shouldNotify()) {
final int elapsedMinutes = Math.max(1, Math.round(decision.elapsedMs() / (float) MS_PER_MINUTE));
NotificationService.sendFastDrainNotification(context, rate.percentPerHour(), limit, elapsedMinutes);
}
}

/**
* Pure decision core, unit-testable with no Android dependencies.
* <ul>
* <li><b>Rate unavailable</b> (#108 can't produce a %/h yet — warm-up, or an unsupported device):
* the alert <em>sleeps</em>. No notification, and the streak is left intact so a brief data gap
* doesn't reset it. Fail-quiet = no false alarms. Continuity is bounded, though: if the rate
* hasn't been observed at/above the limit for {@link #MAX_OBSERVATION_GAP_MS}, the streak has
* lapsed and the next above-limit reading starts a fresh episode instead of instantly alerting
* with a duration built on unobserved time.</li>
* <li><b>Rate below the limit</b>: the drain has calmed → re-arm the episode (clear the streak and
* the alerted flag), so a later flare-up warns again (hysteresis).</li>
* <li><b>Rate at/above the limit</b>: start the streak if new; once it has been sustained for the
* window, fire the first alert (regardless of screen state — warn at least once). After that,
* stay silent while actively used, but remind every {@code reminderGap} while off/locked.</li>
* </ul>
* The fast-drain decision as a pure function of the drain rate, delegating the streak/lapse/hysteresis
* mechanics to {@link SustainedConditionTracker} (#163). The condition is "rate at/above the limit";
* the repeat policy is {@link SustainedConditionTracker#withReminders} — the first alert fires
* regardless of screen state, then reminders repeat only while the screen is off/locked (background
* drain the user can't see). Kept as a rate-oriented method so the behaviour contract stays unit-tested
* against the domain inputs.
*
* @param state current persisted state
* @param state current persisted streak
* @param rateAvailable whether #108 produced a trustworthy %/h this tick
* @param ratePph the drain rate magnitude in %/h (valid when {@code rateAvailable})
* @param limitPph the user's high-drain limit in %/h
Expand All @@ -129,45 +116,28 @@ public static void evaluate(final Context context, final BatteryDO batteryDO, fi
* @param activelyUsed whether the screen is on and unlocked right now
* @param nowMillis current time in millis
*
* @return the notify flag, the new state to persist, and the streak's elapsed time (for the message)
* @return the notify flag, the new streak to persist, and the streak's elapsed time (for the message)
*/
static FastDrainDecision decide(final FastDrainState state, final boolean rateAvailable, final int ratePph,
final int limitPph, final long sustainedMs, final long reminderGapMs,
final boolean activelyUsed, final long nowMillis) {
if (!rateAvailable) {
return new FastDrainDecision(false, state, 0); // sleep — keep the streak, don't fire
}
if (ratePph < limitPph) {
return new FastDrainDecision(false, CLEARED, 0); // calmed — re-arm the episode
}

// Continuity: the sleep above keeps the streak through a brief data gap, but after a long one
// (process death, doze) the "sustained" claim has lapsed — start a fresh episode instead of
// alerting immediately with an inflated duration built on unobserved time.
final boolean lapsed = state.streakStart() != 0
&& nowMillis - state.lastSeenAbove() > MAX_OBSERVATION_GAP_MS;

final long start = (state.streakStart() == 0 || lapsed) ? nowMillis : state.streakStart();
final long elapsed = nowMillis - start;
boolean alerted = !lapsed && state.alerted();
long lastReminder = lapsed ? 0 : state.lastReminder();
boolean notify = false;

if (elapsed >= sustainedMs) {
if (!alerted) {
notify = true; // first alert this episode, regardless of screen state
alerted = true;
lastReminder = nowMillis;
} else if (!activelyUsed && nowMillis - lastReminder >= reminderGapMs) {
notify = true; // background drain the user can't see — remind on the gap
lastReminder = nowMillis;
}
}
return new FastDrainDecision(notify, new FastDrainState(start, alerted, lastReminder, nowMillis), elapsed);
static Outcome decide(Streak state,
boolean rateAvailable,
int ratePph,
int limitPph,
long sustainedMs,
long reminderGapMs,
boolean activelyUsed,
long nowMillis) {
final boolean conditionActive = ratePph >= limitPph;
final SustainedConditionTracker.RepeatPolicy policy =
SustainedConditionTracker.withReminders(activelyUsed, reminderGapMs);
return SustainedConditionTracker.decide(state, rateAvailable, conditionActive, sustainedMs, nowMillis, policy);
}

private static long minutesPref(final SharedPreferences prefs, final Context context, final int keyRes,
final int defaultMinutes, final int minMinutes, final int maxMinutes) {
private static long minutesPref(SharedPreferences prefs,
Context context,
int keyRes,
int defaultMinutes,
int minMinutes,
int maxMinutes) {
return clampMinutesToMs(prefs.getInt(context.getString(keyRes), defaultMinutes), minMinutes, maxMinutes);
}

Expand All @@ -183,59 +153,7 @@ private static long minutesPref(final SharedPreferences prefs, final Context con
*
* @return the clamped duration in milliseconds
*/
static long clampMinutesToMs(final int storedMinutes, final int minMinutes, final int maxMinutes) {
static long clampMinutesToMs(int storedMinutes, int minMinutes, int maxMinutes) {
return Math.max(minMinutes, Math.min(maxMinutes, storedMinutes)) * MS_PER_MINUTE;
}

private static FastDrainState loadState(final SharedPreferences prefs) {
return new FastDrainState(
prefs.getLong(PREF_STREAK_START, 0),
prefs.getBoolean(PREF_ALERTED, false),
prefs.getLong(PREF_LAST_REMINDER, 0),
prefs.getLong(PREF_LAST_SEEN_ABOVE, 0));
}

private static void saveState(final SharedPreferences prefs, final FastDrainState state) {
prefs.edit()
.putLong(PREF_STREAK_START, state.streakStart())
.putBoolean(PREF_ALERTED, state.alerted())
.putLong(PREF_LAST_REMINDER, state.lastReminder())
.putLong(PREF_LAST_SEEN_ABOVE, state.lastSeenAbove())
.apply();
}

/**
* Clears the streak only when it isn't already clear, so charging/disabled broadcasts don't churn
* SharedPreferences on every tick.
*
* @param prefs the shared preferences
*/
private static void clearState(final SharedPreferences prefs) {
if (!loadState(prefs).equals(CLEARED)) {
saveState(prefs, CLEARED);
}
}

/**
* Persisted streak/hysteresis state.
*
* @param streakStart when the rate first reached the limit this episode (0 = no active streak)
* @param alerted whether the first alert has fired this episode
* @param lastReminder when the last (re)notification was sent
* @param lastSeenAbove when the rate was last observed at/above the limit; a gap longer than
* {@link #MAX_OBSERVATION_GAP_MS} lapses the streak (continuity unknowable)
*/
record FastDrainState(long streakStart, boolean alerted, long lastReminder, long lastSeenAbove) {
}

/**
* Result of {@link #decide}: whether to (re)notify, the new state to persist, and the streak's
* elapsed time (for the "for the last N minutes" message).
*
* @param shouldNotify whether to send a notification now
* @param newState the state to persist
* @param elapsedMs the streak's elapsed time in millis
*/
record FastDrainDecision(boolean shouldNotify, FastDrainState newState, long elapsedMs) {
}
}
Loading