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 5fb99a9..21bcb3e 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -257,6 +257,11 @@ static List trimToWindow(final List window, final long now) { * 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 — and only when it clears {@link #MIN_DISPLAY_CURRENT_MA} (#152). + *

+ * The windowed average current is reported alongside the instant (#173), and when available + * it also takes over the floor gate: the average moves slowly, so the row's visibility is stable — + * a momentary dip below the floor no longer blinks the row out, while sustained-garbage readings + * (whose average is also below the floor) stay hidden. * * @param window samples oldest-first * @param capacityMah measured full capacity in mAh, or 0 when unknown/untrusted (#69) @@ -268,14 +273,52 @@ static List trimToWindow(final List window, final long now) { */ static BatteryRate computeRate(final List window, final int capacityMah, final boolean charging, final long nowMillis, final int latestCurrentMicroAmps) { + // Floor-gate on the windowed average when available (#173): the average moves slowly, so the + // row's visibility can't flicker with a momentary dip, and sustained garbage (Kirin pre-#152-v2 + // calibration averages ~1 mA) stays hidden. The instant gates itself only while the window is + // still too fresh to average. + final int avgMicroAmps = averagedCurrentMicroAmps(window); + final boolean hasAvg = avgMicroAmps != PROPERTY_UNSUPPORTED; + final int floorGateMicroAmps = hasAvg ? avgMicroAmps : latestCurrentMicroAmps; final boolean hasCurrent = isPlausibleCurrentMicroAmps(latestCurrentMicroAmps) - && Math.round(Math.abs(latestCurrentMicroAmps) / 1000f) >= MIN_DISPLAY_CURRENT_MA; + && Math.round(Math.abs(floorGateMicroAmps) / 1000f) >= MIN_DISPLAY_CURRENT_MA; final int signedMilliAmps = hasCurrent ? signedCurrentMilliAmps(latestCurrentMicroAmps, charging) : 0; + final boolean hasAvgCurrent = hasCurrent && hasAvg; + final int signedAvgMilliAmps = hasAvgCurrent ? signedCurrentMilliAmps(avgMicroAmps, 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); + return new BatteryRate(hasRate, hasRate ? pph : 0, charging, hasCurrent, signedMilliAmps, hasAvgCurrent, signedAvgMilliAmps); + } + + /** + * The averaged instantaneous current over the window in µA, or {@link Integer#MIN_VALUE} when the + * window doesn't yet hold enough spaced plausible readings — the same smoothing criteria source A + * uses ({@link #MIN_CURRENT_SAMPLES} readings spanning {@link #MIN_SPAN_CURRENT_MS}), extracted so + * the rate source and the displayed average (#173) can't disagree. Pure so it is unit-testable. + * + * @param window samples oldest-first + * + * @return averaged current in µA, or {@link Integer#MIN_VALUE} when not enough data yet + */ + static int averagedCurrentMicroAmps(final List window) { + if (window.size() < 2) { + return PROPERTY_UNSUPPORTED; + } + final long spanMs = window.get(window.size() - 1).timeMillis() - window.get(0).timeMillis(); + double sumMicroAmps = 0; + int currentSamples = 0; + for (final Sample s : window) { + if (isPlausibleCurrentMicroAmps(s.currentMicroAmps())) { + sumMicroAmps += s.currentMicroAmps(); + currentSamples++; + } + } + if (currentSamples < MIN_CURRENT_SAMPLES || spanMs < MIN_SPAN_CURRENT_MS) { + return PROPERTY_UNSUPPORTED; + } + return (int) Math.round(sumMicroAmps / currentSamples); } /** @@ -296,18 +339,10 @@ private static int ratePercentPerHour(final List window, final int capac 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 A: averaged current / capacity (average shared with the displayed avg current, #173). + final int avgMicroAmps = averagedCurrentMicroAmps(window); + if (capacityMah > 0 && avgMicroAmps != PROPERTY_UNSUPPORTED) { + return (int) Math.round(Math.abs(avgMicroAmps / 1000.0) / capacityMah * 100.0); } // Source B: level change over time (capacity-free). @@ -416,6 +451,22 @@ public static String formatCurrentValue(final Context context, final int signedM return context.getString(R.string.battery_current_value, sign + Math.abs(signedMilliAmps)); } + /** + * Formats the windowed-average line shown under the instantaneous current, e.g. + * {@code "avg: −245 mA"} (#173): the moment value stays the headline; this line gives it a stable + * anchor. The value goes through {@link #formatCurrentValue} so the sign, unit, and Western-digit + * guarantee (#96) can't drift from the instant above it. The caller renders it as a second, + * smaller line (see {@code BatteryDetailsFragment}). + * + * @param context Application context + * @param signedAvgMilliAmps signed windowed-average current in mA + * + * @return the formatted average line + */ + public static String formatAverageCurrentLine(final Context context, final int signedAvgMilliAmps) { + return context.getString(R.string.battery_current_avg_line, formatCurrentValue(context, signedAvgMilliAmps)); + } + /** * Estimated minutes until full from the smoothed charge rate (#124): a capacity-free linear projection, * {@code (100 − level) / ratePercentPerHour} hours, so it works even where the charge counter is @@ -525,19 +576,23 @@ 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}) + * flag saying whether it is trustworthy enough to display, plus the windowed average current shown + * next to the instant (#173). + * + * @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}) + * @param hasAvgCurrent whether the windowed average current is available for display + * @param avgCurrentMilliAmps signed windowed-average current in mA (valid only when {@code hasAvgCurrent}) */ public record BatteryRate(boolean hasRate, int percentPerHour, boolean charging, - boolean hasCurrent, int currentMilliAmps) { + boolean hasCurrent, int currentMilliAmps, + boolean hasAvgCurrent, int avgCurrentMilliAmps) { static BatteryRate empty() { - return new BatteryRate(false, 0, false, false, 0); + return new BatteryRate(false, 0, false, false, 0, false, 0); } } } 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 3236f00..860c1ea 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 @@ -7,6 +7,10 @@ import android.content.Context; import android.os.Bundle; import android.provider.Settings; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; @@ -47,8 +51,11 @@ public class BatteryDetailsFragment extends Fragment { private static final int SCROLL_HINT_BOB_COUNT = 2; // how many bobs private static final int SCROLL_HINT_REVEAL_DP = 96; // max peek distance + // #173: the avg line under the Current value renders at this fraction of the cell's text size. + private static final float AVG_LINE_SIZE_RATIO = 0.8f; + private BatteryDO batteryDO; - private Map valuesMap; + private Map valuesMap; private View viewRef; // #94: when this device's charge counter can't be trusted, the Capacity row shows "Unknown" with a @@ -210,7 +217,7 @@ public void onAnimationEnd(final Animator animation) { * * @return The created table row */ - private TableRow createTableRow(final View view, final String label, final String value, + private TableRow createTableRow(final View view, final String label, final CharSequence value, final int cellPadding, final int cellPaddingTop, final boolean valueUnreliable, final int valueColor) { final TableRow row = new TableRow(view.getContext()); @@ -290,7 +297,7 @@ private TextView createSeparatorTextView(final View view) { * @return The created TextView */ private TextView createValueTextView(final View view, - final String text, + final CharSequence text, final int cellPadding, final int cellPaddingTop, final boolean unreliable, @@ -385,8 +392,7 @@ private void addRateRows(final View view) { } addTimeToFullRow(view, rate); if (rate.hasCurrent()) { - valuesMap.put(getResources().getString(R.string.battery_current), - BatteryRateTracker.formatCurrentValue(view.getContext(), rate.currentMilliAmps())); + valuesMap.put(getResources().getString(R.string.battery_current), currentValueText(view, rate)); } } @@ -417,6 +423,32 @@ private void addTimeToFullRow(final View view, final BatteryRateTracker.BatteryR BatteryRateTracker.formatTimeToFull(view.getContext(), minutes)); } + /** + * The Current row's value (#173): the moment value as the headline, and — once the window has + * enough data — the windowed average on its own second line, rendered smaller and in the label + * colour so it reads as a quiet anchor under the ticking instant. A second line (rather than an + * inline "(avg: …)") because the inline form wrapped mid-parenthesis in narrow columns, larger + * font scales, and Arabic. + * + * @param view The fragment view + * @param rate The already-computed rate for this refresh + * + * @return the styled value text for the Current row + */ + private CharSequence currentValueText(final View view, final BatteryRateTracker.BatteryRate rate) { + final String instant = BatteryRateTracker.formatCurrentValue(view.getContext(), rate.currentMilliAmps()); + if (!rate.hasAvgCurrent()) { + return instant; + } + final String avgLine = BatteryRateTracker.formatAverageCurrentLine(view.getContext(), rate.avgCurrentMilliAmps()); + final SpannableString styled = new SpannableString(instant + "\n" + avgLine); + final int avgStart = instant.length() + 1; + styled.setSpan(new RelativeSizeSpan(AVG_LINE_SIZE_RATIO), avgStart, styled.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + styled.setSpan(new ForegroundColorSpan(GeneralHelper.getColor(getResources(), R.color.battery_details_label_color)), + avgStart, styled.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return styled; + } + /** * 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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0a39f39..dc8b54b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -158,6 +158,9 @@ %1$s%%/h %1$s mA + + avg: %1$s ~%1$sh %2$sm ~%1$sm diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 184e5c3..af52e9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,6 +168,9 @@ %1$s%%/h %1$s mA + + avg: %1$s ~%1$sh %2$sm diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java index d62f6e4..84b5c3c 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java @@ -155,6 +155,50 @@ public void negligibleRate_roundsToZeroAndHides() { assertEquals(-10, rate.currentMilliAmps()); } + @Test + public void momentaryDipBelowFloor_staysShownViaAverageGate() { + // #173: a real ~278 mA draw whose latest instant dips to 6 mA — the windowed average carries + // the floor gate, so the row doesn't blink out; the instant stays the displayed headline. + final List window = Arrays.asList( + new Sample(0, 50, -278_000), + new Sample(30_000, 50, -278_000), + new Sample(60_000, 50, -278_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 60_000, -6_000); + + assertTrue(rate.hasCurrent()); + assertEquals(-6, rate.currentMilliAmps()); + assertTrue(rate.hasAvgCurrent()); + assertEquals(-278, rate.avgCurrentMilliAmps()); + } + + @Test + public void sustainedSubFloorAverage_isHidden() { + // #173: when the *average* is also below the floor (Kirin pre-calibration garbage, deep + // sleep), the row stays hidden — the stable gate hides sustained noise, not just instants. + final List window = Arrays.asList( + new Sample(0, 50, -800), + new Sample(30_000, 50, -800), + new Sample(60_000, 50, -800)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 60_000, -800); + + assertFalse(rate.hasCurrent()); + assertFalse(rate.hasAvgCurrent()); + } + + @Test + public void freshWindow_instantGatesItselfWithoutAverage() { + // Two samples can't average yet (#173): the instant carries the floor gate alone and the + // avg segment is absent. + final List window = Arrays.asList( + new Sample(0, 50, -208_000), + new Sample(20_000, 50, -208_000)); + final BatteryRate rate = BatteryRateTracker.computeRate(window, 0, false, 20_000, -208_000); + + assertTrue(rate.hasCurrent()); + assertEquals(-208, rate.currentMilliAmps()); + assertFalse(rate.hasAvgCurrent()); + } + @Test public void kirinMisreadCurrent_isHiddenWhileRateStillShown() { // Kirin/HiSilicon reports CURRENT_NOW in mA, not µA (#152): a real ~800 mA draw arrives as @@ -214,6 +258,53 @@ public void currentUnsupported_isHidden() { } } + /** + * {@link BatteryRateTracker#averagedCurrentMicroAmps}: the shared windowed average (#173) — needs + * enough spaced plausible readings, skips implausible ones, and signals "not yet" otherwise. + */ + public static class AveragedCurrent { + + @Test + public void enoughSamples_returnsMean() { + final List window = Arrays.asList( + new Sample(0, 50, -300_000), + new Sample(30_000, 50, -200_000), + new Sample(60_000, 50, -250_000)); + + assertEquals(-250_000, BatteryRateTracker.averagedCurrentMicroAmps(window)); + } + + @Test + public void implausibleSamplesAreSkipped() { + // The NO_CURRENT sentinel doesn't drag the mean; but with it skipped only 2 plausible + // readings remain, below MIN_CURRENT_SAMPLES -> not yet. + final List window = Arrays.asList( + new Sample(0, 50, -300_000), + new Sample(30_000, 50, NO_CURRENT), + new Sample(60_000, 50, -200_000)); + + assertEquals(NO_CURRENT, BatteryRateTracker.averagedCurrentMicroAmps(window)); + } + + @Test + public void shortSpan_isNotEnough() { + // 3 readings but only 30s of span (< MIN_SPAN_CURRENT_MS): a burst, not a smoothed average. + final List window = Arrays.asList( + new Sample(0, 50, -300_000), + new Sample(15_000, 50, -200_000), + new Sample(30_000, 50, -250_000)); + + assertEquals(NO_CURRENT, BatteryRateTracker.averagedCurrentMicroAmps(window)); + } + + @Test + public void tooFewSamples_isNotEnough() { + assertEquals(NO_CURRENT, BatteryRateTracker.averagedCurrentMicroAmps(List.of())); + assertEquals(NO_CURRENT, BatteryRateTracker.averagedCurrentMicroAmps( + List.of(new Sample(0, 50, -300_000)))); + } + } + /** * {@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). @@ -367,6 +458,40 @@ public void keepsWesternDigitsUnderArabicLocale() { } } + /** + * {@link BatteryRateTracker#formatAverageCurrentLine}: the "avg: −245" line under the Current value + * (#173) — signed like the instant, unitless for compactness, Western digits in every locale (#96). + */ + @RunWith(RobolectricTestRunner.class) + @Config(sdk = 34) + public static class FormatAverageCurrentLine { + + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + } + + @Test + public void dischargingSignsNegative() { + assertEquals("avg: −245 mA", BatteryRateTracker.formatAverageCurrentLine(context, -245)); + } + + @Test + public void chargingSignsPositive() { + assertEquals("avg: +900 mA", BatteryRateTracker.formatAverageCurrentLine(context, 900)); + } + + @Test + @Config(qualifiers = "ar") + public void arabicKeepsLatinAvgLabel() { + // Deliberately identical to English (maintainer decision): the line is Latin-heavy already + // (sign + mA), so a lone Arabic word would just break the text direction. Revisit later. + assertEquals("avg: −245 mA", BatteryRateTracker.formatAverageCurrentLine(context, -245)); + } + } + /** * {@link BatteryRateTracker#isChargingDirection}: charging and full map to the charging direction. */