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 @@ -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
5 changes: 5 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 @@ -154,6 +156,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
6 changes: 6 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 @@ -162,6 +164,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