From c0004119758ccb7f2e56d65f65c096b6c4c44086 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sun, 19 Jul 2026 05:29:48 +0300 Subject: [PATCH 1/2] Expandable ongoing notification: Now/Average breakdown on pull-down (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #192. The ongoing status notification becomes a BigTextStyle two-state notification: - Collapsed: the volatile numbers on one line — rate/power · current · time, e.g. "9%/h · −250 mA · ~9h 27m remaining" / "~18 W · +2000 mA · ~1h 0m to full". Temperature moves to the expanded view; "left" → "remaining". - Expanded: a labelled breakdown — Now (instant current, + wattage while charging), Average (windowed-average current, carrying the smoothed %/h while discharging), Time remaining / Time to full, and Temperature. Each line is dropped when its data is absent; expansion is only offered when the breakdown is genuinely multi-line. The app has a single smoothed rate (itself an average), so there is no separate instantaneous %/h — the %/h rides the Average line rather than being faked onto a "Now" line. RTL: the Latin values (mA, %/h, watts, durations) are wrapped with BidiFormatter so they don't reorder inside an Arabic line (the garbling seen before). No-op in LTR. New labels reuse the details table's strings (Time remaining / Time to full / Temperature / Drain rate); new: Now, Average, and the "label: value" line format. All EN + AR (Arabic drafted, pending review). Tests: OngoingStatusLines reworked to 10 — collapsed (discharge/charge/no-current), expanded (discharge/charge/drain-rate-fallback), the built notification's BigText, temperature-only-and-not-expandable when the toggle is off, and null-snapshot. Closes #194 Co-Authored-By: Claude Fable 5 --- .../service/NotificationService.java | 224 ++++++++++++------ app/src/main/res/values-ar/strings.xml | 9 +- app/src/main/res/values/strings.xml | 9 +- .../service/NotificationServiceTest.java | 120 ++++++++-- 4 files changed, 266 insertions(+), 96 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 d435e02..94a9569 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -20,8 +20,11 @@ import android.os.Looper; import android.provider.Settings; import android.util.Log; +import android.view.View; import android.widget.Toast; import androidx.core.content.ContextCompat; +import androidx.core.text.BidiFormatter; +import androidx.core.text.TextUtilsCompat; import androidx.preference.PreferenceManager; import com.almothafar.simplebatterynotifier.R; import com.almothafar.simplebatterynotifier.model.BatteryDO; @@ -36,6 +39,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -560,16 +564,24 @@ public static Notification buildOngoingNotification(final Context context, final final BatteryRateTracker.BatteryRate rate) { createNotificationChannels(context); - return new Notification.Builder(context, CHANNEL_ID_STATUS) + final String collapsed = statusDetail(context, batteryDO, rate); + final String expanded = statusDetailExpanded(context, batteryDO, rate); + final Notification.Builder builder = new Notification.Builder(context, CHANNEL_ID_STATUS) .setSmallIcon(ongoingIconRes(batteryDO)) .setContentTitle(statusTitle(context, batteryDO)) - .setContentText(statusDetail(context, batteryDO, rate)) + .setContentText(collapsed) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) .setOngoing(true) .setVisibility(Notification.VISIBILITY_PUBLIC) - .setCategory(Notification.CATEGORY_STATUS) - .build(); + .setCategory(Notification.CATEGORY_STATUS); + + // Make it expandable only when the pull-down actually adds something (a multi-line breakdown); + // a single-line expanded view (e.g. temperature only) would show a pointless expand chevron (#194). + if (expanded.indexOf('\n') >= 0) { + builder.setStyle(new Notification.BigTextStyle().bigText(expanded)); + } + return builder.build(); } /** @@ -1140,80 +1152,175 @@ static String statusTitle(Context context, BatteryDO batteryDO) { } /** - * 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. + * The collapsed content line — the volatile numbers under the stable title, joined by + * "{@value #DETAIL_SEPARATOR}": rate/power · current · time, e.g. "9%/h · −250 mA · ~9h 27m remaining" + * or "~18 W · +1500 mA · ~45m to full" (#194). Temperature moves to the expanded view. Each segment is + * shown only when available and only when the show-rate setting is on; if nothing qualifies (setting + * off, or a warm-up tick) it falls back to the temperature so the line is never empty. * * @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) + * @return Formatted collapsed text (empty when there's no snapshot at all) */ 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 (showRateEnabled(context) && nonNull(batteryDO) && nonNull(rate)) { + addIfPresent(parts, rateOrPowerSegment(context, batteryDO, rate)); + addIfPresent(parts, collapsedCurrentSegment(context, rate)); + addIfPresent(parts, collapsedTimeSegment(context, batteryDO, rate)); } - if (nonNull(batteryDO)) { - parts.add(TemperatureUtils.format(context, batteryDO.getTemperature())); + if (parts.isEmpty()) { + return nonNull(batteryDO) ? isolate(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. + * The expanded content (BigText) — the same numbers on labelled lines (#194): Now / Average / + * Time remaining / Temperature while discharging, Now / Average / Time to full / + * Temperature while charging. "Now" is the instantaneous current (plus the charge wattage while + * charging); "Average" is the windowed average, carrying the smoothed %/h while discharging — the app + * computes a single smoothed rate, which is itself an average, so there is no separate instantaneous + * %/h. Every line is dropped when its data is absent; the temperature always shows. Returns a single + * line (no expansion) when nothing but temperature 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 + * @return newline-joined expanded text (may be a single line or empty) */ - private static String speedDetailSegment(Context context, BatteryDO batteryDO, - BatteryRateTracker.BatteryRate rate, boolean showRate) { - if (isNull(batteryDO) || isNull(rate) || !showRate) { - return null; + static String statusDetailExpanded(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + final List lines = new ArrayList<>(4); + if (showRateEnabled(context) && nonNull(batteryDO) && nonNull(rate)) { + addLine(context, lines, R.string.notification_label_now, nowSegment(context, batteryDO, rate)); + addAverageLine(context, lines, rate); + addTimeLine(context, lines, batteryDO, rate); + } + if (nonNull(batteryDO)) { + addLine(context, lines, R.string.temperature, TemperatureUtils.format(context, batteryDO.getTemperature())); + } + return String.join("\n", lines); + } + + private static void addIfPresent(List parts, String value) { + if (nonNull(value)) { + parts.add(value); + } + } + + /** Appends an expanded "label: value" line, with the (Latin) value bidi-isolated for RTL (#194). */ + private static void addLine(Context context, List lines, int labelRes, String rawValue) { + if (nonNull(rawValue)) { + lines.add(context.getString(R.string.notification_detail_line, context.getString(labelRes), isolate(rawValue))); + } + } + + /** + * The "Average" expanded line: the windowed-average current, with the smoothed %/h appended while + * discharging (the %/h is itself a windowed average). When the average current isn't ready yet but a + * rate is, a plain "Drain rate" line stands in so the %/h isn't lost from the breakdown. + */ + private static void addAverageLine(Context context, List lines, BatteryRateTracker.BatteryRate rate) { + if (rate.hasAvgCurrent()) { + String value = BatteryRateTracker.formatCurrentValue(context, rate.avgCurrentMilliAmps()); + if (!rate.charging() && rate.hasRate()) { + value = value + DETAIL_SEPARATOR + BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); + } + addLine(context, lines, R.string.notification_label_average, value); + } else if (!rate.charging() && rate.hasRate()) { + addLine(context, lines, R.string.drain_rate, BatteryRateTracker.formatRateValue(context, rate.percentPerHour())); } + } + + /** The expanded time line: "Time remaining"/"Time to full" label with the bare duration (#194). */ + private static void addTimeLine(Context context, List lines, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + final int minutes = estimatedMinutes(batteryDO, rate); + if (minutes > 0) { + addLine(context, lines, + rate.charging() ? R.string.time_to_full : R.string.time_remaining, + BatteryRateTracker.formatDuration(context, minutes)); + } + } + + /** + * The "Now" value: the instantaneous current, plus the charge wattage while charging (wattage alone if + * the current itself is unavailable). Null when neither is available. + */ + private static String nowSegment(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { if (rate.charging()) { - final String power = chargePowerSegment(context, batteryDO); + final String power = powerSegment(context, batteryDO); + if (rate.hasCurrent()) { + final String current = BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); + return nonNull(power) ? current + DETAIL_SEPARATOR + power : current; + } + return power; + } + return rate.hasCurrent() ? BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()) : null; + } + + /** + * The collapsed rate/power segment (bidi-isolated): the drain rate "%/h" while discharging, or the + * charge power "~18 W" while charging (falling back to the charge %/h when the wattage is unknown). + * The raw current is not a fallback here — the collapsed line carries the current in its own + * segment, so this never duplicates it. Returns null when no rate/power is available. + */ + private static String rateOrPowerSegment(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + if (rate.charging()) { + final String power = powerSegment(context, batteryDO); if (nonNull(power)) { - return power; + return isolate(power); } } - if (rate.hasRate()) { - return BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); + return rate.hasRate() ? isolate(BatteryRateTracker.formatRateValue(context, rate.percentPerHour())) : null; + } + + /** The collapsed current segment (bidi-isolated), or null when no trustworthy current is available. */ + private static String collapsedCurrentSegment(Context context, BatteryRateTracker.BatteryRate rate) { + return rate.hasCurrent() ? isolate(BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps())) : null; + } + + /** + * The collapsed time segment: "~9h 27m remaining" / "~45m to full", with the duration bidi-isolated so + * it doesn't reorder inside an RTL line. Null when no non-degenerate estimate is available. + */ + private static String collapsedTimeSegment(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + final int minutes = estimatedMinutes(batteryDO, rate); + if (minutes <= 0) { + return null; } - if (rate.hasCurrent()) { - return BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); + final String duration = isolate(BatteryRateTracker.formatDuration(context, minutes)); + return context.getString(rate.charging() + ? R.string.notification_status_time_to_full + : R.string.notification_status_time_remaining, duration); + } + + /** + * The estimated minutes to full (charging) or empty (discharging), mirroring the details table's + * gating (#124/#188): 0 when there's no trustworthy rate or the estimate degenerates (already + * full/empty). + */ + private static int estimatedMinutes(BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + if (!rate.hasRate()) { + return 0; } - return null; + final int level = batteryDO.getBatteryPercentageInt(); + return rate.charging() + ? BatteryRateTracker.estimateMinutesToFull(level, rate.percentPerHour()) + : BatteryRateTracker.estimateMinutesToEmpty(level, rate.percentPerHour()); } /** * 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. + * omitted — 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) { + private static String powerSegment(Context context, BatteryDO batteryDO) { final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); if (!speed.isKnown() || speed.getWatts() < 1) { return null; @@ -1223,33 +1330,18 @@ private static String chargePowerSegment(Context context, BatteryDO batteryDO) { } /** - * 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 drops it from the joined detail line. + * Wraps a Latin value (mA, %/h, watts, a duration) as an isolated run so the RTL bidi algorithm can't + * reorder it inside an Arabic detail line (#194) — the garbling seen without it. A no-op in LTR + * locales, so it doesn't perturb the (LTR) values elsewhere. * - * @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 + * @param value the Latin value to isolate + * @return the value, bidi-isolated in RTL locales; unchanged in LTR */ - private static String timeEstimateSegment(Context context, BatteryDO batteryDO, - BatteryRateTracker.BatteryRate rate, boolean showRate) { - if (isNull(batteryDO) || isNull(rate) || !rate.hasRate() || !showRate) { - return null; + private static String isolate(String value) { + if (TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) { + return BidiFormatter.getInstance().unicodeWrap(value); } - 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); + return value; } /** diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 755e765..7c6e712 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -169,9 +169,14 @@ ~%1$s واط - - يتبقّى %1$s + + يتبقّى %1$s %1$s حتى الاكتمال + + %1$s: %2$s + الآن + المتوسط تنبيه ارتفاع الحرارة diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d04feb7..e61814d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -174,9 +174,14 @@ ~%1$s W - - %1$s left + + %1$s remaining %1$s to full + + %1$s: %2$s + Now + Average High Temperature Alert 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 54a494e..311d84e 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -29,6 +29,7 @@ import java.util.Collection; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.robolectric.Shadows.shadowOf; /** @@ -245,15 +246,16 @@ public void nullAlertType_postsNothing() { } /** - * 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. + * The ongoing status notification (#192 title + #194 expandable body): the title is the stable + * percentage + state; the collapsed content is rate/power · current · remaining; the + * expanded content is a labelled Now / Average / time / temperature breakdown. Expected strings + * are built from the same formatters the code uses, so the sign glyph, separators and units can't drift. */ @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public static class OngoingStatusLines { + private static final String SEP = " · "; private Context context; @Before @@ -267,56 +269,122 @@ private static BatteryDO discharging85() { .setTemperature(346); } + private static BatteryDO charging80() { + // 2 A × 5 V = 10 W. + return new BatteryDO().setLevel(80).setScale(100) + .setStatus(BatteryManager.BATTERY_STATUS_CHARGING) + .setTemperature(346).setCurrentMicroAmps(2_000_000).setVoltage(5000); + } + + /** Rate with a %/h only (no current/average yet) — the warm-up shape. */ private static BatteryRateTracker.BatteryRate rate(boolean charging, int pph) { return new BatteryRateTracker.BatteryRate(true, pph, charging, false, 0, false, 0); } + /** Rate with a %/h plus instantaneous and windowed-average current. */ + private static BatteryRateTracker.BatteryRate rateFull(boolean charging, int pph, int mA, int avgMa) { + return new BatteryRateTracker.BatteryRate(true, pph, charging, true, mA, true, avgMa); + } + + private String cur(int mA) { + return BatteryRateTracker.formatCurrentValue(context, mA); + } + + private String line(int labelRes, String value) { + return context.getString(R.string.notification_detail_line, context.getString(labelRes), value); + } + + private String temp() { + return TemperatureUtils.format(context, 346); + } + @Test - public void builtNotificationTitle_isStableHeadline_notAppNameNorRate() { + public void title_isStableHeadline_notAppNameNorRate() { final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rate(false, 9)); - // 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_showsRateTimeAndTemperature_whileDischarging() { - // 85% at 9 %/h → 566.67 min, rounded to 567 → "~9h 27m". - final String expected = "9%/h · ~9h 27m left · " + TemperatureUtils.format(context, 346); - assertEquals(expected, NotificationService.statusDetail(context, discharging85(), rate(false, 9))); + public void collapsed_isRateCurrentRemaining_whileDischarging() { + // 85% at 9 %/h → 566.67 → 567 min → "~9h 27m". + final String remaining = context.getString(R.string.notification_status_time_remaining, "~9h 27m"); + final String expected = "9%/h" + SEP + cur(-250) + SEP + remaining; + assertEquals(expected, NotificationService.statusDetail(context, discharging85(), rateFull(false, 9, -250, -338))); } @Test - 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))); + public void collapsed_isWattsCurrentToFull_whileCharging() { + // 20 points to full at 20 %/h → exactly 60 min → "~1h 0m". + final String toFull = context.getString(R.string.notification_status_time_to_full, "~1h 0m"); + final String expected = "~10 W" + SEP + cur(2000) + SEP + toFull; + assertEquals(expected, NotificationService.statusDetail(context, charging80(), rateFull(true, 20, 2000, 1900))); + } + + @Test + public void collapsed_dropsCurrent_whenNoneYet() { + // Warm-up: %/h available, no current → rate · remaining, no mA segment. + final String remaining = context.getString(R.string.notification_status_time_remaining, "~9h 27m"); + assertEquals("9%/h" + SEP + remaining, + NotificationService.statusDetail(context, discharging85(), rate(false, 9))); } @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); - final String expected = "20%/h · ~1h 0m to full · " + TemperatureUtils.format(context, 346); - assertEquals(expected, NotificationService.statusDetail(context, charging, rate(true, 20))); + public void expanded_isLabelledBreakdown_whileDischarging() { + final String expected = String.join("\n", + line(R.string.notification_label_now, cur(-250)), + line(R.string.notification_label_average, cur(-338) + SEP + "9%/h"), + line(R.string.time_remaining, "~9h 27m"), + line(R.string.temperature, temp())); + assertEquals(expected, NotificationService.statusDetailExpanded(context, discharging85(), rateFull(false, 9, -250, -338))); } @Test - public void detail_fallsBackToTemperatureOnly_whenRateDisplayOff() { + public void expanded_isLabelledBreakdown_whileCharging() { + // Now pairs current with wattage; Average is current only (no %/h while charging). + final String expected = String.join("\n", + line(R.string.notification_label_now, cur(2000) + SEP + "~10 W"), + line(R.string.notification_label_average, cur(1900)), + line(R.string.time_to_full, "~1h 0m"), + line(R.string.temperature, temp())); + assertEquals(expected, NotificationService.statusDetailExpanded(context, charging80(), rateFull(true, 20, 2000, 1900))); + } + + @Test + public void expanded_usesDrainRateLine_whenNoAverageCurrentYet() { + // %/h but no average current → the rate gets its own labelled line instead of riding Average. + final String expected = String.join("\n", + line(R.string.drain_rate, "9%/h"), + line(R.string.time_remaining, "~9h 27m"), + line(R.string.temperature, temp())); + assertEquals(expected, NotificationService.statusDetailExpanded(context, discharging85(), rate(false, 9))); + } + + @Test + public void builtNotification_isExpandable_whenBreakdownAvailable() { + final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rateFull(false, 9, -250, -338)); + final CharSequence bigText = built.extras.getCharSequence(Notification.EXTRA_BIG_TEXT); + assertEquals(NotificationService.statusDetailExpanded(context, discharging85(), rateFull(false, 9, -250, -338)), + String.valueOf(bigText)); + } + + @Test + public void rateDisplayOff_showsTemperatureOnly_andNotExpandable() { 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))); + // Collapsed: bare temperature. Expanded: a single "Temperature: …" line → no BigText. + assertEquals(temp(), NotificationService.statusDetail(context, discharging85(), rateFull(false, 9, -250, -338))); + assertEquals(line(R.string.temperature, temp()), + NotificationService.statusDetailExpanded(context, discharging85(), rateFull(false, 9, -250, -338))); + final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rateFull(false, 9, -250, -338)); + assertNull(built.extras.getCharSequence(Notification.EXTRA_BIG_TEXT)); } @Test public void nullSnapshot_yieldsUnknownTitleAndEmptyDetail() { assertEquals("0% · Unknown", NotificationService.statusTitle(context, null)); assertEquals("", NotificationService.statusDetail(context, null, null)); + assertEquals("", NotificationService.statusDetailExpanded(context, null, null)); } } From 182ce22f9da48ecd06c1b81d335efaeddb97eb72 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sun, 19 Jul 2026 11:48:10 +0300 Subject: [PATCH 2/2] copy: rename expanded-notification "Now" label to "Live" (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded status notification labels the live current line "Now", paired with "Average" below it. "Live" is a clearer contrast with "Average". Arabic "الآن" → "مباشر" (live, pairs with المتوسط). Only the string values change; the resource name notification_label_now and its usages are untouched, so tests are unaffected. Co-Authored-By: Claude Opus 4.8 --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7c6e712..2248ce4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -175,7 +175,7 @@ %1$s: %2$s - الآن + مباشر المتوسط diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e61814d..3f1e08f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,7 +180,7 @@ %1$s: %2$s - Now + Live Average