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..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,8 +562,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)) + .setContentText(statusDetail(context, batteryDO, rate)) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) .setOngoing(true) @@ -1116,92 +1121,147 @@ 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: the stable headline "85% · Discharging". *

- * 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 - * 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 status text + * @return Formatted title text */ - private static String statusText(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()); - 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); + return context.getString(R.string.notification_status_title, percentage, statusLabel); } /** - * 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). + * 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 statusLabel The plain localized status label - * @param rate The precomputed charge/drain rate - * @return The status label, optionally with the rate/current appended + * @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) */ - private static String statusWithRate(final Context context, final BatteryDO batteryDO, - final String statusLabel, final BatteryRateTracker.BatteryRate rate) { - if (isNull(batteryDO) || isNull(rate)) { - return statusLabel; + 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())); } - final boolean showRate = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); - if (!showRate) { - return statusLabel; + 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; } - // 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; + final String power = chargePowerSegment(context, batteryDO); + if (nonNull(power)) { + return power; } } if (rate.hasRate()) { - return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); + return BatteryRateTracker.formatRateValue(context, rate.percentPerHour()); } if (rate.hasCurrent()) { - return statusLabel + " " + BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); + return BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps()); } - return statusLabel; + return null; } /** - * 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). + * 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 charging-speed segment, or null when the charge speed is unknown + * @return the formatted wattage segment, or null when the charge power is unknown or sub-watt */ - private static String chargingSpeedSegment(final Context context, final BatteryDO batteryDO) { + private static String chargePowerSegment(Context context, BatteryDO batteryDO) { final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); - if (!speed.isKnown()) { + if (!speed.isKnown() || speed.getWatts() < 1) { 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 + // 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 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. + * + * @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(Context context, BatteryDO batteryDO, + BatteryRateTracker.BatteryRate rate, boolean showRate) { + if (isNull(batteryDO) || isNull(rate) || !rate.hasRate() || !showRate) { + 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; } - // %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)); + 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 & 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/power (and its derived estimate) should be shown + */ + private static boolean showRateEnabled(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true); } /** diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9ea78ee..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,7 +163,15 @@ حالة البطارية المستمرة أثناء تفعيل المراقبة تنبيهات صامتة تنبيهات البطارية تُعرض بصمت أثناء ساعات الهدوء - %1$s · %2$s · %3$s + + %1$s · %2$s + + ~%1$s واط + + يتبقّى %1$s + %1$s حتى الاكتمال تنبيه ارتفاع الحرارة @@ -189,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 108db80..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,8 +168,15 @@ Quiet Alerts Battery alerts shown silently during your quiet hours - - %1$s · %2$s · %3$s + + %1$s · %2$s + + ~%1$s W + + %1$s left + %1$s to full High Temperature Alert @@ -201,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 next to the status - The ongoing notification shows only the status label + 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 8d5894e..54a494e 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,82 @@ 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. + */ + @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(boolean charging, int pph) { + return new BatteryRateTracker.BatteryRate(true, pph, charging, false, 0, false, 0); + } + + @Test + public void builtNotificationTitle_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))); + } + + @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))); + } + + @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))); + } + + @Test + public void detail_fallsBackToTemperatureOnly_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)); + 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).