Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ static List<Sample> trimToWindow(final List<Sample> 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).
* <p>
* The windowed <em>average</em> 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)
Expand All @@ -268,14 +273,52 @@ static List<Sample> trimToWindow(final List<Sample> window, final long now) {
*/
static BatteryRate computeRate(final List<Sample> 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<Sample> 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);
}

/**
Expand All @@ -296,18 +339,10 @@ private static int ratePercentPerHour(final List<Sample> 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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> valuesMap;
private Map<String, CharSequence> valuesMap;
private View viewRef;

// #94: when this device's charge counter can't be trusted, the Capacity row shows "Unknown" with a
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@
<!-- معدل الشحن/الاستهلاك (#108) -->
<string name="battery_rate_value">%1$s%%/h</string>
<string name="battery_current_value">%1$s mA</string>
<!-- #173: سطر المتوسط الأصغر تحت القيمة اللحظية. "avg" تبقى لاتينية عمداً (قرار المشرف): السطر
خليط لاتيني أصلاً (الإشارة والوحدة mA)، وتعريبها وحدها يكسر اتجاه النص. يعاد النظر لاحقاً. -->
<string name="battery_current_avg_line">avg: %1$s</string>
<!-- #124: الوحدات h/m تبقى لاتينية كبقية قيم المعدل -->
<string name="time_to_full_value_hm">~%1$sh %2$sm</string>
<string name="time_to_full_value_m">~%1$sm</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@
<string name="battery_rate_value">%1$s%%/h</string>
<!-- %1$s is a signed Western-digit number, e.g. "+900" or "−450" -->
<string name="battery_current_value">%1$s mA</string>
<!-- #173: the windowed average, rendered as its own smaller line under the instant current.
%1$s is the formatted battery_current_value ("−245 mA") -->
<string name="battery_current_avg_line">avg: %1$s</string>
<!-- #124: time-to-full duration. %1$s hours, %2$s minutes — Western-digit numbers (String.valueOf);
the h/m units stay Latin like the other rate values (#96). -->
<string name="time_to_full_value_hm">~%1$sh %2$sm</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Sample> 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<Sample> 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<Sample> 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
Expand Down Expand Up @@ -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<Sample> 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<Sample> 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<Sample> 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).
Expand Down Expand Up @@ -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.
*/
Expand Down