From d111454c1dafce664a223eb3aa52d791687614a0 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Fri, 10 Jul 2026 22:34:55 +0300 Subject: [PATCH 1/2] Fast-drain alert: warn when battery drains abnormally fast for a sustained time (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the alerting layer on top of #108's smoothed drain rate. Needs no per-app data — it watches the whole-battery %/h — so it is buildable by a normal app. - FastDrainDetector fires when the (already smoothed) drain rate stays at/above the user's limit for a sustained window (default 5 min); a brief spike breaks the streak. The decision core is pure and unit-tested; the streak/hysteresis state is persisted so it survives process restarts (like BatteryHealthTracker). - Context-aware repeats with no new timer/alarm/wakelock (reminders piggyback the battery broadcasts): warn once per episode while the screen is on and unlocked, but remind every 15 min (default) while off/locked — the highest-value case. Re-arms only after the rate drops back below the limit, like the temperature alert's hysteresis. - Only while discharging. When #108 can't produce a %/h yet (warm-up), the alert sleeps rather than firing — fail-quiet, no false alarms. - Transparent message stating the real measured rate, how long it's been sustained, and the user's own limit. Own notification channel + id (never replaces a level/temperature alert); respects quiet-hours / silent / vibrate. - The limit is the single setting shared with #108's red colour. Adds enable + sustained-minutes + locked-reminder-gap settings. New strings drafted in Arabic. Screen/lock state read via PowerManager.isInteractive + KeyguardManager (no special permission). Co-Authored-By: Claude Opus 4.8 --- .../receiver/BatteryLevelReceiver.java | 7 +- .../service/FastDrainDetector.java | 193 ++++++++++++++++++ .../service/NotificationService.java | 65 ++++++ .../service/SystemService.java | 24 +++ app/src/main/res/values-ar/strings.xml | 14 +- app/src/main/res/values/strings.xml | 19 +- app/src/main/res/xml/pref_notification.xml | 39 +++- .../service/FastDrainDetectorTest.java | 127 ++++++++++++ 8 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java create mode 100644 app/src/test/java/com/almothafar/simplebatterynotifier/service/FastDrainDetectorTest.java diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java index 262d385..dee33cc 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java @@ -11,6 +11,7 @@ import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker; import com.almothafar.simplebatterynotifier.service.BatteryRateTracker; +import com.almothafar.simplebatterynotifier.service.FastDrainDetector; import com.almothafar.simplebatterynotifier.service.NotificationService; import com.almothafar.simplebatterynotifier.service.SystemService; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; @@ -66,7 +67,8 @@ public void onReceive(final Context context, final Intent intent) { final BatteryDO batteryDO = SystemService.getBatteryInfo(context, batteryStatus); // Feed the charge/drain rate window from this broadcast (no polling timer of our own) so both the - // ongoing notification below and the details table reflect the latest reading (issue #108). + // ongoing notification below and the details table reflect the latest reading (issue #108). The + // fast-drain alert (#109) then evaluates the same smoothed rate. final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(context, batteryDO); // Keep the persistent foreground-service status notification live with the latest reading, @@ -101,6 +103,9 @@ public void onReceive(final Context context, final Intent intent) { } handleTemperature(context, batteryDO, sharedPref); + + // #109: warn when the (smoothed #108) drain rate stays abnormally high for a sustained time. + FastDrainDetector.evaluate(context, batteryDO, rate); } /** diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java new file mode 100644 index 0000000..4bb539a --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java @@ -0,0 +1,193 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.preference.PreferenceManager; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.BatteryDO; +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.BatteryRate; + +import static java.util.Objects.isNull; + +/** + * Warns when the battery drains abnormally fast for a sustained time (issue #109). + *

+ * This is the alerting layer on top of the drain rate from {@link BatteryRateTracker} (#108). It needs + * no per-app data — it watches the whole-battery %/h — so it is fully buildable by a normal app. It is + * evaluated on each {@code ACTION_BATTERY_CHANGED} broadcast; there is no new timer/alarm/wakelock + * (reminders piggyback the broadcasts, which are frequent precisely while draining fast). + *

+ * Sustained, not spikes: the rate (already smoothed by #108) must stay at/above the user's limit + * for the whole window (default 5 min); a brief flare breaks the streak. Context-aware repeats: + * warn once per episode while the screen is on and unlocked (the user can see it), but remind every + * {@code reminderGap} while the screen is off or locked (background drain the user can't see — the + * highest-value case). Hysteresis re-arms the episode only once the rate drops back below the limit, + * exactly like the high-temperature alert. The decision core is pure and unit-tested; the streak is + * persisted so it survives process restarts, like {@link BatteryHealthTracker}. + */ +public final class FastDrainDetector { + + // Persisted streak/hysteresis state (survives process restarts). + 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"; + + // Defaults (all user-tunable), matching the settings XML. + static final int DEFAULT_SUSTAINED_MINUTES = 5; + static final int DEFAULT_REMINDER_MINUTES = 15; + + private static final long MS_PER_MINUTE = 60_000L; + + private static final FastDrainState CLEARED = new FastDrainState(0, false, 0); + + private FastDrainDetector() { + // Utility class - prevent instantiation + } + + /** + * Evaluates the fast-drain rule against the freshly-computed rate and (re)notifies when warranted. + * Called from {@link com.almothafar.simplebatterynotifier.receiver.BatteryLevelReceiver} after the + * rate is recorded, so it reads the same smoothed value the table and notification show. + * + * @param context Application context + * @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) { + if (isNull(context) || isNull(batteryDO)) { + return; + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean enabled = prefs.getBoolean(context.getString(R.string._pref_key_notify_fast_drain), true); + final boolean discharging = !BatteryRateTracker.isChargingDirection(batteryDO.getStatus()); + // 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); + return; + } + + final int limit = BatteryRateTracker.getDrainLimitPercentPerHour(context); + final long sustainedMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_sustained_minutes, DEFAULT_SUSTAINED_MINUTES); + final long reminderGapMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_reminder_minutes, DEFAULT_REMINDER_MINUTES); + final boolean activelyUsed = SystemService.isActivelyUsed(context); + final long now = System.currentTimeMillis(); + + final FastDrainDecision decision = decide(loadState(prefs), rate.hasRate(), rate.percentPerHour(), + limit, sustainedMs, reminderGapMs, activelyUsed, now); + + saveState(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. + *

+ * + * @param state current persisted state + * @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 + * @param sustainedMs how long the rate must stay at/above the limit before the first alert + * @param reminderGapMs minimum gap between reminders while the screen is off/locked + * @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) + */ + 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 + } + + final long start = state.streakStart() == 0 ? nowMillis : state.streakStart(); + final long elapsed = nowMillis - start; + boolean alerted = state.alerted(); + long lastReminder = 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), elapsed); + } + + private static long minutesPref(final SharedPreferences prefs, final Context context, + final int keyRes, final int defaultMinutes) { + return prefs.getInt(context.getString(keyRes), defaultMinutes) * 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)); + } + + 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()) + .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 + */ + record FastDrainState(long streakStart, boolean alerted, long lastReminder) { + } + + /** + * 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) { + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java index a507a9a..dcba622 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -68,6 +68,7 @@ public final class NotificationService { private static final String CHANNEL_ID_FULL = "battery_full"; private static final String CHANNEL_ID_STATUS = "battery_status"; private static final String CHANNEL_ID_TEMPERATURE = "battery_temperature"; + private static final String CHANNEL_ID_FAST_DRAIN = "battery_fast_drain"; // Silent, low-importance channel used to deliver an alert quietly during the user's quiet hours: // the alert is still visible but makes no sound or vibration (issue #111). private static final String CHANNEL_ID_ALERTS_SILENT = "battery_alerts_quiet"; @@ -79,6 +80,8 @@ public final class NotificationService { private static final int ONGOING_NOTIFICATION_ID = 1641988; // Separate ID so a temperature alert doesn't replace a battery-level alert private static final int TEMPERATURE_NOTIFICATION_ID = 1641989; + // Separate ID so a fast-drain alert doesn't replace a level or temperature alert (#109) + private static final int FAST_DRAIN_NOTIFICATION_ID = 1641990; // Do Not Disturb mode constants private static final String ZEN_MODE = "zen_mode"; @@ -364,6 +367,64 @@ public static void sendTemperatureNotification(final Context context, final int } } + /** + * Send a "battery draining fast" alert (issue #109). + *

+ * Uses a dedicated channel and notification ID so it never replaces a level or temperature alert, and + * honours the same quiet-hours / silent-mode / vibrate preferences as the other alerts. The message is + * transparent: it states the real measured rate, how long it has been sustained, and the user's own + * limit, so it explains why it fired and that the user controls it. It deliberately cannot name the + * culprit app (that needs a privileged permission — see the issue). + * + * @param context The application context + * @param ratePph The measured drain rate in %/h + * @param limitPph The user's high-drain limit in %/h + * @param elapsedMinutes How long the rate has been sustained at/above the limit, in minutes + */ + public static void sendFastDrainNotification(final Context context, final int ratePph, + final int limitPph, final int elapsedMinutes) { + if (lacksNotificationPermission(context)) { + Log.w(TAG, "Missing POST_NOTIFICATIONS permission, fast-drain alert not sent"); + return; + } + + createNotificationChannels(context); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + // A fast-drain warning is not a critical battery alert, so it respects quiet hours (#111). + final boolean withinWindow = isWithinNotificationWindow(context, prefs); + final String channelId = channelFor(withinWindow, CHANNEL_ID_FAST_DRAIN); + + // Western digits in every locale (#96) via String.valueOf. + final String rate = String.valueOf(ratePph); + final String limit = String.valueOf(limitPph); + final String minutes = String.valueOf(elapsedMinutes); + + final Notification.Builder builder = new Notification.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_stat_device_battery_charging_20) + .setTicker(context.getString(R.string.notification_fast_drain_ticker)) + .setContentTitle(context.getString(R.string.notification_fast_drain_title)) + .setContentText(context.getString(R.string.notification_fast_drain_content, rate, minutes, limit)) + .setWhen(System.currentTimeMillis()) + .setLargeIcon(getLauncherIcon(context)) + .setContentIntent(createMainActivityIntent(context)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setStyle(new Notification.BigTextStyle() + .bigText(context.getString(R.string.notification_fast_drain_content, rate, minutes, limit))); + + final NotificationManager manager = getNotificationManager(context); + if (nonNull(manager)) { + manager.notify(FAST_DRAIN_NOTIFICATION_ID, builder.build()); + } + + final String sound = prefs.getString( + context.getString(R.string._pref_key_notifications_alert_sound_ringtone), + context.getString(R.string._default_notification_sound_uri)); + if (withinWindow) { + playAlarm(context, sound, shouldIgnoreSilentMode(context, prefs), isVibrationEnabled(context, prefs)); + } + } + /** * Clear all battery notifications * @@ -529,6 +590,9 @@ private static void createNotificationChannels(final Context context) { createChannelIfNotExists(manager, CHANNEL_ID_TEMPERATURE, context.getString(R.string.notification_temperature_channel_name), context.getString(R.string.notification_temperature_channel_description), Color.RED, vibrate); + createChannelIfNotExists(manager, CHANNEL_ID_FAST_DRAIN, + context.getString(R.string.notification_fast_drain_channel_name), + context.getString(R.string.notification_fast_drain_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); createSilentChannelIfNotExists(manager, CHANNEL_ID_STATUS, context.getString(R.string.notification_status_channel_name), context.getString(R.string.notification_status_channel_description)); @@ -615,6 +679,7 @@ public static void refreshAlertChannels(final Context context) { manager.deleteNotificationChannel(CHANNEL_ID_WARNING); manager.deleteNotificationChannel(CHANNEL_ID_FULL); manager.deleteNotificationChannel(CHANNEL_ID_TEMPERATURE); + manager.deleteNotificationChannel(CHANNEL_ID_FAST_DRAIN); createNotificationChannels(context); } diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java index ca896d4..7423d5f 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java @@ -1,6 +1,7 @@ package com.almothafar.simplebatterynotifier.service; import android.annotation.SuppressLint; +import android.app.KeyguardManager; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; @@ -12,6 +13,7 @@ import android.os.BatteryManager; import android.os.Build; import android.os.Bundle; +import android.os.PowerManager; import android.os.VibrationEffect; import android.os.Vibrator; import android.os.VibratorManager; @@ -456,6 +458,28 @@ public static int getChargeCycleCount(final Context context) { return cycles > 0 ? cycles : -1; } + /** + * Whether the user is actively using the phone right now: the screen is on and unlocked. + *

+ * Used by the fast-drain alert (#109) to tell a visible, expected drain (warn once) from a background + * drain the user can't see (remind while it continues). Needs no special permission. A missing + * PowerManager conservatively reads as "not actively used", so the higher-value background reminders + * still fire. + * + * @param context The application context + * + * @return true when the screen is interactive and the keyguard is not locked + */ + public static boolean isActivelyUsed(final Context context) { + final PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (isNull(powerManager) || !powerManager.isInteractive()) { + return false; + } + final KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + // No keyguard service means we can't confirm it's unlocked; treat as unlocked since the screen is on. + return isNull(keyguardManager) || !keyguardManager.isKeyguardLocked(); + } + /** * Vibrate the phone with a predefined pattern * diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f93995d..165c1dd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -164,7 +164,19 @@ يعرض الإشعار المستمر معدل الشحن/الاستهلاك المباشر بجانب الحالة يعرض الإشعار المستمر تسمية الحالة فقط حد الاستهلاك المرتفع - يتحول معدل الاستهلاك إلى اللون الكهرماني عند الاقتراب من هذا الحد وإلى الأحمر عند بلوغه أو تجاوزه. يُقاس بنقاط مئوية في الساعة. + يتحول معدل الاستهلاك إلى اللون الكهرماني عند الاقتراب من هذا الحد وإلى الأحمر عند بلوغه أو تجاوزه، ويُطلق تنبيه الاستهلاك السريع عند بقائه عند الحد. يُقاس بنقاط مئوية في الساعة. + + + تنبيه الاستهلاك السريع + ينبهك عندما تستمر البطارية في الاستهلاك أسرع من حدّك — الأكثر فائدة عندما يكون الهاتف في جيبك + تنبيهات الاستهلاك السريع معطّلة + التنبيه بعد (دقائق) + التذكير أثناء القفل (دقائق) + استهلاك سريع للبطارية + ينبّه عندما تُستهلك البطارية بسرعة غير طبيعية + البطارية تُستهلك بسرعة + البطارية تُستهلك بسرعة + يفقد نحو %1$s%% في الساعة خلال آخر %2$s دقيقة (حدّك: %3$s%%/h). قد يكون أحد التطبيقات يستهلك طاقة كبيرة. تحليلات البطارية diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1089db1..0f325b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -170,7 +170,20 @@ The ongoing notification shows the live drain/charge rate next to the status The ongoing notification shows only the status label High drain limit - The drain rate turns amber near this limit and red at or above it. Measured in percentage points per hour. + The drain rate turns amber near this limit and red at or above it, and the fast-drain alert fires when it stays there. Measured in percentage points per hour. + + + Fast-drain alert + Warns you when the battery keeps draining faster than your limit — most useful while it\'s in your pocket + Fast-drain alerts are disabled + Alert after (minutes) + Reminder while locked (minutes) + Fast Battery Drain + Warns when the battery is draining abnormally fast + Battery draining fast + Battery draining fast + + Losing ~%1$s%% per hour for the last %2$s minutes (your limit: %3$s%%/h). Something may be using a lot of power. Battery Insights @@ -285,6 +298,10 @@ key_show_rate_in_notification key_fast_drain_limit + + key_notify_fast_drain + key_fast_drain_sustained_minutes + key_fast_drain_reminder_minutes key_language celsius diff --git a/app/src/main/res/xml/pref_notification.xml b/app/src/main/res/xml/pref_notification.xml index ad8f5f2..6acb653 100644 --- a/app/src/main/res/xml/pref_notification.xml +++ b/app/src/main/res/xml/pref_notification.xml @@ -116,8 +116,19 @@ android:title="@string/pref_cat_title_drain" app:iconSpaceReserved="false"> - + + + + + + + Date: Sun, 12 Jul 2026 08:22:18 +0000 Subject: [PATCH 2/2] Harden FastDrainDetector per code review (#121) - Bound the streak's survival across data gaps: the sleep-on-no-rate rule kept a streak alive indefinitely, so the first tick after a long gap (process death, doze) could alert immediately with a "sustained for N minutes" claim built on unobserved time. The state now records when the rate was last seen at/above the limit; a gap longer than the rate's own smoothing window (BatteryRateTracker.WINDOW_MS) lapses the episode and starts fresh. - Persist the streak state only when it changed: the common case (discharging below the limit) re-decided CLEARED and rewrote identical values into SharedPreferences on every battery broadcast. - Clamp the sustained-minutes and reminder-gap preferences to their slider ranges on read, mirroring clampDrainLimit: a corrupt 0-minute sustained window would have turned the alert into the spike alarm the design forbids. Tests: lapse-rule scenarios (long gap restarts the episode, boundary at exactly WINDOW_MS, re-alert after a lapsed episode), observation refresh, and timing clamps; existing scenarios updated for the new lastSeenAbove field. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012LC6Qt7Nr8MpJHXuszizNF --- .../service/FastDrainDetector.java | 86 ++++++++++++---- app/src/main/res/xml/pref_notification.xml | 3 + .../service/FastDrainDetectorTest.java | 98 ++++++++++++++++--- 3 files changed, 154 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java index 4bb539a..ff3f17d 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java @@ -32,14 +32,26 @@ public final class FastDrainDetector { 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"; - // Defaults (all user-tunable), matching the settings XML. + // 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; + static final int MIN_SUSTAINED_MINUTES = 1; + static final int MAX_SUSTAINED_MINUTES = 30; static final int DEFAULT_REMINDER_MINUTES = 15; + 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); + private static final FastDrainState CLEARED = new FastDrainState(0, false, 0, 0); private FastDrainDetector() { // Utility class - prevent instantiation @@ -70,15 +82,22 @@ public static void evaluate(final Context context, final BatteryDO batteryDO, fi } final int limit = BatteryRateTracker.getDrainLimitPercentPerHour(context); - final long sustainedMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_sustained_minutes, DEFAULT_SUSTAINED_MINUTES); - final long reminderGapMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_reminder_minutes, DEFAULT_REMINDER_MINUTES); + final long sustainedMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_sustained_minutes, + DEFAULT_SUSTAINED_MINUTES, MIN_SUSTAINED_MINUTES, MAX_SUSTAINED_MINUTES); + final long reminderGapMs = minutesPref(prefs, context, R.string._pref_key_fast_drain_reminder_minutes, + DEFAULT_REMINDER_MINUTES, MIN_REMINDER_MINUTES, MAX_REMINDER_MINUTES); final boolean activelyUsed = SystemService.isActivelyUsed(context); final long now = System.currentTimeMillis(); - final FastDrainDecision decision = decide(loadState(prefs), rate.hasRate(), rate.percentPerHour(), + final FastDrainState previous = loadState(prefs); + final FastDrainDecision decision = decide(previous, rate.hasRate(), rate.percentPerHour(), limit, sustainedMs, reminderGapMs, activelyUsed, now); - saveState(prefs, decision.newState()); + // 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()); + } if (decision.shouldNotify()) { final int elapsedMinutes = Math.max(1, Math.round(decision.elapsedMs() / (float) MS_PER_MINUTE)); NotificationService.sendFastDrainNotification(context, rate.percentPerHour(), limit, elapsedMinutes); @@ -90,7 +109,10 @@ public static void evaluate(final Context context, final BatteryDO batteryDO, fi *