From 225d49a2a2e579b46d3cd12fc2e95bbe716de9b7 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Fri, 10 Jul 2026 22:27:21 +0300 Subject: [PATCH 1/2] Show battery charge/drain rate (e.g. "9%/h") in details table and status notification (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a smoothed charge/drain rate (%/h) and a signed instantaneous current, derived best-effort and degrading gracefully: - BatteryRateTracker computes the rate over a trailing, persisted sample window (no polling timer — samples piggyback the existing battery broadcasts and the foreground refresh). Source A = averaged current / capacity; source B = level-change over time (capacity-free, so it still works on Kirin/HiSilicon devices where the charge counter is unreliable, #69/#94). Each output is gated on its own merit. - Details table: "Drain rate"/"Charge rate" and "Current" rows at the top, each shown only when trustworthy. The drain rate turns amber near the user's limit and red at/above it (discharging only); charging is left uncoloured in v1. - Ongoing notification: appends the rate to the status line ("85% . Discharging 9%/h . 32.0 C"), falling back to mA then the plain label. Gated by one new setting (default on). - Settings: the shared "high drain" limit (default 20%/h) — the red line here and the fast-drain alert trigger in #109 — plus the notification toggle. - CONTEXT.md records the vocabulary (Drain rate, Charge rate, Instantaneous current, Design capacity). New strings drafted in Arabic (values-ar) for review. Pure helpers (rate computation, current sign, plausibility, windowing, serialization) are unit-tested. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 30 ++ .../model/BatteryDO.java | 12 + .../receiver/BatteryLevelReceiver.java | 5 + .../service/BatteryRateTracker.java | 411 ++++++++++++++++++ .../service/NotificationService.java | 36 +- .../service/SystemService.java | 44 +- .../ui/fragment/BatteryDetailsFragment.java | 82 +++- app/src/main/res/values-ar/strings.xml | 15 + app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 20 + app/src/main/res/xml/pref_notification.xml | 28 ++ .../service/BatteryRateTrackerTest.java | 345 +++++++++++++++ 12 files changed, 1013 insertions(+), 20 deletions(-) create mode 100644 CONTEXT.md create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java create mode 100644 app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..99a731c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,30 @@ +# Simple Battery Notifier + +An Android app that monitors the device battery and raises notifications for low/critical/full +levels, high temperature, and battery health. This glossary pins down the terms the app's UI and +code use so they don't drift. + +## Language + +**Drain rate**: +How fast the battery level is falling while discharging, expressed in percentage-points per hour +(%/h). The normalized, device-size-independent headline metric for consumption. +_Avoid_: consumption, usage, speed. + +**Charge rate**: +How fast the battery level is rising while charging, in %/h. The charging counterpart of the drain +rate. +_Avoid_: charging speed. + +**Instantaneous current**: +The live current flowing in or out of the battery right now, in milliamps (mA), read from +`BatteryManager.BATTERY_PROPERTY_CURRENT_NOW`. Shown as raw detail; not normalized, so it is not +colored on its own. +_Avoid_: amperage, draw, consumption. + +**Design capacity**: +The battery's rated full capacity when new (mAh), from the manufacturer's spec. User-entered or +best-effort auto-detected from the kernel. Distinct from the measured current full capacity. +_Avoid_: rated capacity (in prose only), max capacity. + + diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java b/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java index c3c210a..2c12f88 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java @@ -13,6 +13,9 @@ public final class BatteryDO { private int voltage; private boolean present; private int capacity; + // Instantaneous current in µA from BATTERY_PROPERTY_CURRENT_NOW; Integer.MIN_VALUE when the device + // doesn't report it. Sign convention varies by OEM, so callers derive direction from the status. + private int currentMicroAmps = Integer.MIN_VALUE; private String technology; private String powerSource; private String health; @@ -124,6 +127,15 @@ public BatteryDO setCapacity(final int capacity) { return this; } + public int getCurrentMicroAmps() { + return currentMicroAmps; + } + + public BatteryDO setCurrentMicroAmps(final int currentMicroAmps) { + this.currentMicroAmps = currentMicroAmps; + return this; + } + public String getPowerSource() { return powerSource; } 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 185ced1..3939070 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java @@ -10,6 +10,7 @@ import com.almothafar.simplebatterynotifier.R; import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker; +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker; import com.almothafar.simplebatterynotifier.service.NotificationService; import com.almothafar.simplebatterynotifier.service.SystemService; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; @@ -64,6 +65,10 @@ public void onReceive(final Context context, final Intent intent) { // Reuse the sticky intent we already read above instead of triggering a second read. 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). + BatteryRateTracker.record(context, batteryDO); + // Keep the persistent foreground-service status notification live with the latest reading NotificationService.updateOngoingNotification(context, batteryDO); diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java new file mode 100644 index 0000000..0880f9d --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -0,0 +1,411 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.BatteryManager; +import androidx.preference.PreferenceManager; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.BatteryDO; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Objects.isNull; + +/** + * Computes the live battery charge/drain rate in percentage-points per hour (%/h) and the + * signed instantaneous current, smoothed over a trailing window (issue #108). + *

+ * The rate is deliberately averaged over a stable window, not instantaneous: a wobbling number + * erodes trust, and the value of a drain readout is the sustained rate. There is no polling + * timer — samples piggyback the existing {@code ACTION_BATTERY_CHANGED} broadcasts and the + * MainActivity foreground refresh, both of which already run. The window is persisted in + * {@link SharedPreferences} (like {@link BatteryHealthTracker}) so it survives process restarts. + *

+ * The %/h is derived best-effort and degrades gracefully: + *

    + *
  1. From the averaged instantaneous current ÷ full capacity, when both are trustworthy.
  2. + *
  3. Else from the level change over the window (capacity-free — the path that still works on + * Kirin/HiSilicon devices where the charge counter is unreliable, see #69 / #94).
  4. + *
  5. Else no rate (only the raw mA may still be shown).
  6. + *
+ * Each output — the rate and the instantaneous current — is gated on its own merit, since on some + * devices one reading is garbage while the other is fine (the Kirin Mate 10 Pro), mirroring #94. + */ +public final class BatteryRateTracker { + + // Persisted trailing window of samples ("t:level:currentUa" joined by ';') and the direction it was + // captured in (1 charging / 0 discharging), so a charge/discharge flip resets the window. + private static final String PREF_RATE_SAMPLES = "_battery_rate_samples"; + private static final String PREF_RATE_CHARGING = "_battery_rate_charging"; + + // Trailing window length: long enough that the level-over-time source sees a real change (at 20%/h, + // 1% takes ~3 min) and that the current average is stable, short enough to still track "right now". + static final long WINDOW_MS = 10L * 60 * 1000; + // Don't append more often than this, so the 3 s foreground refresh can't flood the window. + static final long MIN_SAMPLE_SPACING_MS = 20L * 1000; + // Hard cap on retained samples (defensive; the age + spacing rules already bound it). + private static final int MAX_SAMPLES = 60; + + // Source A (current): needs a plausible full capacity and a few current readings spanning a short + // minimum, so the average is smoothed rather than a single instantaneous spike. + static final int MIN_CURRENT_SAMPLES = 3; + static final long MIN_SPAN_CURRENT_MS = 45L * 1000; + // Source B (level-over-time): needs a longer span so a 1% tick resolves into a sensible rate. + static final long MIN_SPAN_LEVEL_MS = 3L * 60 * 1000; + + // A phone never sources/sinks more than a few amps; anything past this is a bad/units-wrong reading. + static final int MAX_PLAUSIBLE_CURRENT_MA = 15000; + // Above this the derived %/h is garbage (e.g. a wrong-unit current); reject rather than display it. + static final int MAX_PLAUSIBLE_RATE_PPH = 500; + + // The "red / high drain" limit shared with the fast-drain alert (#109): default and accepted range. + public static final int DEFAULT_DRAIN_LIMIT_PPH = 20; + public static final int MIN_DRAIN_LIMIT_PPH = 5; + public static final int MAX_DRAIN_LIMIT_PPH = 60; + // Amber sits just below the red limit; 0.75x keeps colour reserved for genuinely high drain (#108). + private static final float AMBER_RATIO = 0.75f; + + // getIntProperty returns this for an unsupported property. + private static final int PROPERTY_UNSUPPORTED = Integer.MIN_VALUE; + + private BatteryRateTracker() { + // Utility class - prevent instantiation + } + + /** + * Records the current battery snapshot into the trailing window and returns the freshly-computed + * rate. Called on each {@code ACTION_BATTERY_CHANGED} broadcast and on the foreground refresh — the + * two places a sample naturally arrives without any new timer. + * + * @param context Application context + * @param batteryDO Current battery snapshot (may be null) + * + * @return the smoothed rate + instantaneous current, or an empty result when {@code batteryDO} is null + */ + public static BatteryRate record(final Context context, final BatteryDO batteryDO) { + if (isNull(context) || isNull(batteryDO)) { + return BatteryRate.empty(); + } + final long now = System.currentTimeMillis(); + final boolean charging = isChargingDirection(batteryDO.getStatus()); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + final boolean directionKnown = prefs.contains(PREF_RATE_CHARGING); + final boolean prevCharging = prefs.getInt(PREF_RATE_CHARGING, 0) == 1; + // A charge/discharge flip invalidates the whole window (mixed slopes/currents); start fresh. This + // also produces the brief post-unplug warm-up during which no rate is shown yet. + final List window = (directionKnown && prevCharging == charging) + ? parseSamples(prefs.getString(PREF_RATE_SAMPLES, "")) + : new ArrayList<>(); + + final int level = Math.round(batteryDO.getBatteryPercentage()); + final List updated = appendAndTrim(window, new Sample(now, level, batteryDO.getCurrentMicroAmps()), now); + + final SharedPreferences.Editor editor = prefs.edit(); + editor.putString(PREF_RATE_SAMPLES, serializeSamples(updated)); + editor.putInt(PREF_RATE_CHARGING, charging ? 1 : 0); + editor.apply(); + + return computeRate(updated, batteryDO.getCapacity(), charging, now, batteryDO.getCurrentMicroAmps()); + } + + /** + * Reads the current rate from the persisted window without adding a sample. Used by the ongoing + * status notification, which only displays the value; the window is fed by {@link #record}. + * + * @param context Application context + * @param batteryDO Current battery snapshot (may be null) + * + * @return the smoothed rate + instantaneous current, or an empty result when {@code batteryDO} is null + */ + public static BatteryRate getRate(final Context context, final BatteryDO batteryDO) { + if (isNull(context) || isNull(batteryDO)) { + return BatteryRate.empty(); + } + final long now = System.currentTimeMillis(); + final boolean charging = isChargingDirection(batteryDO.getStatus()); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + // Ignore a window captured in the other direction (a flip that record() hasn't reset yet). + final boolean sameDirection = prefs.contains(PREF_RATE_CHARGING) && (prefs.getInt(PREF_RATE_CHARGING, 0) == 1) == charging; + final List window = sameDirection ? parseSamples(prefs.getString(PREF_RATE_SAMPLES, "")) : new ArrayList<>(); + + return computeRate(window, batteryDO.getCapacity(), charging, now, batteryDO.getCurrentMicroAmps()); + } + + /** + * Whether a status maps to the "charging" direction (charging or already full, i.e. plugged and + * rising/topped-off) rather than discharging. + * + * @param status a {@code BatteryManager.BATTERY_STATUS_*} constant + * + * @return true when the battery is charging or full + */ + static boolean isChargingDirection(final int status) { + return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL; + } + + /** + * Appends a sample under the spacing rule and drops anything older than the window. Pure so the + * windowing can be unit-tested. + * + * @param window existing samples, oldest first + * @param sample the candidate new sample + * @param now current time in millis + * + * @return the new window, oldest first + */ + static List appendAndTrim(final List window, final Sample sample, final long now) { + final List result = new ArrayList<>(window.size() + 1); + final long cutoff = now - WINDOW_MS; + for (final Sample s : window) { + if (s.timeMillis() >= cutoff && s.timeMillis() <= now) { + result.add(s); + } + } + final boolean spacedEnough = result.isEmpty() + || sample.timeMillis() - result.get(result.size() - 1).timeMillis() >= MIN_SAMPLE_SPACING_MS; + if (spacedEnough && sample.timeMillis() >= cutoff) { + result.add(sample); + } + // Guard against unbounded growth if the clock jumps; keep the most recent MAX_SAMPLES. + if (result.size() > MAX_SAMPLES) { + return new ArrayList<>(result.subList(result.size() - MAX_SAMPLES, result.size())); + } + return result; + } + + /** + * Computes the smoothed rate and signed instantaneous current from a window. Pure and Android-free + * (apart from the {@code BatteryManager} status constants) so it is fully unit-testable. + *

+ * The %/h magnitude prefers the averaged current ÷ capacity (source A) and falls back to the + * level change over the window (source B). A rate is only reported once the window has enough data + * (the post-unplug warm-up shows nothing), and never when it rounds to 0 (a static level) or exceeds + * a plausible ceiling (a garbage reading). The current is reported independently, signed by + * direction so it reads negative while discharging and positive while charging regardless of the + * device's raw sign convention. + * + * @param window samples oldest-first + * @param capacityMah measured full capacity in mAh, or 0 when unknown/untrusted (#69) + * @param charging direction (label + colouring): true charging, false discharging + * @param nowMillis current time in millis + * @param latestCurrentMicroAmps the freshest instantaneous current reading in µA (for the current row) + * + * @return the computed {@link BatteryRate} + */ + static BatteryRate computeRate(final List window, final int capacityMah, final boolean charging, + final long nowMillis, final int latestCurrentMicroAmps) { + final boolean hasCurrent = isPlausibleCurrentMicroAmps(latestCurrentMicroAmps) + && Math.round(Math.abs(latestCurrentMicroAmps) / 1000f) >= 1; + final int signedMilliAmps = hasCurrent ? signedCurrentMilliAmps(latestCurrentMicroAmps, charging) : 0; + + final int pph = ratePercentPerHour(window, capacityMah); + final boolean hasRate = pph >= 1 && pph <= MAX_PLAUSIBLE_RATE_PPH; + + return new BatteryRate(hasRate, hasRate ? pph : 0, charging, hasCurrent, signedMilliAmps); + } + + /** + * The smoothed %/h magnitude from a window: source A (averaged current ÷ capacity) preferred, + * source B (level change over time) as the capacity-free fallback. Returns 0 when neither source has + * enough trustworthy data yet. + * + * @param window samples oldest-first + * @param capacityMah measured full capacity in mAh, or 0 when unknown + * + * @return rounded %/h magnitude, or 0 when unavailable + */ + private static int ratePercentPerHour(final List window, final int capacityMah) { + if (window.size() < 2) { + return 0; + } + final Sample first = window.get(0); + final Sample last = window.get(window.size() - 1); + final long spanMs = last.timeMillis() - first.timeMillis(); + + // Source A: averaged current / capacity. + double sumMilliAmps = 0; + int currentSamples = 0; + for (final Sample s : window) { + if (isPlausibleCurrentMicroAmps(s.currentMicroAmps())) { + sumMilliAmps += s.currentMicroAmps() / 1000.0; + currentSamples++; + } + } + if (capacityMah > 0 && currentSamples >= MIN_CURRENT_SAMPLES && spanMs >= MIN_SPAN_CURRENT_MS) { + final double avgMilliAmps = sumMilliAmps / currentSamples; + return (int) Math.round(Math.abs(avgMilliAmps) / capacityMah * 100.0); + } + + // Source B: level change over time (capacity-free). + if (spanMs >= MIN_SPAN_LEVEL_MS) { + final int deltaLevel = last.level() - first.level(); + if (deltaLevel != 0) { + final double hours = spanMs / 3_600_000.0; + return (int) Math.round(Math.abs(deltaLevel) / hours); + } + } + return 0; + } + + /** + * Whether a raw current property value is a usable reading (supported, and within a phone's plausible + * range). {@code getIntProperty} returns {@link Integer#MIN_VALUE} when the property is unsupported. + * + * @param microAmps raw current in µA + * + * @return true when the reading can be trusted + */ + static boolean isPlausibleCurrentMicroAmps(final int microAmps) { + if (microAmps == PROPERTY_UNSUPPORTED || microAmps == Integer.MAX_VALUE) { + return false; + } + return Math.abs((long) microAmps) <= MAX_PLAUSIBLE_CURRENT_MA * 1000L; + } + + /** + * Converts a raw µA reading to milliamps signed by direction: negative while discharging, positive + * while charging. Taking the sign from the (reliable) charging state rather than the raw reading + * sidesteps the OEM sign-convention inconsistency for {@code BATTERY_PROPERTY_CURRENT_NOW}. + * + * @param microAmps raw current in µA + * @param charging direction: true charging, false discharging + * + * @return signed current in mA + */ + static int signedCurrentMilliAmps(final int microAmps, final boolean charging) { + final int magnitude = Math.round(Math.abs(microAmps) / 1000f); + return charging ? magnitude : -magnitude; + } + + /** + * The user's shared "high drain" limit in %/h — the red line in the details table (#108) and the + * fast-drain alert trigger (#109). Defaults to {@link #DEFAULT_DRAIN_LIMIT_PPH}. + * + * @param context Application context + * + * @return the configured limit in %/h + */ + public static int getDrainLimitPercentPerHour(final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getInt(context.getString(R.string._pref_key_fast_drain_limit), DEFAULT_DRAIN_LIMIT_PPH); + } + + /** + * The amber threshold derived just below a given red limit (#108). Pure so it is unit-testable. + * + * @param limitPercentPerHour the red limit in %/h + * + * @return the amber threshold in %/h + */ + public static int amberThreshold(final int limitPercentPerHour) { + return Math.round(limitPercentPerHour * AMBER_RATIO); + } + + /** + * Formats the rate magnitude for display, e.g. {@code "9%/h"}, with Western digits in every locale + * (#96), matching the compact form recorded in {@code CONTEXT.md}. + * + * @param context Application context + * @param percentPerHour rate magnitude in %/h + * + * @return the formatted rate string + */ + public static String formatRateValue(final Context context, final int percentPerHour) { + return context.getString(R.string.battery_rate_value, String.valueOf(percentPerHour)); + } + + /** + * Formats the signed current for display, e.g. {@code "+900 mA"} or {@code "−450 mA"}, with Western + * digits in every locale (#96). + * + * @param context Application context + * @param signedMilliAmps signed current in mA (negative discharging, positive charging) + * + * @return the formatted current string + */ + public static String formatCurrentValue(final Context context, final int signedMilliAmps) { + final String sign = signedMilliAmps >= 0 ? "+" : "−"; // U+2212 MINUS SIGN reads cleaner than '-' + return context.getString(R.string.battery_current_value, sign + Math.abs(signedMilliAmps)); + } + + /** + * Serializes a window to a compact string ("t:level:currentUa" joined by ';'). Pure and testable. + * + * @param window samples oldest-first + * + * @return the serialized form + */ + static String serializeSamples(final List window) { + final StringBuilder sb = new StringBuilder(); + for (final Sample s : window) { + if (sb.length() > 0) { + sb.append(';'); + } + sb.append(s.timeMillis()).append(':').append(s.level()).append(':').append(s.currentMicroAmps()); + } + return sb.toString(); + } + + /** + * Parses a serialized window. Malformed entries are validated and skipped (not caught) — mirroring + * {@code SystemService.designCapacityMahFromMicroAmpHours} and the project's "no silent catch" rule. + * + * @param serialized the serialized form (may be empty) + * + * @return the parsed samples oldest-first (empty when nothing valid) + */ + static List parseSamples(final String serialized) { + final List samples = new ArrayList<>(); + if (isNull(serialized) || serialized.isEmpty()) { + return samples; + } + for (final String entry : serialized.split(";")) { + final String[] parts = entry.split(":"); + if (parts.length != 3 || !isLong(parts[0]) || !isInt(parts[1]) || !isInt(parts[2])) { + continue; // skip malformed rather than throw + } + samples.add(new Sample(Long.parseLong(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]))); + } + return samples; + } + + private static boolean isLong(final String s) { + return s.matches("-?\\d{1,19}"); + } + + private static boolean isInt(final String s) { + return s.matches("-?\\d{1,10}"); + } + + /** + * One battery sample in the trailing window. + * + * @param timeMillis capture time in millis + * @param level battery level as a percentage (0-100) + * @param currentMicroAmps instantaneous current in µA, or {@link Integer#MIN_VALUE} when unsupported + */ + record Sample(long timeMillis, int level, int currentMicroAmps) { + } + + /** + * Result of a rate computation: the smoothed %/h and the signed instantaneous current, each with a + * flag saying whether it is trustworthy enough to display. + * + * @param hasRate whether a trustworthy %/h is available + * @param percentPerHour rate magnitude in %/h (valid only when {@code hasRate}) + * @param charging direction: true charging (charge rate), false discharging (drain rate) + * @param hasCurrent whether a trustworthy instantaneous current is available + * @param currentMilliAmps signed current in mA (valid only when {@code hasCurrent}) + */ + public record BatteryRate(boolean hasRate, int percentPerHour, boolean charging, + boolean hasCurrent, int currentMilliAmps) { + + static BatteryRate empty() { + return new BatteryRate(false, 0, false, false, 0); + } + } +} 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 89e36ff..31a071d 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -888,7 +888,11 @@ private static PendingIntent createMainActivityIntent(final Context context) { } /** - * Build the content line of the ongoing status notification, e.g. "85% · Discharging · 32.0 °C". + * Build the content line of the ongoing status notification, e.g. "85% · Discharging 9%/h · 32.0 °C". + *

+ * The middle segment appends the charge/drain rate to the status label when available, falling back + * to the raw mA, then to the plain label — always showing the best number on hand (issue #108). The + * appended rate is gated by a user setting (default on); the plain label always shows. * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable @@ -898,7 +902,35 @@ private static String statusText(final Context context, final BatteryDO batteryD final int percentage = isNull(batteryDO) ? 0 : Math.round(batteryDO.getBatteryPercentage()); final String statusLabel = SystemService.getStatusLabel(context, isNull(batteryDO) ? -1 : batteryDO.getStatus()); final String temperature = isNull(batteryDO) ? "" : TemperatureUtils.format(context, batteryDO.getTemperature()); - return context.getString(R.string.notification_status_content, percentage, statusLabel, temperature); + return context.getString(R.string.notification_status_content, percentage, statusWithRate(context, batteryDO, statusLabel), temperature); + } + + /** + * The status segment with the rate (or raw mA) appended, e.g. "Discharging 9%/h" — or the plain + * label when no reading is available or the user turned the appended rate off (issue #108). + * + * @param context The application context + * @param batteryDO Current battery snapshot, or null if unavailable + * @param statusLabel The plain localized status label + * @return The status label, optionally with the rate/current appended + */ + private static String statusWithRate(final Context context, final BatteryDO batteryDO, final String statusLabel) { + if (isNull(batteryDO)) { + return statusLabel; + } + final boolean showRate = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); + if (!showRate) { + return statusLabel; + } + final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.getRate(context, batteryDO); + if (rate.hasRate()) { + return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); + } + if (rate.hasCurrent()) { + return statusLabel + " " + BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); + } + return statusLabel; } /** 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 49229a7..ca896d4 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java @@ -80,8 +80,9 @@ public static BatteryDO getBatteryInfo(final Context context, final Intent batte final BatteryExtras extras = extractBatteryExtras(batteryStatus); final String chargerType = determineChargerType(extras.plugged, resources); final int batteryCapacity = getBatteryCapacity(context); + final int currentMicroAmps = getInstantaneousCurrentMicroAmps(context); - return buildBatteryDataObject(extras, chargerType, batteryCapacity, resources); + return buildBatteryDataObject(extras, chargerType, batteryCapacity, currentMicroAmps, resources); } /** @@ -188,6 +189,7 @@ private static String determineChargerType(final int plugged, final Resources re private static BatteryDO buildBatteryDataObject(final BatteryExtras extras, final String chargerType, final int batteryCapacity, + final int currentMicroAmps, final Resources resources) { final BatteryDO batteryDO = new BatteryDO(); batteryDO.setLevel(extras.level) @@ -200,6 +202,7 @@ private static BatteryDO buildBatteryDataObject(final BatteryExtras extras, .setTemperature(extras.temperature) .setVoltage(extras.voltage) .setCapacity(batteryCapacity) + .setCurrentMicroAmps(currentMicroAmps) .setIntHealth(extras.health); // Determine health status and set it on the battery object @@ -287,24 +290,18 @@ public static int getBatteryCapacity(final Context context) { /** * Estimate the current charging speed from the instantaneous current and battery voltage. *

- * Reads {@link BatteryManager#BATTERY_PROPERTY_CURRENT_NOW} (µA) and the battery voltage - * ({@link BatteryManager#EXTRA_VOLTAGE}, mV) and combines them into a {@link ChargeSpeed}. Best - * sampled a moment after connection, once the current has stabilised — right at plug-in the - * current reads 0 or noisy. Returns {@link ChargeSpeed#unknown()} when the device doesn't report - * instantaneous current, so callers fall back to a plain "Charging" message. + * Combines {@link #getInstantaneousCurrentMicroAmps(Context)} with the battery voltage + * ({@link BatteryManager#EXTRA_VOLTAGE}, mV) into a {@link ChargeSpeed}. Best sampled a moment + * after connection, once the current has stabilised — right at plug-in the current reads 0 or + * noisy. Returns {@link ChargeSpeed#unknown()} when the device doesn't report instantaneous + * current, so callers fall back to a plain "Charging" message. * * @param context The application context * * @return the estimated {@link ChargeSpeed}; never null */ public static ChargeSpeed getChargeSpeed(final Context context) { - final BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); - if (isNull(batteryManager)) { - Log.w(TAG, "BatteryManager service unavailable"); - return ChargeSpeed.unknown(); - } - - final int currentMicroAmps = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + final int currentMicroAmps = getInstantaneousCurrentMicroAmps(context); final Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); final int voltageMilliVolts = isNull(batteryStatus) ? 0 : batteryStatus.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0); @@ -312,6 +309,27 @@ public static ChargeSpeed getChargeSpeed(final Context context) { return ChargeSpeed.fromMeasurements(currentMicroAmps, voltageMilliVolts); } + /** + * Read the live instantaneous battery current from {@link BatteryManager#BATTERY_PROPERTY_CURRENT_NOW} + * (µA), used to derive the charge/drain rate and the signed "Current" row (issue #108). + *

+ * The raw value is returned unfiltered: {@code getIntProperty} yields {@link Integer#MIN_VALUE} when + * the property is unsupported, and the sign convention varies by OEM. Callers gate plausibility and + * derive direction from the charging status (see {@link BatteryRateTracker}). + * + * @param context The application context + * + * @return instantaneous current in µA, or {@link Integer#MIN_VALUE} when unavailable + */ + public static int getInstantaneousCurrentMicroAmps(final Context context) { + final BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + if (isNull(batteryManager)) { + Log.w(TAG, "BatteryManager service unavailable"); + return Integer.MIN_VALUE; + } + return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + } + /** * Estimate the full battery capacity (mAh) from the remaining charge and remaining percentage. *

diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java index 5813927..dc217ea 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java @@ -4,6 +4,7 @@ import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.annotation.SuppressLint; +import android.content.Context; import android.os.Bundle; import android.provider.Settings; import android.util.Log; @@ -21,6 +22,7 @@ import com.almothafar.simplebatterynotifier.R; import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker; +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker; import com.almothafar.simplebatterynotifier.service.SystemService; import com.almothafar.simplebatterynotifier.util.GeneralHelper; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; @@ -55,6 +57,12 @@ public class BatteryDetailsFragment extends Fragment { private boolean capacityUnreliable; private String capacityLabel; + // #108: the drain-rate row's label, and the colour applied to its value cell (amber near the user's + // limit, red at/above it, while discharging; 0 = no special colour). Both set in fillBatteryInfo and + // applied by createDetailsTable to just that row, mirroring the capacity special-case above. + private String rateLabel; + private int rateValueColor; + /** * Default constructor required for fragment instantiation */ @@ -118,7 +126,9 @@ public void createDetailsTable(final View view) { for (final String key : valuesMap.keySet()) { // Only the capacity row gets the "unreliable reading" info affordance (#94). final boolean unreliable = capacityUnreliable && key.equals(capacityLabel); - final TableRow row = createTableRow(view, key, valuesMap.get(key), cellPadding, cellPaddingTop, unreliable); + // Only the drain-rate row is coloured (amber/red near the limit while discharging) — #108. + final int valueColor = (rateValueColor != 0 && key.equals(rateLabel)) ? rateValueColor : 0; + final TableRow row = createTableRow(view, key, valuesMap.get(key), cellPadding, cellPaddingTop, unreliable, valueColor); tableLayout.addView(row, rowIndex); rowIndex++; } @@ -196,11 +206,13 @@ public void onAnimationEnd(final Animator animation) { * @param cellPadding The horizontal cell padding * @param cellPaddingTop The top cell padding * @param valueUnreliable When true, decorate the value cell with a tappable "unreliable reading" icon + * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108) * * @return The created table row */ private TableRow createTableRow(final View view, final String label, final String value, - final int cellPadding, final int cellPaddingTop, final boolean valueUnreliable) { + final int cellPadding, final int cellPaddingTop, + final boolean valueUnreliable, final int valueColor) { final TableRow row = new TableRow(view.getContext()); final TableRow.LayoutParams layoutParams = new TableRow.LayoutParams( TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1.0f); @@ -212,7 +224,7 @@ private TableRow createTableRow(final View view, final String label, final Strin final TextView textViewLabel = createLabelTextView(view, label, cellPadding, cellPaddingTop); final TextView textViewSep = createSeparatorTextView(view); - final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable); + final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable, valueColor); // Add views in logical order (label -> separator -> value) // RTL languages will automatically reverse the visual order @@ -273,6 +285,7 @@ private TextView createSeparatorTextView(final View view) { * @param cellPaddingTop The top cell padding * @param unreliable When true, append a tappable amber info icon that opens the "unreliable * reading" explanation (#94) + * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108) * * @return The created TextView */ @@ -280,11 +293,14 @@ private TextView createValueTextView(final View view, final String text, final int cellPadding, final int cellPaddingTop, - final boolean unreliable) { + final boolean unreliable, + final int valueColor) { final TextView textView = new TextView(view.getContext()); textView.setTextAppearance(R.style.DefaultTextStyle); textView.setText(text); - textView.setTextColor(GeneralHelper.getColor(getResources(), R.color.battery_details_value_color)); + textView.setTextColor(valueColor != 0 + ? valueColor + : GeneralHelper.getColor(getResources(), R.color.battery_details_value_color)); // Start-align + relative padding + locale text direction so numeric (Latin) values sit right after // the colon like the Arabic text values do, instead of flying to the far edge in RTL (#96). textView.setGravity(Gravity.START); @@ -309,6 +325,11 @@ private TextView createValueTextView(final View view, private void fillBatteryInfo(final View view) { valuesMap = new LinkedHashMap<>(); + // #108: live charge/drain rate and signed current at the very top, each shown only when its + // reading is trustworthy. Recording here also feeds the smoothing window from the foreground + // refresh (the other feed is the battery broadcast) without any polling timer of our own. + addRateRows(view); + valuesMap.put(getResources().getString(R.string.technology), batteryDO.getTechnology()); // Capacity is an estimate from BatteryManager. Show "Unknown" rather than "0 mAh" when the device @@ -341,6 +362,57 @@ private void fillBatteryInfo(final View view) { valuesMap.put(getResources().getString(R.string.battery_condition), batteryDO.getHealth()); } + /** + * Adds the drain/charge rate and signed-current rows at the top of the table (#108). + *

+ * Each row appears only when its reading is trustworthy (independent gating like #94): the rate hides + * during the brief post-unplug warm-up or a static level, the current hides when the device doesn't + * report a plausible mA. The label flips between "Drain rate" and "Charge rate" with direction, and + * the rate value is coloured amber/red near the user's limit while discharging (see {@link #rateColor}). + * + * @param view The fragment view + */ + private void addRateRows(final View view) { + rateLabel = null; + rateValueColor = 0; + + final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(view.getContext(), batteryDO); + + if (rate.hasRate()) { + rateLabel = getResources().getString(rate.charging() ? R.string.charge_rate : R.string.drain_rate); + valuesMap.put(rateLabel, BatteryRateTracker.formatRateValue(view.getContext(), rate.percentPerHour())); + rateValueColor = rateColor(view.getContext(), rate); + } + if (rate.hasCurrent()) { + valuesMap.put(getResources().getString(R.string.battery_current), + BatteryRateTracker.formatCurrentValue(view.getContext(), rate.currentMilliAmps())); + } + } + + /** + * The colour for the drain-rate value: red at/above the user's limit, amber as it approaches (derived + * just below the limit), otherwise the default. Charging is left uncoloured in v1 — its context traps + * (thermal throttling, deliberate trickle near 100%) make a single limit misleading (#108). + * + * @param context The context for resolving colours and the limit preference + * @param rate The computed rate + * + * @return a colour int, or 0 to keep the default value colour + */ + private int rateColor(final Context context, final BatteryRateTracker.BatteryRate rate) { + if (rate.charging()) { + return 0; + } + final int limit = BatteryRateTracker.getDrainLimitPercentPerHour(context); + if (rate.percentPerHour() >= limit) { + return GeneralHelper.getColor(getResources(), R.color.battery_rate_high); + } + if (rate.percentPerHour() >= BatteryRateTracker.amberThreshold(limit)) { + return GeneralHelper.getColor(getResources(), R.color.battery_rate_warn); + } + return 0; + } + /** * Explains why the capacity (and the health figure derived from it) can't be trusted on this device, * including the possibility that the battery is genuinely wearing out (#94). Shares its wording with diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d4c71e..f93995d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -31,6 +31,11 @@ °F مصدر الطاقة + + معدل الاستهلاك + معدل الشحن + التيار + على البطارية مقبس طاقة مدخل يو اس بي @@ -151,6 +156,16 @@ حرارة البطارية %1$s. قلّل الاستخدام ودعها تبرد. حرارة بطاريتك %1$s، وهي أعلى من النطاق الآمن. الحرارة العالية تسرّع تلف البطارية وقد تكون خطرة. أوقف الشحن أو الاستخدام المكثف (الألعاب، الملاحة، الكاميرا) ودع الهاتف يبرد. + + %1$s%%/h + %1$s mA + استهلاك البطارية + إظهار المعدل في إشعار الحالة + يعرض الإشعار المستمر معدل الشحن/الاستهلاك المباشر بجانب الحالة + يعرض الإشعار المستمر تسمية الحالة فقط + حد الاستهلاك المرتفع + يتحول معدل الاستهلاك إلى اللون الكهرماني عند الاقتراب من هذا الحد وإلى الأحمر عند بلوغه أو تجاوزه. يُقاس بنقاط مئوية في الساعة. + تحليلات البطارية تحليلات البطارية diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c529b04..2051bee 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -27,4 +27,9 @@ #004A5B #01708A #004A5B + + + #FFB100 + #E01A00 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfd0774..1089db1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,11 @@ °F Power Source + + Drain rate + Charge rate + Current + On Battery AC Charger USB Port @@ -155,6 +160,18 @@ Battery is at %1$s. Reduce load and let it cool down. Your battery is at %1$s, which is above the safe range. High temperature accelerates battery wear and can be unsafe. Stop charging or heavy use (games, navigation, camera) and let the phone cool down. + + + %1$s%%/h + + %1$s mA + Battery Drain + Show rate in status notification + 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. + Battery Insights Battery Insights @@ -265,6 +282,9 @@ key_critical_ignore_quiet_hours key_notify_high_temperature key_high_temperature_threshold + + key_show_rate_in_notification + key_fast_drain_limit key_language celsius diff --git a/app/src/main/res/xml/pref_notification.xml b/app/src/main/res/xml/pref_notification.xml index a2b5670..b80d0d2 100644 --- a/app/src/main/res/xml/pref_notification.xml +++ b/app/src/main/res/xml/pref_notification.xml @@ -112,6 +112,34 @@ + + + + + + + + diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java new file mode 100644 index 0000000..a356e04 --- /dev/null +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java @@ -0,0 +1,345 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.os.BatteryManager; + +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.BatteryRate; +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.Sample; + +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for the pure charge/drain-rate logic in {@link BatteryRateTracker} (issue #108). + * Currents are in µA; times in millis. The android-free helpers carry the feature's correctness. + */ +@RunWith(Enclosed.class) +public class BatteryRateTrackerTest { + + private static final int NO_CURRENT = Integer.MIN_VALUE; + + /** + * {@link BatteryRateTracker#computeRate}: source selection (current vs level), the warm-up/static + * gates, the plausibility ceiling, and the independent current gating. + */ + public static class ComputeRate { + + @Test + public void sourceA_dischargingFromAveragedCurrent() { + final List window = Arrays.asList( + new Sample(0, 50, -800_000), + new Sample(30_000, 50, -800_000), + new Sample(60_000, 50, -800_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 4000, false, 60_000, -800_000); + + assertTrue(rate.hasRate()); + assertEquals(20, rate.percentPerHour()); // 800mA / 4000mAh * 100 + assertFalse(rate.charging()); + assertTrue(rate.hasCurrent()); + assertEquals(-800, rate.currentMilliAmps()); + } + + @Test + public void sourceA_chargingSignsCurrentPositive() { + final List window = Arrays.asList( + new Sample(0, 40, 800_000), + new Sample(30_000, 40, 800_000), + new Sample(60_000, 40, 800_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 4000, true, 60_000, 800_000); + + assertTrue(rate.hasRate()); + assertEquals(20, rate.percentPerHour()); + assertTrue(rate.charging()); + assertEquals(800, rate.currentMilliAmps()); + } + + @Test + public void sourceA_takesPrecedenceOverLevelDelta() { + // Level drops 8% over 4 min (source B would read ~120%/h), but a trustworthy current wins. + final List window = Arrays.asList( + new Sample(0, 60, -800_000), + new Sample(60_000, 58, -800_000), + new Sample(120_000, 56, -800_000), + new Sample(180_000, 54, -800_000), + new Sample(240_000, 52, -800_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 4000, false, 240_000, -800_000); + + assertTrue(rate.hasRate()); + assertEquals(20, rate.percentPerHour()); + } + + @Test + public void sourceB_levelOverTimeWhenNoCapacityOrCurrent() { + // Kirin-style: capacity untrusted (0) and no usable current — the level-delta path carries it. + final List window = Arrays.asList( + new Sample(0, 50, NO_CURRENT), + new Sample(360_000, 47, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 360_000, NO_CURRENT); + + assertTrue(rate.hasRate()); + assertEquals(30, rate.percentPerHour()); // 3% over 0.1h + assertFalse(rate.hasCurrent()); + } + + @Test + public void warmUp_singleSampleHasNoRate() { + final List window = Arrays.asList(new Sample(0, 50, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 4000, false, 0, NO_CURRENT); + + assertFalse(rate.hasRate()); + assertFalse(rate.hasCurrent()); + } + + @Test + public void warmUp_shortSpanWithoutCurrentHasNoRate() { + final List window = Arrays.asList( + new Sample(0, 50, NO_CURRENT), + new Sample(30_000, 50, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 30_000, NO_CURRENT); + + assertFalse(rate.hasRate()); + } + + @Test + public void staticLevel_hasNoRate() { + final List window = Arrays.asList( + new Sample(0, 50, NO_CURRENT), + new Sample(360_000, 50, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 360_000, NO_CURRENT); + + assertFalse(rate.hasRate()); + } + + @Test + public void garbageRate_rejectedButCurrentStillShown() { + // 3000mA on a 500mAh battery => 600%/h, past the ceiling: reject the rate, keep the mA. + final List window = Arrays.asList( + new Sample(0, 50, -3_000_000), + new Sample(30_000, 50, -3_000_000), + new Sample(60_000, 50, -3_000_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 500, false, 60_000, -3_000_000); + + assertFalse(rate.hasRate()); + assertTrue(rate.hasCurrent()); + assertEquals(-3000, rate.currentMilliAmps()); + } + + @Test + public void negligibleRate_roundsToZeroAndHides() { + final List window = Arrays.asList( + new Sample(0, 50, -10_000), + new Sample(30_000, 50, -10_000), + new Sample(60_000, 50, -10_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 4000, false, 60_000, -10_000); + + assertFalse(rate.hasRate()); // 0.25%/h rounds to 0 + assertTrue(rate.hasCurrent()); + assertEquals(-10, rate.currentMilliAmps()); + } + + @Test + public void currentZero_isHiddenWhileRateStillShown() { + final List window = Arrays.asList( + new Sample(0, 50, NO_CURRENT), + new Sample(360_000, 47, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 360_000, 0); + + assertTrue(rate.hasRate()); + assertFalse(rate.hasCurrent()); // 0 mA is not worth showing + } + + @Test + public void currentUnsupported_isHidden() { + final List window = Arrays.asList( + new Sample(0, 50, NO_CURRENT), + new Sample(360_000, 47, NO_CURRENT)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 360_000, NO_CURRENT); + + assertFalse(rate.hasCurrent()); + } + } + + /** + * {@link BatteryRateTracker#signedCurrentMilliAmps}: magnitude from the reading, sign from the + * charging direction (so an OEM's inverted sign convention can't flip the displayed sign). + */ + @RunWith(Parameterized.class) + public static class CurrentSign { + + @Parameter(0) public String label; + @Parameter(1) public int microAmps; + @Parameter(2) public boolean charging; + @Parameter(3) public int expectedMilliAmps; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"discharging negative reading", -450_000, false, -450}, + {"charging positive reading", 900_000, true, 900}, + {"charging but device reports negative -> sign from status", -450_000, true, 450}, + {"discharging but device reports positive -> sign from status", 450_000, false, -450}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expectedMilliAmps, BatteryRateTracker.signedCurrentMilliAmps(microAmps, charging)); + } + } + + /** + * {@link BatteryRateTracker#isPlausibleCurrentMicroAmps}: unsupported/out-of-range readings rejected. + */ + @RunWith(Parameterized.class) + public static class CurrentPlausibility { + + @Parameter(0) public String label; + @Parameter(1) public int microAmps; + @Parameter(2) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"unsupported MIN_VALUE", Integer.MIN_VALUE, false}, + {"MAX_VALUE sentinel", Integer.MAX_VALUE, false}, + {"zero is a valid reading", 0, true}, + {"typical discharge", -500_000, true}, + {"typical charge", 900_000, true}, + {"20A is implausible", 20_000_000, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, BatteryRateTracker.isPlausibleCurrentMicroAmps(microAmps)); + } + } + + /** + * {@link BatteryRateTracker#amberThreshold}: the amber line sits ~0.75x below the red limit. + */ + @RunWith(Parameterized.class) + public static class AmberThreshold { + + @Parameter(0) public int limit; + @Parameter(1) public int expected; + + @Parameters(name = "limit={0} -> amber={1}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {20, 15}, + {25, 19}, // round(18.75) + {10, 8}, // round(7.5) + {40, 30}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(expected, BatteryRateTracker.amberThreshold(limit)); + } + } + + /** + * {@link BatteryRateTracker#isChargingDirection}: charging and full map to the charging direction. + */ + @RunWith(Parameterized.class) + public static class Direction { + + @Parameter(0) public String label; + @Parameter(1) public int status; + @Parameter(2) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"charging", BatteryManager.BATTERY_STATUS_CHARGING, true}, + {"full", BatteryManager.BATTERY_STATUS_FULL, true}, + {"discharging", BatteryManager.BATTERY_STATUS_DISCHARGING, false}, + {"not charging", BatteryManager.BATTERY_STATUS_NOT_CHARGING, false}, + {"unknown", BatteryManager.BATTERY_STATUS_UNKNOWN, false}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, BatteryRateTracker.isChargingDirection(status)); + } + } + + /** + * {@link BatteryRateTracker#serializeSamples}/{@link BatteryRateTracker#parseSamples}: round-trip and + * malformed-entry skipping (validated, not caught — the project's "no silent catch" rule). + */ + public static class Serialization { + + @Test + public void roundTripPreservesSamples() { + final List samples = Arrays.asList( + new Sample(100, 50, -800_000), + new Sample(200, 49, NO_CURRENT)); + final List parsed = BatteryRateTracker.parseSamples(BatteryRateTracker.serializeSamples(samples)); + + assertEquals(samples, parsed); + } + + @Test + public void emptyStringParsesToEmpty() { + assertTrue(BatteryRateTracker.parseSamples("").isEmpty()); + } + + @Test + public void malformedEntriesAreSkipped() { + final List parsed = BatteryRateTracker.parseSamples("100:50:x;bad;200:60:-800000"); + + assertEquals(1, parsed.size()); + assertEquals(new Sample(200, 60, -800_000), parsed.get(0)); + } + } + + /** + * {@link BatteryRateTracker#appendAndTrim}: the min-spacing throttle and the trailing-window age trim. + */ + public static class Windowing { + + @Test + public void appendsFirstSample() { + final List result = BatteryRateTracker.appendAndTrim( + List.of(), new Sample(0, 50, -800_000), 0); + assertEquals(1, result.size()); + } + + @Test + public void throttlesSamplesCloserThanMinSpacing() { + final List window = List.of(new Sample(0, 50, -800_000)); + final List result = BatteryRateTracker.appendAndTrim(window, new Sample(10_000, 50, -800_000), 10_000); + assertEquals(1, result.size()); // 10s < 20s spacing: not added + } + + @Test + public void appendsOnceSpacingIsMet() { + final List window = List.of(new Sample(0, 50, -800_000)); + final List result = BatteryRateTracker.appendAndTrim(window, new Sample(20_000, 50, -800_000), 20_000); + assertEquals(2, result.size()); + } + + @Test + public void dropsSamplesOlderThanWindow() { + final List window = List.of(new Sample(0, 50, -800_000)); + final List result = BatteryRateTracker.appendAndTrim(window, new Sample(700_000, 45, -800_000), 700_000); + assertEquals(1, result.size()); // the 0-time sample is past the 10-min window + assertEquals(700_000, result.get(0).timeMillis()); + } + } +} From 281b236f4b9fdcec285ede097cd32826bea16f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 19:47:41 +0000 Subject: [PATCH 2/2] Harden BatteryRateTracker per code review (#120) Fixes from a review pass over the drain-rate feature: - parseSamples: close the overflow hole in the digit-length guards. A 10-digit int or 19-digit long passed validation but made parseInt/ parseLong throw, crashing the receiver on every broadcast while the pref was corrupted. Ints are now range-checked through a long (the full int range must stay accepted: the "no current" sentinel is Integer.MIN_VALUE itself); longs are capped at 18 digits. - record(): skip snapshots whose level/scale extras are invalid (-1), which read as 0% and would plant a bogus sample yielding a huge false drain rate against the next real reading. - record(): persist only when the window or direction actually changed, instead of rewriting the preferences file on every ACTION_BATTERY_ CHANGED (needless disk I/O in a battery app). - getRate(): age-trim the loaded window so a stale pre-restart window can't briefly surface as a current rate at service start. - Extract shared sameDirection/loadWindow/trimToWindow helpers so the record and read paths can't diverge on direction or freshness rules. - Reuse the rate computed by record() in the ongoing notification instead of re-reading and re-parsing the window in the same broadcast. - Clamp the stored drain limit to the slider's range on read; the bounds constants and pref_notification.xml now reference each other. Tests: overflow entries skipped without throwing, int boundary round-trip, invalid-level gating, age-trim (stale/future samples), and limit clamping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012LC6Qt7Nr8MpJHXuszizNF --- .../receiver/BatteryLevelReceiver.java | 7 +- .../service/BatteryRateTracker.java | 142 ++++++++++++++---- .../service/NotificationService.java | 36 ++++- app/src/main/res/xml/pref_notification.xml | 2 + .../service/BatteryRateTrackerTest.java | 107 +++++++++++++ 5 files changed, 256 insertions(+), 38 deletions(-) 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 3939070..262d385 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java @@ -67,10 +67,11 @@ public void onReceive(final Context context, final Intent intent) { // 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). - BatteryRateTracker.record(context, batteryDO); + final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(context, batteryDO); - // Keep the persistent foreground-service status notification live with the latest reading - NotificationService.updateOngoingNotification(context, batteryDO); + // Keep the persistent foreground-service status notification live with the latest reading, + // reusing the rate just computed instead of re-parsing the persisted sample window. + NotificationService.updateOngoingNotification(context, batteryDO, rate); if (batteryDO == null) { // Without a real reading, don't assume a level. Previously this defaulted to 100%, diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java index 0880f9d..9845a94 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -61,6 +61,8 @@ public final class BatteryRateTracker { static final int MAX_PLAUSIBLE_RATE_PPH = 500; // The "red / high drain" limit shared with the fast-drain alert (#109): default and accepted range. + // MIN/MAX must match the slider bounds (android:min/android:max) in pref_notification.xml; they are + // enforced when the preference is read, so an out-of-range stored value can't skew the red line. public static final int DEFAULT_DRAIN_LIMIT_PPH = 20; public static final int MIN_DRAIN_LIMIT_PPH = 5; public static final int MAX_DRAIN_LIMIT_PPH = 60; @@ -88,25 +90,32 @@ public static BatteryRate record(final Context context, final BatteryDO batteryD if (isNull(context) || isNull(batteryDO)) { return BatteryRate.empty(); } + // A snapshot with a missing/invalid scale reads as 0% (see BatteryDO.getBatteryPercentage), and + // recording it would plant a bogus level-0 sample whose delta against the next real reading + // yields a huge false drain rate. Fall back to a read-only computation instead. + if (!hasUsableLevel(batteryDO)) { + return getRate(context, batteryDO); + } + final long now = System.currentTimeMillis(); final boolean charging = isChargingDirection(batteryDO.getStatus()); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final boolean directionKnown = prefs.contains(PREF_RATE_CHARGING); - final boolean prevCharging = prefs.getInt(PREF_RATE_CHARGING, 0) == 1; - // A charge/discharge flip invalidates the whole window (mixed slopes/currents); start fresh. This - // also produces the brief post-unplug warm-up during which no rate is shown yet. - final List window = (directionKnown && prevCharging == charging) - ? parseSamples(prefs.getString(PREF_RATE_SAMPLES, "")) - : new ArrayList<>(); + final boolean sameDirection = sameDirection(prefs, charging); + final List window = loadWindow(prefs, charging, now); final int level = Math.round(batteryDO.getBatteryPercentage()); final List updated = appendAndTrim(window, new Sample(now, level, batteryDO.getCurrentMicroAmps()), now); - final SharedPreferences.Editor editor = prefs.edit(); - editor.putString(PREF_RATE_SAMPLES, serializeSamples(updated)); - editor.putInt(PREF_RATE_CHARGING, charging ? 1 : 0); - editor.apply(); + // Persist only when something actually changed. ACTION_BATTERY_CHANGED can fire every few seconds + // (voltage/temperature deltas); when the spacing throttle rejected the sample and nothing was + // trimmed, rewriting the whole preferences file would be needless disk I/O in a battery app. + if (!sameDirection || !updated.equals(window)) { + prefs.edit() + .putString(PREF_RATE_SAMPLES, serializeSamples(updated)) + .putInt(PREF_RATE_CHARGING, charging ? 1 : 0) + .apply(); + } return computeRate(updated, batteryDO.getCapacity(), charging, now, batteryDO.getCurrentMicroAmps()); } @@ -128,11 +137,52 @@ public static BatteryRate getRate(final Context context, final BatteryDO battery final boolean charging = isChargingDirection(batteryDO.getStatus()); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - // Ignore a window captured in the other direction (a flip that record() hasn't reset yet). - final boolean sameDirection = prefs.contains(PREF_RATE_CHARGING) && (prefs.getInt(PREF_RATE_CHARGING, 0) == 1) == charging; - final List window = sameDirection ? parseSamples(prefs.getString(PREF_RATE_SAMPLES, "")) : new ArrayList<>(); + return computeRate(loadWindow(prefs, charging, now), batteryDO.getCapacity(), charging, now, batteryDO.getCurrentMicroAmps()); + } + + /** + * Whether the snapshot's level reading can be trusted for the sample window. BatteryManager defaults + * the level/scale extras to -1 when unavailable; such a snapshot must not be recorded (its percentage + * reads as 0%). Pure so it is unit-testable. + * + * @param batteryDO the battery snapshot + * + * @return true when the level and scale form a real reading + */ + static boolean hasUsableLevel(final BatteryDO batteryDO) { + return batteryDO.getScale() > 0 && batteryDO.getLevel() >= 0; + } + + /** + * Whether the persisted window was captured in the same charge/discharge direction as the current + * snapshot. A flip invalidates the whole window (mixed slopes/currents), producing the brief + * post-unplug warm-up during which no rate is shown. + * + * @param prefs the default shared preferences + * @param charging the current direction + * + * @return true when a window exists and matches the direction + */ + private static boolean sameDirection(final SharedPreferences prefs, final boolean charging) { + return prefs.contains(PREF_RATE_CHARGING) && (prefs.getInt(PREF_RATE_CHARGING, 0) == 1) == charging; + } - return computeRate(window, batteryDO.getCapacity(), charging, now, batteryDO.getCurrentMicroAmps()); + /** + * Loads the persisted window for the current direction, age-trimmed to the trailing window so a + * stale pre-restart window (e.g. read at boot before the first {@link #record}) can't surface as a + * current rate. Returns an empty window on a direction flip. + * + * @param prefs the default shared preferences + * @param charging the current direction + * @param now current time in millis + * + * @return the loaded samples oldest-first (possibly empty) + */ + private static List loadWindow(final SharedPreferences prefs, final boolean charging, final long now) { + if (!sameDirection(prefs, charging)) { + return new ArrayList<>(); + } + return trimToWindow(parseSamples(prefs.getString(PREF_RATE_SAMPLES, "")), now); } /** @@ -158,16 +208,10 @@ static boolean isChargingDirection(final int status) { * @return the new window, oldest first */ static List appendAndTrim(final List window, final Sample sample, final long now) { - final List result = new ArrayList<>(window.size() + 1); - final long cutoff = now - WINDOW_MS; - for (final Sample s : window) { - if (s.timeMillis() >= cutoff && s.timeMillis() <= now) { - result.add(s); - } - } + final List result = trimToWindow(window, now); final boolean spacedEnough = result.isEmpty() || sample.timeMillis() - result.get(result.size() - 1).timeMillis() >= MIN_SAMPLE_SPACING_MS; - if (spacedEnough && sample.timeMillis() >= cutoff) { + if (spacedEnough && sample.timeMillis() >= now - WINDOW_MS) { result.add(sample); } // Guard against unbounded growth if the clock jumps; keep the most recent MAX_SAMPLES. @@ -177,6 +221,27 @@ static List appendAndTrim(final List window, final Sample sample return result; } + /** + * Drops samples outside the trailing window: older than {@link #WINDOW_MS} or future-dated (a clock + * jump). Pure so the age trim can be unit-tested; shared by the load and append paths so the two + * cannot disagree on what "fresh" means. + * + * @param window samples oldest-first + * @param now current time in millis + * + * @return a new list holding only the fresh samples, oldest-first + */ + static List trimToWindow(final List window, final long now) { + final List result = new ArrayList<>(window.size() + 1); + final long cutoff = now - WINDOW_MS; + for (final Sample s : window) { + if (s.timeMillis() >= cutoff && s.timeMillis() <= now) { + result.add(s); + } + } + return result; + } + /** * Computes the smoothed rate and signed instantaneous current from a window. Pure and Android-free * (apart from the {@code BatteryManager} status constants) so it is fully unit-testable. @@ -290,8 +355,22 @@ static int signedCurrentMilliAmps(final int microAmps, final boolean charging) { * @return the configured limit in %/h */ public static int getDrainLimitPercentPerHour(final Context context) { - return PreferenceManager.getDefaultSharedPreferences(context) - .getInt(context.getString(R.string._pref_key_fast_drain_limit), DEFAULT_DRAIN_LIMIT_PPH); + final int stored = PreferenceManager.getDefaultSharedPreferences(context) + .getInt(context.getString(R.string._pref_key_fast_drain_limit), DEFAULT_DRAIN_LIMIT_PPH); + return clampDrainLimit(stored); + } + + /** + * Clamps a stored drain limit to the accepted range, so a corrupt or out-of-range preference value + * can't skew the red line here or the fast-drain trigger in #109. The bounds mirror the slider's + * {@code android:min}/{@code android:max} in {@code pref_notification.xml}. Pure so it is unit-testable. + * + * @param stored the raw persisted limit in %/h + * + * @return the limit clamped to [{@link #MIN_DRAIN_LIMIT_PPH}, {@link #MAX_DRAIN_LIMIT_PPH}] + */ + static int clampDrainLimit(final int stored) { + return Math.max(MIN_DRAIN_LIMIT_PPH, Math.min(MAX_DRAIN_LIMIT_PPH, stored)); } /** @@ -374,11 +453,20 @@ static List parseSamples(final String serialized) { } private static boolean isLong(final String s) { - return s.matches("-?\\d{1,19}"); + // 18 digits max so Long.parseLong can't overflow (Long.MAX_VALUE has 19 digits, and a 19-digit + // value above it would throw). Real timestamps are 13 digits, so nothing valid is excluded. + return s.matches("-?\\d{1,18}"); } private static boolean isInt(final String s) { - return s.matches("-?\\d{1,10}"); + // Shape first, then a range check through a long: a 10-digit value like "9999999999" matches the + // shape but overflows Integer.parseInt. The full int range must stay accepted because the + // serialized "no current" sentinel is Integer.MIN_VALUE itself (10 digits). + if (!s.matches("-?\\d{1,10}")) { + return false; + } + final long value = Long.parseLong(s); + return value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE; } /** 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 31a071d..a507a9a 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -397,12 +397,27 @@ public static int getOngoingNotificationId() { * @return The built ongoing notification */ public static Notification buildOngoingNotification(final Context context, final BatteryDO batteryDO) { + return buildOngoingNotification(context, batteryDO, BatteryRateTracker.getRate(context, batteryDO)); + } + + /** + * Build the ongoing notification from an already-computed rate, so a caller that just fed the rate + * window (see {@link BatteryRateTracker#record}) doesn't trigger a second read-and-parse of the + * persisted samples (issue #108). + * + * @param context The application context + * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The precomputed charge/drain rate + * @return The built ongoing notification + */ + public static Notification buildOngoingNotification(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { createNotificationChannels(context); return new Notification.Builder(context, CHANNEL_ID_STATUS) .setSmallIcon(ongoingIconRes(batteryDO)) .setContentTitle(context.getString(R.string.app_name)) - .setContentText(statusText(context, batteryDO)) + .setContentText(statusText(context, batteryDO, rate)) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) .setOngoing(true) @@ -419,14 +434,16 @@ public static Notification buildOngoingNotification(final Context context, final * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The rate already computed while feeding the sample window */ - public static void updateOngoingNotification(final Context context, final BatteryDO batteryDO) { + public static void updateOngoingNotification(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { if (lacksNotificationPermission(context)) { return; } final NotificationManager manager = getNotificationManager(context); if (nonNull(manager)) { - manager.notify(ONGOING_NOTIFICATION_ID, buildOngoingNotification(context, batteryDO)); + manager.notify(ONGOING_NOTIFICATION_ID, buildOngoingNotification(context, batteryDO, rate)); } } @@ -896,13 +913,15 @@ private static PendingIntent createMainActivityIntent(final Context context) { * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The precomputed charge/drain rate * @return Formatted status text */ - private static String statusText(final Context context, final BatteryDO batteryDO) { + private static String statusText(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { final int percentage = isNull(batteryDO) ? 0 : Math.round(batteryDO.getBatteryPercentage()); final String statusLabel = SystemService.getStatusLabel(context, isNull(batteryDO) ? -1 : batteryDO.getStatus()); final String temperature = isNull(batteryDO) ? "" : TemperatureUtils.format(context, batteryDO.getTemperature()); - return context.getString(R.string.notification_status_content, percentage, statusWithRate(context, batteryDO, statusLabel), temperature); + return context.getString(R.string.notification_status_content, percentage, statusWithRate(context, batteryDO, statusLabel, rate), temperature); } /** @@ -912,10 +931,12 @@ private static String statusText(final Context context, final BatteryDO batteryD * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable * @param statusLabel The plain localized status label + * @param rate The precomputed charge/drain rate * @return The status label, optionally with the rate/current appended */ - private static String statusWithRate(final Context context, final BatteryDO batteryDO, final String statusLabel) { - if (isNull(batteryDO)) { + private static String statusWithRate(final Context context, final BatteryDO batteryDO, + final String statusLabel, final BatteryRateTracker.BatteryRate rate) { + if (isNull(batteryDO) || isNull(rate)) { return statusLabel; } final boolean showRate = PreferenceManager.getDefaultSharedPreferences(context) @@ -923,7 +944,6 @@ private static String statusWithRate(final Context context, final BatteryDO batt if (!showRate) { return statusLabel; } - final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.getRate(context, batteryDO); if (rate.hasRate()) { return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); } diff --git a/app/src/main/res/xml/pref_notification.xml b/app/src/main/res/xml/pref_notification.xml index b80d0d2..ad8f5f2 100644 --- a/app/src/main/res/xml/pref_notification.xml +++ b/app/src/main/res/xml/pref_notification.xml @@ -116,6 +116,8 @@ android:title="@string/pref_cat_title_drain" app:iconSpaceReserved="false"> + parsed = BatteryRateTracker.parseSamples( + "100:50:9999999999;9999999999999999999:50:-800000;-100:-9999999999:0;200:60:-800000"); + + assertEquals(1, parsed.size()); + assertEquals(new Sample(200, 60, -800_000), parsed.get(0)); + } + + @Test + public void intBoundaryValuesSurviveParsing() { + // The "no current" sentinel is Integer.MIN_VALUE itself — the overflow guard must keep + // accepting the full int range, not just 9-digit values. + final List samples = Arrays.asList( + new Sample(100, 50, Integer.MIN_VALUE), + new Sample(200, 49, Integer.MAX_VALUE)); + final List parsed = BatteryRateTracker.parseSamples(BatteryRateTracker.serializeSamples(samples)); + + assertEquals(samples, parsed); + } + } + + /** + * {@link BatteryRateTracker#hasUsableLevel}: snapshots whose level/scale extras defaulted to -1 + * (unavailable) must not be recorded — they'd read as a bogus 0% sample. + */ + public static class UsableLevel { + + @Test + public void validReadingIsUsable() { + assertTrue(BatteryRateTracker.hasUsableLevel(new BatteryDO().setLevel(50).setScale(100))); + } + + @Test + public void missingScaleIsNotUsable() { + assertFalse(BatteryRateTracker.hasUsableLevel(new BatteryDO().setLevel(50).setScale(-1))); + assertFalse(BatteryRateTracker.hasUsableLevel(new BatteryDO().setLevel(50).setScale(0))); + } + + @Test + public void missingLevelIsNotUsable() { + assertFalse(BatteryRateTracker.hasUsableLevel(new BatteryDO().setLevel(-1).setScale(100))); + } + } + + /** + * {@link BatteryRateTracker#trimToWindow}: stale and future-dated samples are dropped, so a window + * persisted before a shutdown can't surface as a current rate when read back at boot. + */ + public static class TrimToWindow { + + @Test + public void staleSamplesAreDropped() { + // Samples from "last night" (8h before now) must not survive the load. + final long now = 8L * 60 * 60 * 1000; + final List stale = Arrays.asList(new Sample(0, 80, -500_000), new Sample(600_000, 78, -500_000)); + + assertTrue(BatteryRateTracker.trimToWindow(stale, now).isEmpty()); + } + + @Test + public void freshSamplesAreKept() { + final List window = Arrays.asList( + new Sample(0, 50, -500_000), new Sample(300_000, 49, -500_000)); + + assertEquals(window, BatteryRateTracker.trimToWindow(window, 300_000)); + } + + @Test + public void futureDatedSamplesAreDropped() { + // A backwards clock jump leaves samples "from the future"; they must not be kept. + final List window = Arrays.asList(new Sample(500_000, 50, -500_000)); + + assertTrue(BatteryRateTracker.trimToWindow(window, 100_000).isEmpty()); + } + } + + /** + * {@link BatteryRateTracker#clampDrainLimit}: a stored limit outside the slider's range is clamped, + * so a corrupt preference can't skew the red line or the #109 trigger. + */ + @RunWith(Parameterized.class) + public static class ClampDrainLimit { + + @Parameter(0) public int stored; + @Parameter(1) public int expected; + + @Parameters(name = "stored={0} -> {1}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {20, 20}, // in range: unchanged + {BatteryRateTracker.MIN_DRAIN_LIMIT_PPH, 5}, // boundaries kept + {BatteryRateTracker.MAX_DRAIN_LIMIT_PPH, 60}, + {0, 5}, // below min: clamped up + {-3, 5}, + {999, 60}, // above max: clamped down + }); + } + + @Test + public void matchesExpected() { + assertEquals(expected, BatteryRateTracker.clampDrainLimit(stored)); + } } /**