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 604d571..94ab547 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -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 not 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 > 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. * 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 dc217ea..3236f00 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 @@ -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 < 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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c94e343..72404bf 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -30,6 +30,8 @@ معدل الاستهلاك معدل الشحن التيار + + الوقت حتى الاكتمال على البطارية مقبس طاقة @@ -154,6 +156,9 @@ %1$s%%/h %1$s mA + + ~%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 a80ae81..970f02e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ Drain rate Charge rate Current + + Time to full On Battery AC Charger @@ -162,6 +164,10 @@ %1$s%%/h %1$s mA + + ~%1$sh %2$sm + ~%1$sm Battery Drain Show rate in status notification The ongoing notification shows the live drain/charge rate next to the status 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 15a34f0..670cfea 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java @@ -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; @@ -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 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. */