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..ff3f17d --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/FastDrainDetector.java @@ -0,0 +1,241 @@ +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"; + private static final String PREF_LAST_SEEN_ABOVE = "_fast_drain_last_seen_above"; + + // 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, 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, 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 FastDrainState previous = loadState(prefs); + final FastDrainDecision 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()); + } + 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. + *
+ * 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 @@