From 61b73e09b58b8bdbfedb834d0ddbc08639eff5c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:16:13 +0000 Subject: [PATCH 1/2] Ongoing notification: headline data in the title instead of the app name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status notification's title repeated the app name that Android already prints in the header, wasting the most glanceable line. The title now carries the headline data (live percentage + status/rate, e.g. "85% · Discharging 9%/h"), and the freed content line shows the estimated time to empty/full — reusing the details table's projection (#124/#188) — plus the temperature, e.g. "~9h 27m left · 34.6 °C". The existing show-rate setting governs both the appended rate and the derived time estimate, so turning it off still yields a minimal status-and-temperature notification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017qfVAwqfzKvg3tbg8u4qox --- .../service/NotificationService.java | 97 +++++++++++++++---- app/src/main/res/values-ar/strings.xml | 12 ++- app/src/main/res/values/strings.xml | 14 ++- .../service/NotificationServiceTest.java | 68 +++++++++++++ 4 files changed, 167 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java index 3879e42..74c3b1a 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -557,8 +557,8 @@ public static Notification buildOngoingNotification(final Context context, final return new Notification.Builder(context, CHANNEL_ID_STATUS) .setSmallIcon(ongoingIconRes(batteryDO)) - .setContentTitle(context.getString(R.string.app_name)) - .setContentText(statusText(context, batteryDO, rate)) + .setContentTitle(statusTitle(context, batteryDO, rate)) + .setContentText(statusDetail(context, batteryDO, rate)) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) .setOngoing(true) @@ -1116,26 +1116,94 @@ private static PendingIntent createMainActivityIntent(final Context context) { } /** - * Build the content line of the ongoing status notification, e.g. "85.12% · Discharging 9%/h · 32.0 °C". + * Build the title line of the ongoing status notification, e.g. "85.12% · Discharging 9%/h". *

- * The percentage is the same live value the home gauge shows — two decimals when the device + * The title carries the headline data, not the app name — Android already prints the app name in + * the notification header, so repeating it in the title wasted the most glanceable line. The + * percentage is the same live value the home gauge shows — two decimals when the device * genuinely resolves below one percent, whole otherwise (#158); only the level alerts - * (critical/warning) stay integer. The middle segment appends the charge/drain rate to the status - * label when available, falling back to the raw mA, then to the plain label — always showing the - * best number on hand (issue #108). The appended rate is gated by a user setting (default on); the + * (critical/warning) stay integer. The status segment appends the charge/drain rate when + * available, falling back to the raw mA, then to the plain label — always showing the best + * number on hand (issue #108). The appended rate is gated by a user setting (default on); the * plain label always shows. * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable * @param rate The precomputed charge/drain rate - * @return Formatted status text + * @return Formatted title text */ - private static String statusText(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { + static String statusTitle(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { final String percentage = BatteryPercentFormatter.formatLive(batteryDO); final String statusLabel = SystemService.getStatusLabel(context, isNull(batteryDO) ? -1 : batteryDO.getStatus()); + return context.getString(R.string.notification_status_title, percentage, statusWithRate(context, batteryDO, statusLabel, rate)); + } + + /** + * Build the content line of the ongoing status notification — the secondary detail under the + * headline: the estimated time to empty/full plus the temperature, e.g. "~9h 26m left · 34.6 °C" + * or "~45m to full · 34.6 °C", degrading to just the temperature when no trustworthy rate is + * available (or the user turned the rate display off — the estimate is derived from the same + * tracker, so the toggle governs both). The projection is the same capacity-free linear one the + * details table shows (#124/#188); precision is a non-goal — the OS owns the accurate figure. + * + * @param context The application context + * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The precomputed charge/drain rate + * @return Formatted detail text (empty when nothing is known) + */ + static String statusDetail(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { final String temperature = isNull(batteryDO) ? "" : TemperatureUtils.format(context, batteryDO.getTemperature()); - return context.getString(R.string.notification_status_content, percentage, statusWithRate(context, batteryDO, statusLabel, rate), temperature); + final String time = timeEstimateSegment(context, batteryDO, rate); + if (isNull(time)) { + return temperature; + } + if (temperature.isEmpty()) { + return time; + } + return context.getString(R.string.notification_status_detail, time, temperature); + } + + /** + * The estimated-time segment of the detail line: "~9h 26m left" while discharging, "~45m to full" + * while charging — mirroring the details table's time row (#124/#188), including its gating: a + * trustworthy %/h and a non-degenerate estimate (not already full/empty). Returns null when no + * figure should be shown, so the caller falls back to the temperature alone. + * + * @param context The application context + * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The precomputed charge/drain rate + * @return the formatted time segment, or null when no estimate is available + */ + private static String timeEstimateSegment(final Context context, final BatteryDO batteryDO, + final BatteryRateTracker.BatteryRate rate) { + if (isNull(batteryDO) || isNull(rate) || !rate.hasRate() || !showRateEnabled(context)) { + return null; + } + final int level = batteryDO.getBatteryPercentageInt(); + final int minutes = rate.charging() + ? BatteryRateTracker.estimateMinutesToFull(level, rate.percentPerHour()) + : BatteryRateTracker.estimateMinutesToEmpty(level, rate.percentPerHour()); + if (minutes <= 0) { + return null; + } + final String duration = BatteryRateTracker.formatDuration(context, minutes); + return context.getString(rate.charging() + ? R.string.notification_status_time_to_full + : R.string.notification_status_time_left, duration); + } + + /** + * Whether the "show rate in status notification" setting is on (default on). Governs both the + * rate appended to the status segment and the derived time estimate in the detail line. + * + * @param context The application context + * @return true when the rate (and its derived estimate) should be shown + */ + private static boolean showRateEnabled(final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); } /** @@ -1150,12 +1218,7 @@ private static String statusText(final Context context, final BatteryDO batteryD */ private static String statusWithRate(final Context context, final BatteryDO batteryDO, final String statusLabel, final BatteryRateTracker.BatteryRate rate) { - if (isNull(batteryDO) || isNull(rate)) { - return statusLabel; - } - final boolean showRate = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); - if (!showRate) { + if (isNull(batteryDO) || isNull(rate) || !showRateEnabled(context)) { return statusLabel; } // While charging, humans think in charger speed, not %/h — show the estimated tier + wattage when we diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9ea78ee..b8af0eb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -165,7 +165,13 @@ حالة البطارية المستمرة أثناء تفعيل المراقبة تنبيهات صامتة تنبيهات البطارية تُعرض بصمت أثناء ساعات الهدوء - %1$s · %2$s · %3$s + + %1$s · %2$s + + %1$s · %2$s + + يتبقى %1$s + %1$s حتى الاكتمال تنبيه ارتفاع الحرارة @@ -190,8 +196,8 @@ ~%1$sm استهلاك البطارية إظهار المعدل في إشعار الحالة - يعرض الإشعار المستمر معدل الشحن/الاستهلاك المباشر بجانب الحالة - يعرض الإشعار المستمر تسمية الحالة فقط + يعرض الإشعار المستمر معدل الشحن/الاستهلاك المباشر والوقت المتبقي المقدّر + يعرض الإشعار المستمر تسمية الحالة ودرجة الحرارة فقط حد الاستهلاك المرتفع يتحول معدل الاستهلاك إلى اللون الكهرماني عند الاقتراب من هذا الحد وإلى الأحمر عند بلوغه أو تجاوزه، ويُطلق تنبيه الاستهلاك السريع عند بقائه عند الحد. يُقاس بنقاط مئوية في الساعة. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 108db80..658fc70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -173,8 +173,14 @@ Quiet Alerts Battery alerts shown silently during your quiet hours - - %1$s · %2$s · %3$s + + %1$s · %2$s + + %1$s · %2$s + + %1$s left + %1$s to full High Temperature Alert @@ -202,8 +208,8 @@ ~%1$sm Battery Drain Show rate in status notification - The ongoing notification shows the live drain/charge rate next to the status - The ongoing notification shows only the status label + The ongoing notification shows the live drain/charge rate and the estimated time remaining + The ongoing notification shows only the status label and temperature High drain limit The drain rate turns amber near this limit and red at or above it, and the fast-drain alert fires when it stays there. Measured in percentage points per hour. diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java index 8d5894e..e96d1ca 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -2,14 +2,18 @@ import android.Manifest; import android.app.Application; +import android.app.Notification; import android.app.NotificationManager; import android.content.Context; +import android.os.BatteryManager; import androidx.preference.PreferenceManager; import androidx.test.core.app.ApplicationProvider; import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.model.ChargeSpeed; +import com.almothafar.simplebatterynotifier.util.TemperatureUtils; import org.junit.Before; import org.junit.Test; @@ -240,6 +244,70 @@ public void nullAlertType_postsNothing() { } } + /** + * The two lines of the ongoing status notification: the title carries the headline data + * (percentage + status/rate) instead of repeating the app name the header already shows, and + * the content line carries the time estimate + temperature. + */ + @RunWith(RobolectricTestRunner.class) + @Config(sdk = 34) + public static class OngoingStatusLines { + + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + } + + private static BatteryDO discharging85() { + return new BatteryDO().setLevel(85).setScale(100) + .setStatus(BatteryManager.BATTERY_STATUS_DISCHARGING) + .setTemperature(346); + } + + private static BatteryRateTracker.BatteryRate rate(final boolean charging, final int pph) { + return new BatteryRateTracker.BatteryRate(true, pph, charging, false, 0, false, 0); + } + + @Test + public void builtNotificationTitle_isHeadlineData_notAppName() { + final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rate(false, 9)); + assertEquals("85% · Discharging 9%/h", String.valueOf(built.extras.getCharSequence(Notification.EXTRA_TITLE))); + } + + @Test + public void detail_showsTimeLeftAndTemperature_whileDischarging() { + // 85% at 9 %/h → 566.67 min, rounded to 567 → "~9h 27m". + final String expected = "~9h 27m left · " + TemperatureUtils.format(context, 346); + assertEquals(expected, NotificationService.statusDetail(context, discharging85(), rate(false, 9))); + } + + @Test + public void detail_showsTimeToFull_whileCharging() { + final BatteryDO charging = discharging85().setLevel(80) + .setStatus(BatteryManager.BATTERY_STATUS_CHARGING); + // 20 points to full at 20 %/h → exactly 60 min → "~1h 0m". + final String expected = "~1h 0m to full · " + TemperatureUtils.format(context, 346); + assertEquals(expected, NotificationService.statusDetail(context, charging, rate(true, 20))); + } + + @Test + public void detail_fallsBackToTemperature_whenRateDisplayOff() { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(context.getString(R.string._pref_key_show_rate_in_notification), false) + .commit(); + assertEquals(TemperatureUtils.format(context, 346), + NotificationService.statusDetail(context, discharging85(), rate(false, 9))); + } + + @Test + public void nullSnapshot_yieldsUnknownTitleAndEmptyDetail() { + assertEquals("0% · Unknown", NotificationService.statusTitle(context, null, null)); + assertEquals("", NotificationService.statusDetail(context, null, null)); + } + } + /** * {@link NotificationService#resolveChargeStyle(String)} — normalizes the persisted charge-style * preference, defaulting a null/blank/unrecognized value to Toast (issue #122). From f413ca9f26d2e9cbe4219345ebab9c9dc6dcb973 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sun, 19 Jul 2026 03:59:14 +0300 Subject: [PATCH 2/2] Ongoing notification: stable metrics in title, volatile details in body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: split the two lines by stability, not just "headline vs secondary", and remove all redundancy between them. - Title = the stable, always-available headline only: percentage + plain charge state ("85% · Discharging"). No app name, no rate, no tier. - Body = the volatile details, each shown only when available, joined by " · ": the drain rate (or raw mA) while discharging / the charge power in watts while charging, then the estimated time, then the temperature. The charging body shows wattage without the "Fast charging" tier word, since the title already says the phone is charging — so the lines never repeat. Also from the review: - statusWithRate / chargingSpeedSegment removed (title no longer needs them); the tier label stays only on the charge-connected popup. New wattage-only string; the tier+wattage and 2-part detail strings dropped. - The "Show rate in status notification" toggle now reads "Show rate & time…" and its summaries match what it governs (rate/power + time; temperature always shows). - showRateEnabled is read once per build and passed down (was read twice). - Arabic updated for all of the above (drafted, pending review). Tests: OngoingStatusLines reworked to the new lines (stable title; discharging rate+time+temp; charging watts+time+temp; charge-rate fallback when power unknown; temperature-only when the toggle is off; unknown/empty on null). Co-Authored-By: Claude Fable 5 --- .../service/NotificationService.java | 195 +++++++++--------- app/src/main/res/values-ar/strings.xml | 20 +- app/src/main/res/values/strings.xml | 22 +- .../service/NotificationServiceTest.java | 38 ++-- 4 files changed, 140 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java index 74c3b1a..d435e02 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -34,6 +34,8 @@ import java.lang.ref.WeakReference; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -94,6 +96,9 @@ public final class NotificationService { // still dismissed at plug-in, but explicitly (see clearLevelAlert), not by ID collision. private static final int CHARGE_CONNECTED_NOTIFICATION_ID = 1641992; + // Joins the ongoing notification's detail segments (rate/power · time · temperature). + private static final String DETAIL_SEPARATOR = " · "; + // Do Not Disturb mode constants private static final String ZEN_MODE = "zen_mode"; private static final int ZEN_MODE_IMPORTANT_INTERRUPTIONS = 1; @@ -557,7 +562,7 @@ public static Notification buildOngoingNotification(final Context context, final return new Notification.Builder(context, CHANNEL_ID_STATUS) .setSmallIcon(ongoingIconRes(batteryDO)) - .setContentTitle(statusTitle(context, batteryDO, rate)) + .setContentTitle(statusTitle(context, batteryDO)) .setContentText(statusDetail(context, batteryDO, rate)) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) @@ -1116,69 +1121,122 @@ private static PendingIntent createMainActivityIntent(final Context context) { } /** - * Build the title line of the ongoing status notification, e.g. "85.12% · Discharging 9%/h". + * Build the title line of the ongoing status notification: the stable headline "85% · Discharging". *

- * The title carries the headline data, not the app name — Android already prints the app name in - * the notification header, so repeating it in the title wasted the most glanceable line. The - * percentage is the same live value the home gauge shows — two decimals when the device - * genuinely resolves below one percent, whole otherwise (#158); only the level alerts - * (critical/warning) stay integer. The status segment appends the charge/drain rate when - * available, falling back to the raw mA, then to the plain label — always showing the best - * number on hand (issue #108). The appended rate is gated by a user setting (default on); the - * plain label always shows. + * The title carries the two most stable, always-available metrics — the live percentage and the + * plain charge state — not the app name (Android already prints that in the header) and not the + * volatile rate/time (those live in the detail line, so the title and body never repeat each other). + * The percentage is the same live value the home gauge shows: two decimals when the device genuinely + * resolves below one percent, whole otherwise (#158). * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable - * @param rate The precomputed charge/drain rate * @return Formatted title text */ - static String statusTitle(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { + static String statusTitle(Context context, BatteryDO batteryDO) { final String percentage = BatteryPercentFormatter.formatLive(batteryDO); final String statusLabel = SystemService.getStatusLabel(context, isNull(batteryDO) ? -1 : batteryDO.getStatus()); - return context.getString(R.string.notification_status_title, percentage, statusWithRate(context, batteryDO, statusLabel, rate)); + return context.getString(R.string.notification_status_title, percentage, statusLabel); } /** - * Build the content line of the ongoing status notification — the secondary detail under the - * headline: the estimated time to empty/full plus the temperature, e.g. "~9h 26m left · 34.6 °C" - * or "~45m to full · 34.6 °C", degrading to just the temperature when no trustworthy rate is - * available (or the user turned the rate display off — the estimate is derived from the same - * tracker, so the toggle governs both). The projection is the same capacity-free linear one the - * details table shows (#124/#188); precision is a non-goal — the OS owns the accurate figure. + * Build the content line of the ongoing status notification — the volatile details under the stable + * headline: the live rate/power, the estimated time to empty/full, and the temperature, joined by + * "{@value #DETAIL_SEPARATOR}" and each shown only when it's available, e.g. "9%/h · ~9h 27m left · + * 34.6 °C" or "~18 W · ~45m to full · 34.6 °C". The rate/power and the time estimate are gated by the + * show-rate setting (default on); the temperature always shows when a snapshot exists. Degrades to + * whatever remains — down to just the temperature, or empty when there's no snapshot at all. * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable * @param rate The precomputed charge/drain rate * @return Formatted detail text (empty when nothing is known) */ - static String statusDetail(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { - final String temperature = isNull(batteryDO) ? "" : TemperatureUtils.format(context, batteryDO.getTemperature()); - final String time = timeEstimateSegment(context, batteryDO, rate); - if (isNull(time)) { - return temperature; + static String statusDetail(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + final boolean showRate = showRateEnabled(context); + final List parts = new ArrayList<>(3); + + final String speed = speedDetailSegment(context, batteryDO, rate, showRate); + if (nonNull(speed)) { + parts.add(speed); + } + final String time = timeEstimateSegment(context, batteryDO, rate, showRate); + if (nonNull(time)) { + parts.add(time); + } + if (nonNull(batteryDO)) { + parts.add(TemperatureUtils.format(context, batteryDO.getTemperature())); + } + return String.join(DETAIL_SEPARATOR, parts); + } + + /** + * The speed detail for the notification body: the drain rate ("9%/h", falling back to the raw mA) + * while discharging, or the charge power ("~18 W", falling back to the charge %/h) while charging. + * Numbers only — the qualitative state (Charging/Discharging) is the title's job, so the two lines + * stay non-redundant (issue #108/#125). Gated by the show-rate setting; returns null when nothing + * trustworthy is available. + * + * @param context The application context + * @param batteryDO Current battery snapshot, or null if unavailable + * @param rate The precomputed charge/drain rate + * @param showRate whether the show-rate setting is on + * @return the formatted speed segment, or null when none should be shown + */ + private static String speedDetailSegment(Context context, BatteryDO batteryDO, + BatteryRateTracker.BatteryRate rate, boolean showRate) { + if (isNull(batteryDO) || isNull(rate) || !showRate) { + return null; } - if (temperature.isEmpty()) { - return time; + if (rate.charging()) { + final String power = chargePowerSegment(context, batteryDO); + if (nonNull(power)) { + return power; + } + } + if (rate.hasRate()) { + return BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); + } + if (rate.hasCurrent()) { + return BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); + } + return null; + } + + /** + * The charge power as a wattage-only segment ("~18 W"), or null when it can't be estimated or rounds + * below 1 W. Derived from the snapshot (not a fresh read), so it agrees with the details table and + * {@link SlowChargeDetector} within a tick (#157). The tier label ("Fast charging") is deliberately + * omitted here — it would repeat the "Charging" the title already carries. + * + * @param context The application context + * @param batteryDO Current battery snapshot (non-null; the caller already checked) + * @return the formatted wattage segment, or null when the charge power is unknown or sub-watt + */ + private static String chargePowerSegment(Context context, BatteryDO batteryDO) { + final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); + if (!speed.isKnown() || speed.getWatts() < 1) { + return null; } - return context.getString(R.string.notification_status_detail, time, temperature); + // Western digits (0-9) in every locale (#96). + return context.getString(R.string.notification_status_watts, String.valueOf(speed.getWatts())); } /** - * The estimated-time segment of the detail line: "~9h 26m left" while discharging, "~45m to full" + * The estimated-time segment of the detail line: "~9h 27m left" while discharging, "~45m to full" * while charging — mirroring the details table's time row (#124/#188), including its gating: a * trustworthy %/h and a non-degenerate estimate (not already full/empty). Returns null when no - * figure should be shown, so the caller falls back to the temperature alone. + * figure should be shown, so the caller drops it from the joined detail line. * * @param context The application context * @param batteryDO Current battery snapshot, or null if unavailable * @param rate The precomputed charge/drain rate + * @param showRate whether the show-rate setting is on (governs the estimate too) * @return the formatted time segment, or null when no estimate is available */ - private static String timeEstimateSegment(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { - if (isNull(batteryDO) || isNull(rate) || !rate.hasRate() || !showRateEnabled(context)) { + private static String timeEstimateSegment(Context context, BatteryDO batteryDO, + BatteryRateTracker.BatteryRate rate, boolean showRate) { + if (isNull(batteryDO) || isNull(rate) || !rate.hasRate() || !showRate) { return null; } final int level = batteryDO.getBatteryPercentageInt(); @@ -1195,78 +1253,17 @@ private static String timeEstimateSegment(final Context context, final BatteryDO } /** - * Whether the "show rate in status notification" setting is on (default on). Governs both the - * rate appended to the status segment and the derived time estimate in the detail line. + * Whether the "show rate & time in status notification" setting is on (default on). Governs the + * rate/power and the derived time estimate in the detail line; the temperature shows regardless. * * @param context The application context - * @return true when the rate (and its derived estimate) should be shown + * @return true when the rate/power (and its derived estimate) should be shown */ - private static boolean showRateEnabled(final Context context) { + private static boolean showRateEnabled(Context context) { return PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); } - /** - * The status segment with the rate (or raw mA) appended, e.g. "Discharging 9%/h" — or the plain - * label when no reading is available or the user turned the appended rate off (issue #108). - * - * @param context The application context - * @param batteryDO Current battery snapshot, or null if unavailable - * @param statusLabel The plain localized status label - * @param rate The precomputed charge/drain rate - * @return The status label, optionally with the rate/current appended - */ - private static String statusWithRate(final Context context, final BatteryDO batteryDO, - final String statusLabel, final BatteryRateTracker.BatteryRate rate) { - if (isNull(batteryDO) || isNull(rate) || !showRateEnabled(context)) { - 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, batteryDO); - if (nonNull(chargingSpeed)) { - return chargingSpeed; - } - } - if (rate.hasRate()) { - return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); - } - if (rate.hasCurrent()) { - return statusLabel + " " + BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); - } - 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. - *

- * The speed is derived from the {@code batteryDO} snapshot, not a fresh hardware read, so this segment - * judges the same reading as the details table and {@link SlowChargeDetector} within one tick (#157). - * - * @param context The application context - * @param batteryDO Current battery snapshot (non-null; the caller already checked) - * @return the formatted charging-speed segment, or null when the charge speed is unknown - */ - private static String chargingSpeedSegment(final Context context, final BatteryDO batteryDO) { - final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); - 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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b8af0eb..755e765 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -137,8 +137,6 @@ %1$s · %2$s · ~%3$d واط %1$s · %2$s شحن %1$s - - %1$s · ~%2$s واط أسلوب تنبيه الشحن @@ -165,12 +163,14 @@ حالة البطارية المستمرة أثناء تفعيل المراقبة تنبيهات صامتة تنبيهات البطارية تُعرض بصمت أثناء ساعات الهدوء - + %1$s · %2$s - - %1$s · %2$s - - يتبقى %1$s + + ~%1$s واط + + يتبقّى %1$s %1$s حتى الاكتمال @@ -195,9 +195,9 @@ ~%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 658fc70..d04feb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -155,11 +155,6 @@ %1$s · %2$s %1$s charging - - %1$s · ~%2$s W - Charge notification style @@ -173,12 +168,13 @@ Quiet Alerts Battery alerts shown silently during your quiet hours - + %1$s · %2$s - - %1$s · %2$s - + + ~%1$s W + %1$s left %1$s to full @@ -207,9 +203,9 @@ ~%1$sh %2$sm ~%1$sm Battery Drain - Show rate in status notification - The ongoing notification shows the live drain/charge rate and the estimated time remaining - The ongoing notification shows only the status label and temperature + Show rate & time in status notification + The ongoing notification adds the live rate/power and estimated time to the percentage, status and temperature + The ongoing notification shows the percentage, status and temperature only High drain limit The drain rate turns amber near this limit and red at or above it, and the fast-drain alert fires when it stays there. Measured in percentage points per hour. diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java index e96d1ca..54a494e 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -245,9 +245,10 @@ public void nullAlertType_postsNothing() { } /** - * The two lines of the ongoing status notification: the title carries the headline data - * (percentage + status/rate) instead of repeating the app name the header already shows, and - * the content line carries the time estimate + temperature. + * The two lines of the ongoing status notification (#192): the title carries the stable headline — + * percentage + plain charge state — instead of repeating the app name the header already shows, and + * the content line carries the volatile details (rate/power, time estimate, temperature) with no + * status word, so the two lines never repeat each other. */ @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) @@ -266,34 +267,45 @@ private static BatteryDO discharging85() { .setTemperature(346); } - private static BatteryRateTracker.BatteryRate rate(final boolean charging, final int pph) { + private static BatteryRateTracker.BatteryRate rate(boolean charging, int pph) { return new BatteryRateTracker.BatteryRate(true, pph, charging, false, 0, false, 0); } @Test - public void builtNotificationTitle_isHeadlineData_notAppName() { + public void builtNotificationTitle_isStableHeadline_notAppNameNorRate() { final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rate(false, 9)); - assertEquals("85% · Discharging 9%/h", String.valueOf(built.extras.getCharSequence(Notification.EXTRA_TITLE))); + // Stable metrics only: percentage + status. The rate lives in the content line. + assertEquals("85% · Discharging", String.valueOf(built.extras.getCharSequence(Notification.EXTRA_TITLE))); } @Test - public void detail_showsTimeLeftAndTemperature_whileDischarging() { + public void detail_showsRateTimeAndTemperature_whileDischarging() { // 85% at 9 %/h → 566.67 min, rounded to 567 → "~9h 27m". - final String expected = "~9h 27m left · " + TemperatureUtils.format(context, 346); + final String expected = "9%/h · ~9h 27m left · " + TemperatureUtils.format(context, 346); assertEquals(expected, NotificationService.statusDetail(context, discharging85(), rate(false, 9))); } @Test - public void detail_showsTimeToFull_whileCharging() { + public void detail_showsChargePowerAndTimeToFull_whileCharging() { + // 2 A × 5 V = 10 W; 20 points to full at 20 %/h → exactly 60 min → "~1h 0m". + final BatteryDO charging = discharging85().setLevel(80) + .setStatus(BatteryManager.BATTERY_STATUS_CHARGING) + .setCurrentMicroAmps(2_000_000).setVoltage(5000); + final String expected = "~10 W · ~1h 0m to full · " + TemperatureUtils.format(context, 346); + assertEquals(expected, NotificationService.statusDetail(context, charging, rate(true, 20))); + } + + @Test + public void detail_fallsBackToChargeRate_whenChargePowerUnknown() { + // No current/voltage → charge power can't be estimated → the charge %/h stands in. final BatteryDO charging = discharging85().setLevel(80) .setStatus(BatteryManager.BATTERY_STATUS_CHARGING); - // 20 points to full at 20 %/h → exactly 60 min → "~1h 0m". - final String expected = "~1h 0m to full · " + TemperatureUtils.format(context, 346); + final String expected = "20%/h · ~1h 0m to full · " + TemperatureUtils.format(context, 346); assertEquals(expected, NotificationService.statusDetail(context, charging, rate(true, 20))); } @Test - public void detail_fallsBackToTemperature_whenRateDisplayOff() { + public void detail_fallsBackToTemperatureOnly_whenRateDisplayOff() { PreferenceManager.getDefaultSharedPreferences(context).edit() .putBoolean(context.getString(R.string._pref_key_show_rate_in_notification), false) .commit(); @@ -303,7 +315,7 @@ public void detail_fallsBackToTemperature_whenRateDisplayOff() { @Test public void nullSnapshot_yieldsUnknownTitleAndEmptyDetail() { - assertEquals("0% · Unknown", NotificationService.statusTitle(context, null, null)); + assertEquals("0% · Unknown", NotificationService.statusTitle(context, null)); assertEquals("", NotificationService.statusDetail(context, null, null)); } }