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 @@ -411,6 +411,45 @@ public static String formatCurrentValue(final Context context, final int signedM
return context.getString(R.string.battery_current_value, sign + Math.abs(signedMilliAmps));
}

/**
* 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
* unreliable (Kirin). Deliberately simple — it does <b>not</b> model the charge-curve taper above ~80%,
* so it overshoots near full; precision is a non-goal (the OS owns the accurate figure). Callers gate on
* charging + a trustworthy rate + a level below the taper top before showing it. Pure so it is testable.
*
* @param level current battery level (0-100)
* @param ratePercentPerHour smoothed charge-rate magnitude in %/h
*
* @return estimated minutes to full, or 0 when not computable (non-positive rate, or already at/above full)
*/
public static int estimateMinutesToFull(final int level, final int ratePercentPerHour) {
if (ratePercentPerHour <= 0 || level >= 100) {
return 0;
}
final int remaining = 100 - level;
return (int) Math.round(remaining * 60.0 / ratePercentPerHour);
}

/**
* Formats a time-to-full estimate as a compact {@code "~1h 20m"} / {@code "~45m"}, with Western digits
* in every locale (#96). The units stay Latin (h/m), matching {@code battery_rate_value} and
* {@code design_capacity_value}.
*
* @param context Application context
* @param totalMinutes estimated minutes to full (from {@link #estimateMinutesToFull}); must be &gt; 0
*
* @return the formatted duration string
*/
public static String formatTimeToFull(final Context context, final int totalMinutes) {
final int hours = totalMinutes / 60;
final int minutes = totalMinutes % 60;
if (hours > 0) {
return context.getString(R.string.time_to_full_value_hm, String.valueOf(hours), String.valueOf(minutes));
}
return context.getString(R.string.time_to_full_value_m, String.valueOf(minutes));
}

/**
* Serializes a window to a compact string ("t:level:currentUa" joined by ';'). Pure and testable.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,15 @@ private static String statusWithRate(final Context context, final BatteryDO batt
if (!showRate) {
return statusLabel;
}
// While charging, humans think in charger speed, not %/h — show the estimated tier + wattage when we
// can (#125). The ChargeSpeed read (CURRENT_NOW × voltage) is charging-only, so the discharging path
// below is untouched; it falls through to the %/h → mA → plain chain when the speed can't be estimated.
if (rate.charging()) {
final String chargingSpeed = chargingSpeedSegment(context);
if (nonNull(chargingSpeed)) {
return chargingSpeed;
}
}
if (rate.hasRate()) {
return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour());
}
Expand All @@ -1010,6 +1019,30 @@ private static String statusWithRate(final Context context, final BatteryDO batt
return statusLabel;
}

/**
* The charging-speed segment for the ongoing status notification — the estimated tier and wattage, e.g.
* "Fast charging · ~18 W", or just the tier ("Slow charging") when the wattage rounds below 1 W (#125).
* The tier label already reads as "… charging", so it replaces the plain "Charging" status word rather
* than being appended to it. Returns null when the speed can't be estimated (e.g. a device that doesn't
* report {@code CURRENT_NOW}), so the caller falls back to the %/h → mA → plain chain.
*
* @param context The application context
* @return the formatted charging-speed segment, or null when the charge speed is unknown
*/
private static String chargingSpeedSegment(final Context context) {
final ChargeSpeed speed = SystemService.getChargeSpeed(context);
if (!speed.isKnown()) {
return null;
}
final String tierLabel = context.getString(tierLabelRes(speed.getTier()));
final int watts = speed.getWatts();
if (watts < 1) {
return tierLabel; // sub-watt (e.g. trickle): "~0 W" would read as an error
}
// %2$s via String.valueOf keeps the wattage in Western digits (0-9) in every locale (#96).
return context.getString(R.string.notification_status_charge_power, tierLabel, String.valueOf(watts));
}

/**
* Choose a small icon for the ongoing notification that reflects the actual battery state: a
* charging bolt only while actively charging, otherwise a plain battery whose fill matches the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,12 +383,40 @@ private void addRateRows(final View view) {
valuesMap.put(rateLabel, BatteryRateTracker.formatRateValue(view.getContext(), rate.percentPerHour()));
rateValueColor = rateColor(view.getContext(), rate);
}
addTimeToFullRow(view, rate);
if (rate.hasCurrent()) {
valuesMap.put(getResources().getString(R.string.battery_current),
BatteryRateTracker.formatCurrentValue(view.getContext(), rate.currentMilliAmps()));
}
}

/**
* Adds the estimated time-to-full row directly below the charge rate it is derived from (#124). Shown
* only while charging, with a trustworthy rate, and below the taper top (level &lt; 99%) — hidden
* otherwise, so the user never sees a garbage "0m" or a wildly optimistic figure right as the charge
* tapers. The estimate is a rough linear projection (see
* {@link BatteryRateTracker#estimateMinutesToFull}); precision is a non-goal — the OS owns the accurate
* number. Reuses the already-computed {@code rate}; only the level is read here.
*
* @param view The fragment view
* @param rate The already-computed rate for this refresh
*/
private void addTimeToFullRow(final View view, final BatteryRateTracker.BatteryRate rate) {
if (!rate.charging() || !rate.hasRate()) {
return;
}
final int level = Math.round(batteryDO.getBatteryPercentage());
if (level >= 99) {
return;
}
final int minutes = BatteryRateTracker.estimateMinutesToFull(level, rate.percentPerHour());
if (minutes <= 0) {
return;
}
valuesMap.put(getResources().getString(R.string.time_to_full),
BatteryRateTracker.formatTimeToFull(view.getContext(), minutes));
}

/**
* 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
7 changes: 7 additions & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
<string name="drain_rate">معدل الاستهلاك</string>
<string name="charge_rate">معدل الشحن</string>
<string name="battery_current">التيار</string>
<!-- #124: الوقت المقدّر حتى اكتمال الشحن -->
<string name="time_to_full">الوقت حتى الاكتمال</string>

<string name="battery">على البطارية</string>
<string name="charger_ac">مقبس طاقة</string>
Expand Down Expand Up @@ -119,6 +121,8 @@
<string name="charge_connected_power">%1$s · %2$s · ~%3$d واط</string>
<string name="charge_connected_tier">%1$s · %2$s</string>
<string name="charge_connected_plain">شحن %1$s</string>
<!-- #125: إشعار الحالة أثناء الشحن — الفئة + الواط المقدّر، مثل "شحن سريع · ~18 واط" -->
<string name="notification_status_charge_power">%1$s · ~%2$s واط</string>

<!-- "Charge notification style" preference (issue #122) -->
<string name="charge_notification_style_title">أسلوب تنبيه الشحن</string>
Expand Down Expand Up @@ -154,6 +158,9 @@
<!-- معدل الشحن/الاستهلاك (#108) -->
<string name="battery_rate_value">%1$s%%/h</string>
<string name="battery_current_value">%1$s mA</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>
<string name="pref_cat_title_drain">استهلاك البطارية</string>
<string name="show_rate_in_notification">إظهار المعدل في إشعار الحالة</string>
<string name="show_rate_in_notification_summary_on">يعرض الإشعار المستمر معدل الشحن/الاستهلاك المباشر بجانب الحالة</string>
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
<string name="drain_rate">Drain rate</string>
<string name="charge_rate">Charge rate</string>
<string name="battery_current">Current</string>
<!-- #124: estimated time to full, shown below the charge rate while charging -->
<string name="time_to_full">Time to full</string>

<string name="battery">On Battery</string>
<string name="charger_ac">AC Charger</string>
Expand Down Expand Up @@ -128,6 +130,10 @@
<string name="charge_connected_tier">%1$s · %2$s</string>
<!-- e.g. "Wired charging" (when the charging speed can't be estimated) -->
<string name="charge_connected_plain">%1$s charging</string>
<!-- #125: ongoing status notification while charging — tier + estimated watts (no wired/wireless
source needed here), e.g. "Fast charging · ~18 W". %2$s is Western-digit (String.valueOf), 0-9
in every locale (#96). -->
<string name="notification_status_charge_power">%1$s · ~%2$s W</string>

<!-- "Charge notification style" preference (issue #122). The summary shows the current choice
via useSimpleSummaryProvider, so no separate summary string is needed. -->
Expand Down Expand Up @@ -162,6 +168,10 @@
<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>
<!-- #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>
<string name="time_to_full_value_m">~%1$sm</string>
<string name="pref_cat_title_drain">Battery Drain</string>
<string name="show_rate_in_notification">Show rate in status notification</string>
<string name="show_rate_in_notification_summary_on">The ongoing notification shows the live drain/charge rate next to the status</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package com.almothafar.simplebatterynotifier.service;

import android.content.Context;
import android.os.BatteryManager;

import androidx.test.core.app.ApplicationProvider;

import com.almothafar.simplebatterynotifier.model.BatteryDO;
import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.BatteryRate;
import com.almothafar.simplebatterynotifier.service.BatteryRateTracker.Sample;

import org.junit.Before;
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 org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;

import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -252,6 +258,78 @@ public void matchesExpected() {
}
}

/**
* {@link BatteryRateTracker#estimateMinutesToFull}: the capacity-free linear projection (#124) and its
* not-computable guards. Boundaries: the taper-top levels (99/100), a zero/negative rate, and rounding.
*/
@RunWith(Parameterized.class)
public static class EstimateMinutesToFull {

@Parameter(0) public String label;
@Parameter(1) public int level;
@Parameter(2) public int ratePercentPerHour;
@Parameter(3) public int expectedMinutes;

@Parameters(name = "{0}")
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
{"50% at 30%/h -> 100m", 50, 30, 100}, // 50/30 h = 1h40m
{"80% at 60%/h -> 20m", 80, 60, 20}, // 20/60 h
{"20% at 10%/h -> 480m", 20, 10, 480}, // 80/10 h = 8h
{"rounds to nearest minute", 55, 40, 68}, // 45/40 h = 67.5m -> 68
{"one point left at 60%/h -> 1m", 99, 60, 1}, // helper allows; the UI gates level >= 99
{"zero rate not computable", 50, 0, 0},
{"negative rate not computable", 50, -5, 0},
{"already full not computable", 100, 30, 0},
});
}

@Test
public void matchesExpected() {
assertEquals(label, expectedMinutes, BatteryRateTracker.estimateMinutesToFull(level, ratePercentPerHour));
}
}

/**
* {@link BatteryRateTracker#formatTimeToFull}: the "~1h 20m" / "~45m" rendering and its Western-digit
* guarantee (#96). Context-dependent (string resources), so it runs under Robolectric — unlike the pure
* math above; mirrors how the other Context-backed formatters would be exercised.
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public static class FormatTimeToFull {

private Context context;

@Before
public void setUp() {
context = ApplicationProvider.getApplicationContext();
}

@Test
public void hoursAndMinutes() {
assertEquals("~1h 20m", BatteryRateTracker.formatTimeToFull(context, 80));
}

@Test
public void minutesOnly() {
assertEquals("~45m", BatteryRateTracker.formatTimeToFull(context, 45));
}

@Test
public void wholeHoursStillShowMinutes() {
assertEquals("~2h 0m", BatteryRateTracker.formatTimeToFull(context, 120));
}

@Test
@Config(qualifiers = "ar")
public void keepsWesternDigitsUnderArabicLocale() {
// values-ar keeps the "~%1$sh %2$sm" shape and String.valueOf keeps the digits 0-9 (#96), so the
// Arabic render is identical — a regression to %d here would surface Arabic-Indic digits.
assertEquals("~1h 20m", BatteryRateTracker.formatTimeToFull(context, 80));
}
}

/**
* {@link BatteryRateTracker#isChargingDirection}: charging and full map to the charging direction.
*/
Expand Down