diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java index 4d339fa..3f2629f 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java @@ -291,7 +291,7 @@ record LevelAlertState(int prevLevel, AlertType prevType, boolean fullNotified) /** * The user's alert thresholds and toggles (reduces parameter count, like - * {@code NotificationService.NotificationConfig}). + * {@code NotificationConfig}). * * @param criticalLevel critical alert threshold in percent * @param warningLevel warning alert threshold in percent diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSounds.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSounds.java new file mode 100644 index 0000000..a1b900d --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSounds.java @@ -0,0 +1,88 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.media.AudioManager; +import android.net.Uri; +import android.provider.Settings; +import android.util.Log; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static java.util.Objects.isNull; + +/** + * The alert sound/vibration playback path (issue #166): plays the user's alarm sound (and optionally + * vibrates) when the phone is silenced but the user opted to override silent/DND mode. In normal ringer + * mode the high-importance notification channel plays its own sound, so this stays out of the way. + *

+ * Split out of {@code NotificationService} so it owns its own thread pool. The single-thread executor + * runs for the app's lifetime and is reclaimed on process death (there is no {@code Application} to hook + * an explicit shutdown to). + */ +final class AlertSounds { + private static final String TAG = AlertSounds.class.getSimpleName(); + + // Do Not Disturb mode constants + private static final String ZEN_MODE = "zen_mode"; + private static final int ZEN_MODE_IMPORTANT_INTERRUPTIONS = 1; + + /** + * Thread pool for async sound playback. This single-thread executor is used throughout the app + * lifetime to play notification sounds asynchronously. Android reclaims it when the process + * terminates. + */ + private static final ExecutorService soundExecutor = Executors.newSingleThreadExecutor(); + + private AlertSounds() { + // Utility class - prevent instantiation + } + + /** + * Play the alert sound (and optionally vibrate) when the phone is silenced but the user opted + * to override silent mode. In normal ringer mode the notification channel plays its own sound. + *

+ * Callers must first check that alerts are allowed right now (quiet hours / critical override). + * + * @param context The application context + * @param soundUriStr The alarm sound URI string + * @param ignoreSilent Whether the user opted to override silent/DND mode + * @param vibrate Whether the user enabled vibration + */ + static void playAlarm(Context context, String soundUriStr, boolean ignoreSilent, boolean vibrate) { + final Uri soundUri = Uri.parse(soundUriStr); + final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (isNull(audioManager)) { + return; + } + + final boolean isNotNormalRingerMode = audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL || isInDoNotDisturbMode(context); + + if (ignoreSilent && isNotNormalRingerMode) { + soundExecutor.execute(() -> { + SystemService.playSound(context, soundUri); + if (vibrate) { + SystemService.vibratePhone(context); + } + }); + } + } + + /** + * Check if device is in Do Not Disturb mode + * + * @param context The application context + * @return true if in DND mode, false otherwise + */ + private static boolean isInDoNotDisturbMode(Context context) { + try { + return ZEN_MODE_IMPORTANT_INTERRUPTIONS == Settings.Global.getInt(context.getContentResolver(), ZEN_MODE); + } catch (Settings.SettingNotFoundException e) { + // zen_mode has existed since API 21 and minSdk is 26, so its absence is unexpected, not an + // expected-validation case — log it rather than swallow. Falling back to "not in DND" keeps + // the alert on its normal audible path. + Log.w(TAG, "zen_mode setting not found; assuming not in Do Not Disturb", e); + return false; + } + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSpec.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSpec.java new file mode 100644 index 0000000..55d6339 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/AlertSpec.java @@ -0,0 +1,21 @@ +package com.almothafar.simplebatterynotifier.service; + +/** + * Everything an alert notification needs to display, and the routing it posts under — the single + * currency the dispatch layer flows through (issue #166). The level, temperature, fast-drain, + * slow-charge and charge-connected alerts all build one of these, so the notification builder chain is + * written exactly once ({@code NotificationService.alertBuilder}). Bundling the fields also keeps the + * dispatch methods' parameter counts down. + * + * @param logName short name used in log messages (e.g. "temperature") + * @param audibleChannelId the alert's audible base channel; rerouted to the silent channel in quiet hours + * @param notificationId the alert's own notification id, so it never replaces another alert + * @param iconRes small icon resource + * @param ticker ticker text + * @param title content title + * @param content collapsed content text + * @param bigContent expanded (BigTextStyle) content text + */ +record AlertSpec(String logName, String audibleChannelId, int notificationId, int iconRes, + String ticker, String title, String content, String bigContent) { +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationChannels.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationChannels.java new file mode 100644 index 0000000..056803b --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationChannels.java @@ -0,0 +1,222 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; + +import androidx.preference.PreferenceManager; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.util.AppPrefs; + +import static java.util.Objects.isNull; + +/** + * The notification-channel registry (issue #166): creates, updates, refreshes and resolves the app's + * notification channels. Split out of {@code NotificationService} so channel bookkeeping lives in one + * place. + *

+ * The six alert channels below are base IDs: the actual channel ID carries a version suffix + * (see {@link #versionedChannelId}) because Android un-deletes a channel recreated under the same ID, + * restoring its old settings — which made the Vibrate toggle a no-op (issue #153). + * {@link #refreshAlertChannels} bumps the version so a changed setting really applies. + */ +final class NotificationChannels { + + // Alert (audible, high-importance) channels — base IDs, see class javadoc. + static final String CHANNEL_ID_CRITICAL = "battery_critical"; + static final String CHANNEL_ID_WARNING = "battery_warning"; + static final String CHANNEL_ID_FULL = "battery_full"; + static final String CHANNEL_ID_TEMPERATURE = "battery_temperature"; + static final String CHANNEL_ID_FAST_DRAIN = "battery_fast_drain"; + static final String CHANNEL_ID_SLOW_CHARGE = "battery_slow_charge"; + // Silent, low-importance channels: the persistent status notification, and the quiet-hours channel + // used to deliver an alert quietly during the user's quiet hours — still visible, no sound/vibration + // (issue #111). + static final String CHANNEL_ID_STATUS = "battery_status"; + static final String CHANNEL_ID_ALERTS_SILENT = "battery_alerts_quiet"; + + // Current version of the alert channels' settings, stored in the default SharedPreferences. + // Version 1 means the original unsuffixed channel IDs, so existing installs keep their channels + // (and any per-channel tweaks) until the user first changes the Vibrate preference. + private static final String PREF_ALERT_CHANNEL_VERSION = "alert_channel_version"; + + private NotificationChannels() { + // Utility class - prevent instantiation + } + + /** + * Create the notification channels if they don't exist, or refresh their name/description if they + * do (so translated names reach upgraded installs — issue #165). + * + * @param context The application context + */ + static void ensureChannels(Context context) { + final NotificationManager manager = getManager(context); + if (isNull(manager)) { + return; + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final boolean vibrate = AppPrefs.vibrateEnabled(context); + final int version = alertChannelVersion(prefs); + + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_CRITICAL, version), + context.getString(R.string.notification_critical_channel_name), + context.getString(R.string.notification_critical_channel_description), Color.RED, vibrate); + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_WARNING, version), + context.getString(R.string.notification_warning_channel_name), + context.getString(R.string.notification_warning_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_FULL, version), + context.getString(R.string.notification_full_channel_name), + context.getString(R.string.notification_full_channel_description), Color.GREEN, vibrate); + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_TEMPERATURE, version), + context.getString(R.string.notification_temperature_channel_name), + context.getString(R.string.notification_temperature_channel_description), Color.RED, vibrate); + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_FAST_DRAIN, version), + context.getString(R.string.notification_fast_drain_channel_name), + context.getString(R.string.notification_fast_drain_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); + createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_SLOW_CHARGE, version), + context.getString(R.string.notification_slow_charge_channel_name), + context.getString(R.string.notification_slow_charge_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); + createOrUpdateSilentChannel(manager, CHANNEL_ID_STATUS, + context.getString(R.string.notification_status_channel_name), + context.getString(R.string.notification_status_channel_description)); + createOrUpdateSilentChannel(manager, CHANNEL_ID_ALERTS_SILENT, + context.getString(R.string.notification_quiet_channel_name), + context.getString(R.string.notification_quiet_channel_description)); + } + + /** + * Re-create the alert channels so a changed "Vibrate" preference takes effect. + *

+ * Deleting and recreating a channel under the same ID is not enough: Android un-deletes it with + * its old settings (issue #153). So the old-version channels are deleted (keeping system + * settings free of orphans) and the channels are recreated under the next version's IDs, which + * the system treats as brand-new channels with the new vibration setting. The silent channels + * are untouched. + * + * @param context The application context + */ + static void refreshAlertChannels(Context context) { + final NotificationManager manager = getManager(context); + if (isNull(manager)) { + return; + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final int oldVersion = alertChannelVersion(prefs); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_CRITICAL, oldVersion)); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_WARNING, oldVersion)); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_FULL, oldVersion)); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_TEMPERATURE, oldVersion)); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_FAST_DRAIN, oldVersion)); + manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_SLOW_CHARGE, oldVersion)); + + prefs.edit().putInt(PREF_ALERT_CHANNEL_VERSION, oldVersion + 1).apply(); + ensureChannels(context); + } + + /** + * The channel an alerting notification should post to: its normal audible (high-importance) channel + * when alerts may sound now, or the shared silent channel during quiet hours so the alert is still + * shown but makes no sound or vibration (issue #111). The audible channel's base ID is resolved to + * its current versioned ID (issue #153), so every posting site tracks version bumps automatically. + * + * @param context The application context + * @param alertsAllowed whether alerts may sound now (inside the window, or a critical override) + * @param audibleBaseChannelId the base channel ID to use when alerts are allowed to sound + * @return the channel id to post the notification on + */ + static String channelFor(Context context, boolean alertsAllowed, String audibleBaseChannelId) { + if (!alertsAllowed) { + return CHANNEL_ID_ALERTS_SILENT; + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return versionedChannelId(audibleBaseChannelId, alertChannelVersion(prefs)); + } + + /** + * The alert-channel ID for a given settings version. Version 1 is the original unsuffixed ID; + * later versions append a suffix ("battery_critical_v2", ...) so Android sees a brand-new channel + * instead of un-deleting the old one with its stale settings (issue #153). + * + * @param baseChannelId the unversioned channel ID + * @param version the alert-channel settings version (1-based) + * @return the channel ID to create and post on for that version + */ + static String versionedChannelId(String baseChannelId, int version) { + return version <= 1 ? baseChannelId : baseChannelId + "_v" + version; + } + + /** + * The current alert-channel settings version, bumped by {@link #refreshAlertChannels(Context)} + * whenever the Vibrate preference changes. + * + * @param prefs the default SharedPreferences + * @return the current version, 1 for installs that never changed the Vibrate preference + */ + private static int alertChannelVersion(SharedPreferences prefs) { + return prefs.getInt(PREF_ALERT_CHANNEL_VERSION, 1); + } + + /** + * Create a low-importance, fully silent channel (no sound, vibration, lights or badge), or update + * its name/description if it already exists. + *

+ * Used both by the persistent status notification and, during the user's quiet hours, to deliver + * an alert quietly so it stays visible without disturbing the user (issue #111). + *

+ * As with the alert channels, re-calling for an existing ID updates only name/description, so + * translated names reach upgraded installs (#165) without disturbing the silent behaviour. + * + * @param manager The NotificationManager + * @param channelId The channel ID to create + * @param name The channel name + * @param description The channel description + */ + private static void createOrUpdateSilentChannel(NotificationManager manager, String channelId, String name, String description) { + final NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_LOW); + channel.setDescription(description); + channel.enableLights(false); + channel.enableVibration(false); + channel.setSound(null, null); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + + /** + * Create an alert channel, or update its name/description if it already exists. + *

+ * Re-calling {@code createNotificationChannel} with an existing ID updates only the name, + * description and group — Android ignores importance, vibration, lights and sound so the user's + * (and the versioned #153) settings are preserved. This is how translated channel names reach + * upgraded installs, not just fresh ones (#165): the channel already exists, so we still + * re-apply the current locale's name and description. + * + * @param manager The NotificationManager + * @param channelId The channel ID + * @param name The channel name + * @param description The channel description + * @param ledColor The LED color for notifications + * @param vibrate Whether the channel should vibrate (from the user's Vibrate preference) + */ + private static void createOrUpdateAlertChannel(NotificationManager manager, String channelId, String name, String description, + int ledColor, boolean vibrate) { + final NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_HIGH); + channel.setDescription(description); + channel.enableLights(true); + channel.setLightColor(ledColor); + channel.enableVibration(vibrate); + if (vibrate) { + channel.setVibrationPattern(SystemService.VIBRATION_PATTERN); + } + manager.createNotificationChannel(channel); + } + + private static NotificationManager getManager(Context context) { + return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationConfig.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationConfig.java new file mode 100644 index 0000000..3b5ac19 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationConfig.java @@ -0,0 +1,119 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.util.AppPrefs; + +/** + * Configuration for a battery-level notification (reduces parameter count). + *

+ * Encapsulates everything needed to build a level alert, extracted from SharedPreferences and the alert + * type. Split out of {@code NotificationService} (issue #166) so the dispatch layer holds the flow and + * this holds the per-type data. Its display half is turned into an {@link AlertSpec} by the caller so the + * level alert flows through the same builder chain as the other alerts. + */ +final class NotificationConfig { + final AlertType type; + final String channelId; + final int iconRes; + final String ticker; + final String title; + final String content; + final String bigContent; + final String alarmSound; + final boolean alertsAllowed; + final boolean ignoreSilent; + final boolean vibrate; + final boolean stickyNotification; + + /** + * Create a NotificationConfig from preferences and type + * + * @param context The application context + * @param prefs SharedPreferences containing user settings + * @param type Which battery-level alert to configure (non-null) + */ + NotificationConfig(Context context, SharedPreferences prefs, AlertType type) { + this.type = type; + + // Load common preferences + final int warningLevel = AppPrefs.warningLevel(context); + final int criticalLevel = AppPrefs.criticalLevel(context); + + this.stickyNotification = prefs.getBoolean(context.getString(R.string._pref_key_notifications_sticky), false); + final boolean withinWindow = QuietHours.isWithinNotificationWindow(context, prefs); + final boolean criticalIgnoresQuietHours = prefs.getBoolean(context.getString(R.string._pref_key_critical_ignore_quiet_hours), true); + this.alertsAllowed = QuietHours.alertsAllowedNow(withinWindow, type == AlertType.CRITICAL, criticalIgnoresQuietHours); + this.ignoreSilent = QuietHours.shouldIgnoreSilentMode(context, prefs); + this.vibrate = AppPrefs.vibrateEnabled(context); + + final String defaultSound = context.getString(R.string._default_notification_sound_uri); + + // A switch EXPRESSION over the enum is exhaustive at compile time — the old int switch + // needed a default branch, which posted a completely blank notification on any invalid + // type value (issue #160). That branch can no longer exist. + final AlertStyle style = switch (type) { + case CRITICAL -> new AlertStyle( + NotificationChannels.CHANNEL_ID_CRITICAL, + R.drawable.ic_stat_device_battery_charging_20, + prefs.getString(context.getString(R.string._pref_key_notifications_alert_sound_ringtone), defaultSound), + context.getString(R.string.notification_critical_ticker, criticalLevel), + context.getString(R.string.notification_critical_title), + context.getString(R.string.notification_critical_content, criticalLevel), + context.getString(R.string.notification_critical_content_big, criticalLevel)); + case WARNING -> new AlertStyle( + NotificationChannels.CHANNEL_ID_WARNING, + R.drawable.ic_stat_device_battery_charging_50, + prefs.getString(context.getString(R.string._pref_key_notifications_warning_sound_ringtone), defaultSound), + context.getString(R.string.notification_warning_ticker, warningLevel), + context.getString(R.string.notification_warning_title), + context.getString(R.string.notification_warning_content, warningLevel), + context.getString(R.string.notification_warning_content_big, warningLevel)); + case FULL -> new AlertStyle( + NotificationChannels.CHANNEL_ID_FULL, + R.drawable.ic_stat_device_battery_charging_full, + prefs.getString(context.getString(R.string._pref_key_notifications_full_sound_ringtone), defaultSound), + context.getString(R.string.notification_full_level_ticker), + context.getString(R.string.notification_full_level_title), + context.getString(R.string.notification_full_level_content), + context.getString(R.string.notification_full_level_content_big)); + }; + this.channelId = style.channelId(); + this.iconRes = style.iconRes(); + this.alarmSound = style.alarmSound(); + this.ticker = style.ticker(); + this.title = style.title(); + this.content = style.content(); + this.bigContent = style.bigContent(); + } + + /** + * The display half of this config as an {@link AlertSpec}, so the level alert flows through the same + * builder chain as the other alerts (issue #166) without the dispatcher reaching into these fields. + * The audible base channel is this config's own channel; the caller supplies the level alert's id. + * + * @param notificationId the level alert's notification id + * @return the display spec for this level alert + */ + AlertSpec toAlertSpec(int notificationId) { + return new AlertSpec("level", channelId, notificationId, iconRes, ticker, title, content, bigContent); + } + + /** + * The per-type presentation of a battery-level alert, produced by the exhaustive type switch above + * (#160). + * + * @param channelId the audible base channel ID for this type + * @param iconRes the small-icon resource + * @param alarmSound the user's chosen alarm sound for this type + * @param ticker the ticker text + * @param title the notification title + * @param content the collapsed content line + * @param bigContent the expanded (BigTextStyle) content + */ + private record AlertStyle(String channelId, int iconRes, String alarmSound, String ticker, + String title, String content, String bigContent) { + } +} 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 6ce3485..7139ab0 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -2,7 +2,6 @@ import android.Manifest; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; @@ -11,20 +10,12 @@ import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.media.AudioManager; -import android.net.Uri; -import android.os.BatteryManager; import android.os.Build; import android.os.Handler; 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; @@ -32,107 +23,57 @@ import com.almothafar.simplebatterynotifier.model.ChargeSpeedTier; import com.almothafar.simplebatterynotifier.ui.MainActivity; import com.almothafar.simplebatterynotifier.util.AppPrefs; -import com.almothafar.simplebatterynotifier.util.BatteryPercentFormatter; -import com.almothafar.simplebatterynotifier.util.GeneralHelper; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; import java.lang.ref.WeakReference; -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; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; /** - * Service for managing battery notification creation and display + * Battery-notification dispatch (issue #166): the public façade the receivers and the foreground service + * call to post battery alerts and the ongoing status notification. *

- * This is a utility class that handles all notification-related functionality including: - * - Creating notification channels - * - Building and sending battery status notifications - * - Playing notification sounds - * - Managing notification lifecycle - *

- * Thread Safety: This class uses a single-thread executor for async sound playback. - * The executor is managed statically and should be shutdown when the app terminates - * by calling {@link #shutdown()}. + * The heavy lifting is delegated to focused collaborators in this package, each owning one job: + *

+ * This class owns the notification IDs, the single shared alert-builder chain, the per-charge-style + * delivery, and the per-level {@link NotificationConfig}. */ public final class NotificationService { // Battery thresholds public static final int RED_ALERT_LEVEL = 4; public static final int FULL_PERCENTAGE = 95; private static final String TAG = NotificationService.class.getSimpleName(); - // Notification channels. The six alert channels below are *base* IDs: the actual channel ID - // carries a version suffix (see versionedChannelId) because Android un-deletes a channel that is - // recreated with the same ID, restoring its old settings — which made the Vibrate toggle a no-op - // (issue #153). refreshAlertChannels() bumps the version so a changed setting really applies. - private static final String CHANNEL_ID_CRITICAL = "battery_critical"; - private static final String CHANNEL_ID_WARNING = "battery_warning"; - private static final String CHANNEL_ID_FULL = "battery_full"; - private static final String CHANNEL_ID_STATUS = "battery_status"; - private static final String CHANNEL_ID_TEMPERATURE = "battery_temperature"; - private static final String CHANNEL_ID_FAST_DRAIN = "battery_fast_drain"; - private static final String CHANNEL_ID_SLOW_CHARGE = "battery_slow_charge"; - // Silent, low-importance channel used to deliver an alert quietly during the user's quiet hours: - // the alert is still visible but makes no sound or vibration (issue #111). - private static final String CHANNEL_ID_ALERTS_SILENT = "battery_alerts_quiet"; - - // Current version of the alert channels' settings, stored in the default SharedPreferences. - // Version 1 means the original unsuffixed channel IDs, so existing installs keep their channels - // (and any per-channel tweaks) until the user first changes the Vibrate preference. - private static final String PREF_ALERT_CHANNEL_VERSION = "alert_channel_version"; - // Notification settings - private static final long[] VIBRATION_PATTERN = {0, 500, 250, 500, 250}; + // Notification IDs — each alert gets its own so one can never replace another. private static final int NOTIFICATION_ID = 1641987; - // Separate ID for the persistent foreground-service status notification + // The persistent foreground-service status notification. private static final int ONGOING_NOTIFICATION_ID = 1641988; - // Separate ID so a temperature alert doesn't replace a battery-level alert + // A temperature alert doesn't replace a battery-level alert. private static final int TEMPERATURE_NOTIFICATION_ID = 1641989; - // Separate ID so a fast-drain alert doesn't replace a level or temperature alert (#109) + // A fast-drain alert doesn't replace a level or temperature alert (#109). private static final int FAST_DRAIN_NOTIFICATION_ID = 1641990; - // Separate ID so a slow-charge warning doesn't replace any other alert (#123) + // A slow-charge warning doesn't replace any other alert (#123). private static final int SLOW_CHARGE_NOTIFICATION_ID = 1641991; - // Separate ID so "Charging started" doesn't replace a level alert (#155). The level alert is - // still dismissed at plug-in, but explicitly (see clearLevelAlert), not by ID collision. + // "Charging started" doesn't replace a level alert (#155). The level alert is 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; - - // Charge-connected notification style (values persisted by the ListPreference in pref_alerts.xml). + // Charge-connected notification style (values persisted by the ListPreference in pref_behaviour.xml). // Toast is the default so plugging in stays low-clutter (issue #122). static final String CHARGE_STYLE_TOAST = "toast"; static final String CHARGE_STYLE_NOTIFICATION = "notification"; static final String CHARGE_STYLE_NONE = "none"; /** - * Thread pool for async sound playback - *

- * This single-thread executor is used throughout the app lifetime to play - * notification sounds asynchronously. It should be shutdown when the app - * terminates by calling {@link #shutdown()}. - *

- * Note: In practice, Android will clean up this executor when the process - * terminates, but explicit cleanup is provided for proper resource management. - */ - private static final ExecutorService soundExecutor = Executors.newSingleThreadExecutor(); - - /** - * Cached launcher icon bitmap (performance optimization) - *

- * Uses WeakReference to allow garbage collection if memory is tight. - * The icon will be reloaded on next access if garbage collected. - * This prevents holding a strong reference to a potentially large bitmap - * throughout the app lifetime. + * Cached launcher icon bitmap (performance optimization). Uses a {@link WeakReference} so it can be + * garbage-collected if memory is tight; it is reloaded on next access if that happens, which avoids + * holding a strong reference to a potentially large bitmap for the app's lifetime. */ private static WeakReference cachedLauncherIcon; @@ -146,7 +87,7 @@ private NotificationService() { * @param context The application context * @param type Which battery-level alert to send */ - public static void sendNotification(final Context context, final AlertType type) { + public static void sendNotification(Context context, AlertType type) { if (isNull(type)) { Log.w(TAG, "No alert type given, notification not sent"); return; @@ -156,21 +97,27 @@ public static void sendNotification(final Context context, final AlertType type) return; } - createNotificationChannels(context); + NotificationChannels.ensureChannels(context); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final NotificationConfig config = new NotificationConfig(context, prefs, type); - final Notification.Builder builder = createNotificationBuilder(context, config); - if (isNull(builder)) { - return; + final String channelId = NotificationChannels.channelFor(context, config.alertsAllowed, config.channelId); + final Notification.Builder builder = alertBuilder(context, channelId, config.toAlertSpec(NOTIFICATION_ID)); + if (!type.alertsEveryTime()) { + // Non-critical alerts update quietly when re-posted; only critical alerts alert every time. + builder.setOnlyAlertOnce(true); } - configureNotificationContent(context, builder, config); - final Notification notification = buildNotification(builder, config); + final Notification notification = builder.build(); + if (config.stickyNotification) { + notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT; + } + post(context, NOTIFICATION_ID, notification); - sendNotificationToSystem(context, notification); - playSoundIfNeeded(context, config); + if (config.alertsAllowed) { + AlertSounds.playAlarm(context, config.alarmSound, config.ignoreSilent, config.vibrate); + } } /** @@ -190,150 +137,9 @@ public static void sendNotification(final Context context, final AlertType type) * @param speed The estimated charging speed (may be {@link ChargeSpeed#unknown()}) * @param wireless true when charging over a wireless charger, false when wired */ - public static void notifyChargeConnected(final Context context, final ChargeSpeed speed, - final boolean wireless) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String style = resolveChargeStyle(prefs.getString( - context.getString(R.string._pref_key_charge_notification_style), CHARGE_STYLE_TOAST)); - - if (CHARGE_STYLE_NONE.equals(style)) { - return; // User opted out of charge-connected feedback entirely. - } - + public static void notifyChargeConnected(Context context, ChargeSpeed speed, boolean wireless) { final String content = chargeConnectedMessage(context, speed, wireless); - - if (CHARGE_STYLE_TOAST.equals(style)) { - showChargeToast(context, content); - return; - } - - postChargeNotification(context, content); - } - - /** - * Normalize a stored charge-style preference value to a known style, defaulting to - * {@link #CHARGE_STYLE_TOAST} for a null/blank/unrecognized value. Pure and Android-free so it is - * unit-testable. - * - * @param stored the raw persisted value - * - * @return one of the {@code CHARGE_STYLE_*} constants - */ - static String resolveChargeStyle(final String stored) { - if (CHARGE_STYLE_NOTIFICATION.equals(stored)) { - return CHARGE_STYLE_NOTIFICATION; - } - if (CHARGE_STYLE_NONE.equals(stored)) { - return CHARGE_STYLE_NONE; - } - return CHARGE_STYLE_TOAST; - } - - /** - * Build the charge-connected message, e.g. "Wireless · Fast charging · ~18 W", or - * "Wired charging" when the speed can't be estimated. - * - * @param context The application context - * @param speed The estimated charging speed - * @param wireless true for wireless charging, false for wired - * - * @return the localized message shown in the toast or notification - */ - private static String chargeConnectedMessage(final Context context, final ChargeSpeed speed, final boolean wireless) { - final String source = context.getString( - wireless ? R.string.charge_source_wireless : R.string.charge_source_wired); - - if (!speed.isKnown()) { - return context.getString(R.string.charge_connected_plain, source); - } - - final String tierLabel = context.getString(tierLabelRes(speed.getTier())); - final int watts = speed.getWatts(); - if (watts < 1) { - // Known tier but sub-watt (e.g. trickle): showing "~0 W" would read as an error. - return context.getString(R.string.charge_connected_tier, source, tierLabel); - } - return context.getString(R.string.charge_connected_power, source, tierLabel, watts); - } - - /** - * Map a {@link ChargeSpeedTier} to its localized label resource. - * - * @param tier the charging-speed tier - * - * @return a string resource id for the tier's label - */ - private static int tierLabelRes(final ChargeSpeedTier tier) { - return switch (tier) { - case TRICKLE -> R.string.charge_tier_trickle; - case NORMAL -> R.string.charge_tier_normal; - case FAST -> R.string.charge_tier_fast; - case SUPER_FAST -> R.string.charge_tier_super_fast; - case SUPER_FAST_PLUS -> R.string.charge_tier_super_fast_plus; - case UNKNOWN -> R.string.charging; - }; - } - - /** - * Show the charge message as a toast. Toasts must be posted from a thread with a Looper, so this - * hops to the main thread when called from a background context. Uses the application context to - * avoid leaking the caller. - * - * @param context The context - * @param message The message to show - */ - private static void showChargeToast(final Context context, final String message) { - final Context appContext = context.getApplicationContext(); - if (Looper.myLooper() == Looper.getMainLooper()) { - Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show(); - } else { - new Handler(Looper.getMainLooper()).post( - () -> Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show()); - } - } - - /** - * Post the charge-connected message as a system notification (the "notification" style). - *

- * Plugging in during quiet hours shouldn't ding: shown on the silent channel outside the window - * instead of the audible full-battery channel (issue #111). Posted under its own ID so it can - * never replace a level alert (#155) — the level alert's dismissal at plug-in is the explicit - * {@link #clearLevelAlert} call in {@code PowerConnectionReceiver}, not an ID collision here. - * - * @param context The application context - * @param content The charge message to display - */ - private static void postChargeNotification(final Context context, final String content) { - if (lacksNotificationPermission(context)) { - Log.w(TAG, "Missing POST_NOTIFICATIONS permission, notification not sent"); - return; - } - - createNotificationChannels(context); - - final String title = context.getString(R.string.notification_charge_started_title); - final String ticker = title.concat(", ").concat(content); - final int iconRes = R.drawable.ic_stat_device_battery_charging_50; - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final boolean withinWindow = isWithinNotificationWindow(context, prefs); - - final Notification.Builder builder = new Notification.Builder(context, channelFor(context, withinWindow, CHANNEL_ID_FULL)) - .setSmallIcon(iconRes) - .setOnlyAlertOnce(true) - .setTicker(ticker) - .setContentTitle(title) - .setContentText(content) - .setWhen(System.currentTimeMillis()) - .setLargeIcon(getLauncherIcon(context)) - .setContentIntent(createMainActivityIntent(context)) - .setVisibility(Notification.VISIBILITY_PUBLIC) - .setStyle(new Notification.BigTextStyle().bigText(content)); - - final NotificationManager manager = getNotificationManager(context); - if (nonNull(manager)) { - manager.notify(CHARGE_CONNECTED_NOTIFICATION_ID, builder.build()); - } + deliverPerChargeStyle(context, content, () -> postChargeNotification(context, content)); } /** @@ -346,11 +152,11 @@ private static void postChargeNotification(final Context context, final String c * @param context The application context * @param rawTenthsC Battery temperature in tenths of a degree Celsius (as reported by BatteryManager) */ - public static void sendTemperatureNotification(final Context context, final int rawTenthsC) { + public static void sendTemperatureNotification(Context context, int rawTenthsC) { final String temperature = TemperatureUtils.format(context, rawTenthsC); sendQuietHoursAwareAlert(context, new AlertSpec( "temperature", - CHANNEL_ID_TEMPERATURE, + NotificationChannels.CHANNEL_ID_TEMPERATURE, TEMPERATURE_NOTIFICATION_ID, R.drawable.ic_stat_temperature_hot, context.getString(R.string.notification_temperature_ticker), @@ -373,8 +179,7 @@ public static void sendTemperatureNotification(final Context context, final int * @param limitPph The user's high-drain limit in %/h * @param elapsedMinutes How long the rate has been sustained at/above the limit, in minutes */ - public static void sendFastDrainNotification(final Context context, final int ratePph, - final int limitPph, final int elapsedMinutes) { + public static void sendFastDrainNotification(Context context, int ratePph, int limitPph, int elapsedMinutes) { // Western digits in every locale (#96) via String.valueOf. final String rate = String.valueOf(ratePph); final String limit = String.valueOf(limitPph); @@ -383,7 +188,7 @@ public static void sendFastDrainNotification(final Context context, final int ra sendQuietHoursAwareAlert(context, new AlertSpec( "fast-drain", - CHANNEL_ID_FAST_DRAIN, + NotificationChannels.CHANNEL_ID_FAST_DRAIN, FAST_DRAIN_NOTIFICATION_ID, R.drawable.ic_stat_device_battery_charging_20, context.getString(R.string.notification_fast_drain_ticker), @@ -404,95 +209,18 @@ public static void sendFastDrainNotification(final Context context, final int ra * @param context The application context * @param watts The estimated charging power in watts */ - public static void sendSlowChargeWarning(final Context context, final int watts) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String style = resolveChargeStyle(prefs.getString( - context.getString(R.string._pref_key_charge_notification_style), CHARGE_STYLE_TOAST)); - if (CHARGE_STYLE_NONE.equals(style)) { - return; // the user opted out of charge feedback entirely - } - + public static void sendSlowChargeWarning(Context context, int watts) { // Western digits in every locale (#96) via String.valueOf. final String content = context.getString(R.string.notification_slow_charge_content, String.valueOf(watts)); - if (CHARGE_STYLE_TOAST.equals(style)) { - showChargeToast(context, content); - return; - } - sendQuietHoursAwareAlert(context, new AlertSpec( + deliverPerChargeStyle(context, content, () -> sendQuietHoursAwareAlert(context, new AlertSpec( "slow-charge", - CHANNEL_ID_SLOW_CHARGE, + NotificationChannels.CHANNEL_ID_SLOW_CHARGE, SLOW_CHARGE_NOTIFICATION_ID, R.drawable.ic_stat_device_battery_charging_20, context.getString(R.string.notification_slow_charge_ticker), context.getString(R.string.notification_slow_charge_title), content, - context.getString(R.string.notification_slow_charge_content_big, String.valueOf(watts)))); - } - - /** - * Send an alert on its own channel and notification ID, honouring quiet hours, silent-mode and - * vibrate preferences. - *

- * The single home of the quiet-hours dance shared by the temperature (#18/#111) and fast-drain - * (#109) alerts: rerouting to the silent channel outside the notification window, and gating the - * alarm sound/vibration on the window — so a quiet-hours fix can't be applied to one alert and - * missed on another. Both alerts reuse the critical-alert ringtone preference, as before. - * - * @param context The application context - * @param spec What to show: channel, id, icon and text content - */ - private static void sendQuietHoursAwareAlert(final Context context, final AlertSpec spec) { - if (lacksNotificationPermission(context)) { - Log.w(TAG, "Missing POST_NOTIFICATIONS permission, " + spec.logName() + " alert not sent"); - return; - } - - createNotificationChannels(context); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - // These are not critical battery alerts, so they respect quiet hours (#111). - final boolean withinWindow = isWithinNotificationWindow(context, prefs); - final String channelId = channelFor(context, withinWindow, spec.audibleChannelId()); - - final Notification.Builder builder = new Notification.Builder(context, channelId) - .setSmallIcon(spec.iconRes()) - .setTicker(spec.ticker()) - .setContentTitle(spec.title()) - .setContentText(spec.content()) - .setWhen(System.currentTimeMillis()) - .setLargeIcon(getLauncherIcon(context)) - .setContentIntent(createMainActivityIntent(context)) - .setVisibility(Notification.VISIBILITY_PUBLIC) - .setStyle(new Notification.BigTextStyle().bigText(spec.bigContent())); - - final NotificationManager manager = getNotificationManager(context); - if (nonNull(manager)) { - manager.notify(spec.notificationId(), builder.build()); - } - - final String sound = prefs.getString( - context.getString(R.string._pref_key_notifications_alert_sound_ringtone), - context.getString(R.string._default_notification_sound_uri)); - if (withinWindow) { - playAlarm(context, sound, shouldIgnoreSilentMode(context, prefs), isVibrationEnabled(context, prefs)); - } - } - - /** - * Everything an own-channel alert needs to display (reduces parameter count, like - * {@link NotificationConfig}). - * - * @param logName short name used in log messages (e.g. "temperature") - * @param audibleChannelId the alert's audible channel; rerouted to the silent channel in quiet hours - * @param notificationId the alert's own notification id, so it never replaces another alert - * @param iconRes small icon resource - * @param ticker ticker text - * @param title content title - * @param content collapsed content text - * @param bigContent expanded (BigTextStyle) content text - */ - private record AlertSpec(String logName, String audibleChannelId, int notificationId, int iconRes, - String ticker, String title, String content, String bigContent) { + context.getString(R.string.notification_slow_charge_content_big, String.valueOf(watts))))); } /** @@ -502,7 +230,7 @@ private record AlertSpec(String logName, String audibleChannelId, int notificati * * @param context The application context */ - public static void clearNotifications(final Context context) { + public static void clearNotifications(Context context) { final NotificationManager manager = getNotificationManager(context); if (nonNull(manager)) { manager.cancel(NOTIFICATION_ID); @@ -520,13 +248,23 @@ public static void clearNotifications(final Context context) { * * @param context The application context */ - public static void clearLevelAlert(final Context context) { + public static void clearLevelAlert(Context context) { final NotificationManager manager = getNotificationManager(context); if (nonNull(manager)) { manager.cancel(NOTIFICATION_ID); } } + /** + * Re-create the alert channels so a changed "Vibrate" preference takes effect (issue #153). + * Delegates to {@link NotificationChannels#refreshAlertChannels(Context)}. + * + * @param context The application context + */ + public static void refreshAlertChannels(Context context) { + NotificationChannels.refreshAlertChannels(context); + } + /** * ID of the persistent status notification (used by the foreground service). * @@ -547,7 +285,7 @@ public static int getOngoingNotificationId() { * @param batteryDO Current battery snapshot, or null if unavailable * @return The built ongoing notification */ - public static Notification buildOngoingNotification(final Context context, final BatteryDO batteryDO) { + public static Notification buildOngoingNotification(Context context, BatteryDO batteryDO) { return buildOngoingNotification(context, batteryDO, BatteryRateTracker.getRate(context, batteryDO)); } @@ -561,15 +299,14 @@ public static Notification buildOngoingNotification(final Context context, final * @param rate The precomputed charge/drain rate * @return The built ongoing notification */ - public static Notification buildOngoingNotification(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { - createNotificationChannels(context); + public static Notification buildOngoingNotification(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + NotificationChannels.ensureChannels(context); - 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)) + final String collapsed = OngoingStatusContent.statusDetail(context, batteryDO, rate); + final String expanded = OngoingStatusContent.statusDetailExpanded(context, batteryDO, rate); + final Notification.Builder builder = new Notification.Builder(context, NotificationChannels.CHANNEL_ID_STATUS) + .setSmallIcon(OngoingStatusContent.ongoingIconRes(batteryDO)) + .setContentTitle(OngoingStatusContent.statusTitle(context, batteryDO)) .setContentText(collapsed) .setContentIntent(createMainActivityIntent(context)) .setOnlyAlertOnce(true) @@ -595,497 +332,278 @@ public static Notification buildOngoingNotification(final Context context, final * @param batteryDO Current battery snapshot, or null if unavailable * @param rate The rate already computed while feeding the sample window */ - public static void updateOngoingNotification(final Context context, final BatteryDO batteryDO, - final BatteryRateTracker.BatteryRate rate) { + public static void updateOngoingNotification(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { if (lacksNotificationPermission(context)) { return; } - final NotificationManager manager = getNotificationManager(context); - if (nonNull(manager)) { - manager.notify(ONGOING_NOTIFICATION_ID, buildOngoingNotification(context, batteryDO, rate)); - } + post(context, ONGOING_NOTIFICATION_ID, buildOngoingNotification(context, batteryDO, rate)); } - /** - * Shutdown the sound executor service - *

- * This method should be called when the application is terminating to ensure - * proper cleanup of the background thread pool. In practice, Android will clean - * up the executor when the process terminates, but explicit shutdown is good - * practice for resource management. - *

- * Note: This is typically called from Application.onTerminate(), though that - * method is rarely invoked on production devices. - */ - public static void shutdown() { - soundExecutor.shutdown(); - try { - if (!soundExecutor.awaitTermination(1, TimeUnit.SECONDS)) { - soundExecutor.shutdownNow(); - } - } catch (InterruptedException e) { - soundExecutor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } + // ========== Private Helper Methods ========== /** - * Release the cached launcher icon bitmap + * Send an alert on its own channel and notification ID, honouring quiet hours, silent-mode and + * vibrate preferences. *

- * This method clears the cached bitmap reference to help with memory management. - * The bitmap will be reloaded on the next access if needed. + * The single home of the quiet-hours dance shared by the temperature (#18/#111), fast-drain (#109) + * and slow-charge (#123) alerts: rerouting to the silent channel outside the notification window, + * and gating the alarm sound/vibration on the window — so a quiet-hours fix can't be applied to one + * alert and missed on another. All reuse the critical-alert ringtone preference, as before. + * + * @param context The application context + * @param spec What to show: channel, id, icon and text content */ - public static void releaseCachedBitmap() { - if (nonNull(cachedLauncherIcon)) { - cachedLauncherIcon.clear(); - cachedLauncherIcon = null; + private static void sendQuietHoursAwareAlert(Context context, AlertSpec spec) { + final AlertRouting routing = routeAlert(context, spec); + if (isNull(routing)) { + return; } - } - // ========== Private Helper Methods ========== + post(context, spec.notificationId(), alertBuilder(context, routing.channelId(), spec).build()); - /** - * Check if app lacks POST_NOTIFICATIONS permission (required for API 33+) - * - * @param context The application context - * @return true if permission is missing, false if granted or not required - */ - private static boolean lacksNotificationPermission(final Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED; + if (routing.withinWindow()) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String sound = prefs.getString( + context.getString(R.string._pref_key_notifications_alert_sound_ringtone), + context.getString(R.string._default_notification_sound_uri)); + AlertSounds.playAlarm(context, sound, QuietHours.shouldIgnoreSilentMode(context, prefs), AppPrefs.vibrateEnabled(context)); } - return false; // Permission isn't required before API 33, so never lacking } /** - * Create notification channels if they don't exist + * Resolve where an own-channel alert posts: its quiet-hours-aware channel and whether alerts may sound + * now. The single home of the permission guard, channel setup and window resolution shared by + * {@link #sendQuietHoursAwareAlert} and {@link #postChargeNotification}; each caller then differs only + * in its {@code setOnlyAlertOnce} and whether it sounds. * * @param context The application context + * @param spec The alert being routed (its audible base channel and log name) + * @return the resolved channel and window state, or null (already logged) when the alert can't be posted */ - private static void createNotificationChannels(final Context context) { - final NotificationManager manager = getNotificationManager(context); - if (isNull(manager)) { - return; + private static AlertRouting routeAlert(Context context, AlertSpec spec) { + if (lacksNotificationPermission(context)) { + Log.w(TAG, "Missing POST_NOTIFICATIONS permission, " + spec.logName() + " alert not sent"); + return null; } - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final boolean vibrate = prefs.getBoolean(context.getString(R.string._pref_key_notifications_vibrate), true); - final int version = alertChannelVersion(prefs); + NotificationChannels.ensureChannels(context); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_CRITICAL, version), - context.getString(R.string.notification_critical_channel_name), - context.getString(R.string.notification_critical_channel_description), Color.RED, vibrate); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_WARNING, version), - context.getString(R.string.notification_warning_channel_name), - context.getString(R.string.notification_warning_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_FULL, version), - context.getString(R.string.notification_full_channel_name), - context.getString(R.string.notification_full_channel_description), Color.GREEN, vibrate); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_TEMPERATURE, version), - context.getString(R.string.notification_temperature_channel_name), - context.getString(R.string.notification_temperature_channel_description), Color.RED, vibrate); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_FAST_DRAIN, version), - context.getString(R.string.notification_fast_drain_channel_name), - context.getString(R.string.notification_fast_drain_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); - createOrUpdateAlertChannel(manager, versionedChannelId(CHANNEL_ID_SLOW_CHARGE, version), - context.getString(R.string.notification_slow_charge_channel_name), - context.getString(R.string.notification_slow_charge_channel_description), Color.rgb(0xff, 0x66, 0x00), vibrate); - createOrUpdateSilentChannel(manager, CHANNEL_ID_STATUS, - context.getString(R.string.notification_status_channel_name), - context.getString(R.string.notification_status_channel_description)); - createOrUpdateSilentChannel(manager, CHANNEL_ID_ALERTS_SILENT, - context.getString(R.string.notification_quiet_channel_name), - context.getString(R.string.notification_quiet_channel_description)); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + // These are not critical battery alerts, so they respect quiet hours (#111). + final boolean withinWindow = QuietHours.isWithinNotificationWindow(context, prefs); + return new AlertRouting(NotificationChannels.channelFor(context, withinWindow, spec.audibleChannelId()), withinWindow); } /** - * Create a low-importance, fully silent channel (no sound, vibration, lights or badge), or update - * its name/description if it already exists. - *

- * Used both by the persistent status notification and, during the user's quiet hours, to deliver - * an alert quietly so it stays visible without disturbing the user (issue #111). - *

- * As with the alert channels, re-calling for an existing ID updates only name/description, so - * translated names reach upgraded installs (#165) without disturbing the silent behaviour. + * Where a quiet-hours-aware alert posts: the resolved channel id and whether alerts may sound now. * - * @param manager The NotificationManager - * @param channelId The channel ID to create - * @param name The channel name - * @param description The channel description + * @param channelId the channel to post on (silent channel outside the window, audible inside) + * @param withinWindow whether now falls inside the notification window (so the alarm may sound) */ - private static void createOrUpdateSilentChannel( - final NotificationManager manager, - final String channelId, - final String name, - final String description) { - final NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_LOW); - channel.setDescription(description); - channel.enableLights(false); - channel.enableVibration(false); - channel.setSound(null, null); - channel.setShowBadge(false); - manager.createNotificationChannel(channel); + private record AlertRouting(String channelId, boolean withinWindow) { } /** - * Create an alert channel, or update its name/description if it already exists. - *

- * Re-calling {@code createNotificationChannel} with an existing ID updates only the name, - * description and group — Android ignores importance, vibration, lights and sound so the user's - * (and the versioned #153) settings are preserved. This is how translated channel names reach - * upgraded installs, not just fresh ones (#165): the channel already exists, so we still - * re-apply the current locale's name and description. - * - * @param manager The NotificationManager - * @param channelId The channel ID - * @param name The channel name - * @param description The channel description - * @param ledColor The LED color for notifications - * @param vibrate Whether the channel should vibrate (from the user's Vibrate preference) - */ - private static void createOrUpdateAlertChannel( - final NotificationManager manager, - final String channelId, - final String name, - final String description, - final int ledColor, - final boolean vibrate) { - final NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_HIGH); - channel.setDescription(description); - channel.enableLights(true); - channel.setLightColor(ledColor); - channel.enableVibration(vibrate); - if (vibrate) { - channel.setVibrationPattern(VIBRATION_PATTERN); - } - manager.createNotificationChannel(channel); - } - - /** - * Re-create the alert channels so a changed "Vibrate" preference takes effect. + * Post the charge-connected message as a system notification (the "notification" style). *

- * Deleting and recreating a channel under the same ID is not enough: Android un-deletes it with - * its old settings (issue #153). So the old-version channels are deleted (keeping system - * settings free of orphans) and the channels are recreated under the next version's IDs, which - * the system treats as brand-new channels with the new vibration setting. The silent channels - * are untouched. + * Plugging in during quiet hours shouldn't ding: shown on the silent channel outside the window + * instead of the audible full-battery channel (issue #111). Posted under its own ID so it can + * never replace a level alert (#155) — the level alert's dismissal at plug-in is the explicit + * {@link #clearLevelAlert} call in {@code PowerConnectionReceiver}, not an ID collision here. * * @param context The application context + * @param content The charge message to display */ - public static void refreshAlertChannels(final Context context) { - final NotificationManager manager = getNotificationManager(context); - if (isNull(manager)) { + private static void postChargeNotification(Context context, String content) { + final String title = context.getString(R.string.notification_charge_started_title); + final String ticker = title.concat(", ").concat(content); + final AlertSpec spec = new AlertSpec( + "charge-connected", + NotificationChannels.CHANNEL_ID_FULL, + CHARGE_CONNECTED_NOTIFICATION_ID, + R.drawable.ic_stat_device_battery_charging_50, + ticker, + title, + content, + content); + + final AlertRouting routing = routeAlert(context, spec); + if (isNull(routing)) { return; } - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final int oldVersion = alertChannelVersion(prefs); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_CRITICAL, oldVersion)); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_WARNING, oldVersion)); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_FULL, oldVersion)); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_TEMPERATURE, oldVersion)); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_FAST_DRAIN, oldVersion)); - manager.deleteNotificationChannel(versionedChannelId(CHANNEL_ID_SLOW_CHARGE, oldVersion)); - - prefs.edit().putInt(PREF_ALERT_CHANNEL_VERSION, oldVersion + 1).apply(); - createNotificationChannels(context); + // Charge-connected never dings (even inside the window); it just alerts once, quietly. + final Notification.Builder builder = alertBuilder(context, routing.channelId(), spec); + builder.setOnlyAlertOnce(true); + post(context, spec.notificationId(), builder.build()); } /** - * Create notification builder with channel and icon + * Apply the shared alert-notification builder chain (issue #166): the identical + * {@code setSmallIcon/Ticker/ContentTitle/ContentText/setWhen/setLargeIcon/setContentIntent/setVisibility/BigTextStyle} + * sequence that the level, own-channel and charge-connected alerts each used to spell out separately. + * Each caller still owns its own channel resolution, notification id, {@code setOnlyAlertOnce}, sticky + * flag and sound — only the identical content chain lives here. * - * @param context The application context - * @param config The notification configuration - * @return Notification.Builder instance - */ - private static Notification.Builder createNotificationBuilder(final Context context, final NotificationConfig config) { - final String channelId = channelFor(context, config.alertsAllowed, config.channelId); - final Notification.Builder builder = new Notification.Builder(context, channelId).setSmallIcon(config.iconRes); - - if (!config.type.alertsEveryTime()) { - builder.setOnlyAlertOnce(true); - } - - return builder; + * @param context The application context + * @param channelId The resolved channel id to post on + * @param display What to show: icon + text (an {@link AlertSpec}) + * @return a Notification.Builder with the shared content applied + */ + private static Notification.Builder alertBuilder(Context context, String channelId, AlertSpec display) { + return new Notification.Builder(context, channelId) + .setSmallIcon(display.iconRes()) + .setTicker(display.ticker()) + .setContentTitle(display.title()) + .setContentText(display.content()) + .setWhen(System.currentTimeMillis()) + .setLargeIcon(getLauncherIcon(context)) + .setContentIntent(createMainActivityIntent(context)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setStyle(new Notification.BigTextStyle().bigText(display.bigContent())); } /** - * The channel an alerting notification should post to: its normal audible (high-importance) channel - * when alerts may sound now, or the shared silent channel during quiet hours so the alert is still - * shown but makes no sound or vibration (issue #111). The audible channel's base ID is resolved to - * its current versioned ID (issue #153), so every posting site tracks version bumps automatically. + * Deliver a charge-related message per the user's chosen charge-notification style (issue #122): + * nothing when they opted out, a brief toast, or the caller's own notification path. The single home + * of the read-pref → resolve → none/toast/notification branch shared by {@link #notifyChargeConnected} + * and {@link #sendSlowChargeWarning}. * * @param context The application context - * @param alertsAllowed whether alerts may sound now (inside the window, or a critical override) - * @param audibleBaseChannelId the base channel ID to use when alerts are allowed to sound - * @return the channel id to post the notification on + * @param content the message to show as a toast (and that the caller shows in its notification) + * @param notificationDelivery the caller's notification path, run only for the "notification" style */ - private static String channelFor(final Context context, final boolean alertsAllowed, final String audibleBaseChannelId) { - if (!alertsAllowed) { - return CHANNEL_ID_ALERTS_SILENT; + private static void deliverPerChargeStyle(Context context, String content, Runnable notificationDelivery) { + final String style = chargeStyle(context); + if (CHARGE_STYLE_NONE.equals(style)) { + return; // User opted out of charge feedback entirely. } - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return versionedChannelId(audibleBaseChannelId, alertChannelVersion(prefs)); - } - - /** - * The alert-channel ID for a given settings version. Version 1 is the original unsuffixed ID; - * later versions append a suffix ("battery_critical_v2", ...) so Android sees a brand-new channel - * instead of un-deleting the old one with its stale settings (issue #153). - * - * @param baseChannelId the unversioned channel ID - * @param version the alert-channel settings version (1-based) - * @return the channel ID to create and post on for that version - */ - static String versionedChannelId(final String baseChannelId, final int version) { - return version <= 1 ? baseChannelId : baseChannelId + "_v" + version; - } - - /** - * The current alert-channel settings version, bumped by {@link #refreshAlertChannels(Context)} - * whenever the Vibrate preference changes. - * - * @param prefs the default SharedPreferences - * @return the current version, 1 for installs that never changed the Vibrate preference - */ - private static int alertChannelVersion(final SharedPreferences prefs) { - return prefs.getInt(PREF_ALERT_CHANNEL_VERSION, 1); + if (CHARGE_STYLE_TOAST.equals(style)) { + showChargeToast(context, content); + return; + } + notificationDelivery.run(); } /** - * Configure notification content (title, text, ticker) + * The user's charge-notification style preference, normalized to a known {@code CHARGE_STYLE_*} value. * * @param context The application context - * @param builder The notification builder - * @param config The notification configuration + * @return one of the {@code CHARGE_STYLE_*} constants */ - private static void configureNotificationContent(final Context context, final Notification.Builder builder, final NotificationConfig config) { - builder.setTicker(config.ticker) - .setContentTitle(config.title) - .setContentText(config.content) - .setWhen(System.currentTimeMillis()) - .setLargeIcon(getLauncherIcon(context)) - .setContentIntent(createMainActivityIntent(context)) - .setVisibility(Notification.VISIBILITY_PUBLIC) - .setStyle(new Notification.BigTextStyle().bigText(config.bigContent)); + private static String chargeStyle(Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return resolveChargeStyle(prefs.getString( + context.getString(R.string._pref_key_charge_notification_style), CHARGE_STYLE_TOAST)); } /** - * Build the final notification + * Normalize a stored charge-style preference value to a known style, defaulting to + * {@link #CHARGE_STYLE_TOAST} for a null/blank/unrecognized value. Pure and Android-free so it is + * unit-testable. * - * @param builder The notification builder - * @param config The notification configuration - * @return The built Notification - */ - private static Notification buildNotification(final Notification.Builder builder, final NotificationConfig config) { - final Notification notification = builder.build(); - - if (config.stickyNotification) { - notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT; - } - - return notification; - } - - /** - * Send notification to system + * @param stored the raw persisted value * - * @param context The application context - * @param notification The notification to send + * @return one of the {@code CHARGE_STYLE_*} constants */ - private static void sendNotificationToSystem(final Context context, final Notification notification) { - final NotificationManager manager = getNotificationManager(context); - if (nonNull(manager)) { - manager.notify(NOTIFICATION_ID, notification); + static String resolveChargeStyle(String stored) { + if (CHARGE_STYLE_NOTIFICATION.equals(stored)) { + return CHARGE_STYLE_NOTIFICATION; } - } - - /** - * Play notification sound if conditions are met - * - * @param context The application context - * @param config The notification configuration - */ - private static void playSoundIfNeeded(final Context context, final NotificationConfig config) { - if (config.alertsAllowed) { - playAlarm(context, config.alarmSound, config.ignoreSilent, config.vibrate); + if (CHARGE_STYLE_NONE.equals(stored)) { + return CHARGE_STYLE_NONE; } + return CHARGE_STYLE_TOAST; } /** - * Play the alert sound (and optionally vibrate) when the phone is silenced but the user opted - * to override silent mode. In normal ringer mode the notification channel plays its own sound. - *

- * Callers must first check that alerts are allowed right now (quiet hours / critical override). + * Build the charge-connected message, e.g. "Wireless · Fast charging · ~18 W", or + * "Wired charging" when the speed can't be estimated. * - * @param context The application context - * @param soundUriStr The alarm sound URI string - * @param ignoreSilent Whether the user opted to override silent/DND mode - * @param vibrate Whether the user enabled vibration + * @param context The application context + * @param speed The estimated charging speed + * @param wireless true for wireless charging, false for wired + * + * @return the localized message shown in the toast or notification */ - private static void playAlarm( - final Context context, - final String soundUriStr, - final boolean ignoreSilent, - final boolean vibrate) { - final Uri soundUri = Uri.parse(soundUriStr); - final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - if (isNull(audioManager)) { - return; - } - - final boolean isNotNormalRingerMode = audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL || isInDoNotDisturbMode(context); + private static String chargeConnectedMessage(Context context, ChargeSpeed speed, boolean wireless) { + final String source = context.getString( + wireless ? R.string.charge_source_wireless : R.string.charge_source_wired); - if (ignoreSilent && isNotNormalRingerMode) { - soundExecutor.execute(() -> { - SystemService.playSound(context, soundUri); - if (vibrate) { - SystemService.vibratePhone(context); - } - }); + if (!speed.isKnown()) { + return context.getString(R.string.charge_connected_plain, source); } - } - /** - * Read the user's "Vibrate" preference (defaults to enabled). - * - * @param context The application context - * @param prefs SharedPreferences containing user settings - * @return true if vibration is enabled - */ - private static boolean isVibrationEnabled(final Context context, final SharedPreferences prefs) { - return prefs.getBoolean(context.getString(R.string._pref_key_notifications_vibrate), true); - } - - /** - * Whether the current time falls inside the user's allowed notification window - * (always true when the time-range limit is disabled). - * - * @param context The application context - * @param prefs SharedPreferences containing user settings - * @return true if alerts are allowed at the current time - */ - private static boolean isWithinNotificationWindow(final Context context, final SharedPreferences prefs) { - // Default ON to match the toggle's XML default (pref_behaviour.xml). Reading false here let a - // fresh install that never opened Time Settings alert around the clock, so quiet hours silently - // did nothing (issue #111). Now quiet hours apply by default (06:30–23:30). - final boolean limitedTime = prefs.getBoolean(context.getString(R.string._pref_key_notifications_time_range), true); - final String defaultStartTime = context.getString(R.string._pref_value_notifications_time_range_start); - final String defaultEndTime = context.getString(R.string._pref_value_notifications_time_range_end); - final String startTime = prefs.getString(context.getString(R.string._pref_key_notifications_time_range_start), defaultStartTime); - final String endTime = prefs.getString(context.getString(R.string._pref_key_notifications_time_range_end), defaultEndTime); - return isWithinTime(startTime, endTime, defaultStartTime, defaultEndTime) || !limitedTime; + final String tierLabel = context.getString(tierLabelRes(speed.getTier())); + final int watts = speed.getWatts(); + if (watts < 1) { + // Known tier but sub-watt (e.g. trickle): showing "~0 W" would read as an error. + return context.getString(R.string.charge_connected_tier, source, tierLabel); + } + return context.getString(R.string.charge_connected_power, source, tierLabel, watts); } /** - * Whether an alert may sound/vibrate right now, given the quiet-hours window and the critical-alert - * override. Alerts are allowed inside the window; a critical alert may additionally be allowed to - * break through quiet hours when the user has left that option on (default), so a genuinely low - * battery still wakes them (issue #111). Pure and Android-free so it is unit-testable. + * Map a {@link ChargeSpeedTier} to its localized label resource. * - * @param withinWindow whether now falls inside the allowed notification window - * @param isCritical whether this is a critical (about-to-die) alert - * @param criticalIgnoresQuietHours whether critical alerts are allowed to break through quiet hours - * @return true when the alert may sound/vibrate now - */ - static boolean alertsAllowedNow(final boolean withinWindow, final boolean isCritical, - final boolean criticalIgnoresQuietHours) { - return withinWindow || (isCritical && criticalIgnoresQuietHours); - } - - /** - * Whether the user opted to override silent/DND mode for alerts. + * @param tier the charging-speed tier * - * @param context The application context - * @param prefs SharedPreferences containing user settings - * @return true if silent mode should be overridden + * @return a string resource id for the tier's label */ - private static boolean shouldIgnoreSilentMode(final Context context, final SharedPreferences prefs) { - return !prefs.getBoolean(context.getString(R.string._pref_key_notifications_apply_silent_mode), false); + private static int tierLabelRes(ChargeSpeedTier tier) { + return switch (tier) { + case TRICKLE -> R.string.charge_tier_trickle; + case NORMAL -> R.string.charge_tier_normal; + case FAST -> R.string.charge_tier_fast; + case SUPER_FAST -> R.string.charge_tier_super_fast; + case SUPER_FAST_PLUS -> R.string.charge_tier_super_fast_plus; + case UNKNOWN -> R.string.charging; + }; } /** - * Check if device is in Do Not Disturb mode + * Show the charge message as a toast. Toasts must be posted from a thread with a Looper, so this + * hops to the main thread when called from a background context. Uses the application context to + * avoid leaking the caller. * - * @param context The application context - * @return true if in DND mode, false otherwise + * @param context The context + * @param message The message to show */ - private static boolean isInDoNotDisturbMode(final Context context) { - try { - return ZEN_MODE_IMPORTANT_INTERRUPTIONS == Settings.Global.getInt(context.getContentResolver(), ZEN_MODE); - } catch (Settings.SettingNotFoundException e) { - return false; + private static void showChargeToast(Context context, String message) { + final Context appContext = context.getApplicationContext(); + if (Looper.myLooper() == Looper.getMainLooper()) { + Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show(); + } else { + new Handler(Looper.getMainLooper()).post( + () -> Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show()); } } /** - * Check if current time is within notification time range. Never throws: this runs on the - * broadcast path of every alert, and a corrupt stored bound must not turn alerting into a crash - * loop (issue #154) — a malformed bound falls back to that bound's default instead. - * - * @param startTime Start time string (HH:mm format) - * @param endTime End time string (HH:mm format) - * @param defaultStartTime Default window start, used when {@code startTime} is malformed - * @param defaultEndTime Default window end, used when {@code endTime} is malformed - * @return true if current time is within range - */ - private static boolean isWithinTime( - final String startTime, - final String endTime, - final String defaultStartTime, - final String defaultEndTime) { - final LocalTime now = LocalTime.now(); - final int nowMinutes = now.getHour() * 60 + now.getMinute(); - final int startMinutes = boundOrDefaultMinutes(startTime, defaultStartTime); - final int endMinutes = boundOrDefaultMinutes(endTime, defaultEndTime); - return isWithinTimeRange(nowMinutes, startMinutes, endMinutes); - } - - /** - * A quiet-hours window bound as minutes since midnight, falling back to the bound's default when - * the stored value is malformed (backup/restore corruption, prefs damage — issue #154). The - * fallback is logged: a corrupt pref is unexpected and should be visible, just never fatal. + * Check if app lacks POST_NOTIFICATIONS permission (required for API 33+) * - * @param storedTime the persisted "HH:MM" bound - * @param defaultTime the bound's default from resources, expected to always parse - * @return the bound in minutes since midnight + * @param context The application context + * @return true if permission is missing, false if granted or not required */ - static int boundOrDefaultMinutes(final String storedTime, final String defaultTime) { - final int minutes = GeneralHelper.parseTimeToMinutes(storedTime); - if (minutes >= 0) { - return minutes; + private static boolean lacksNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED; } - Log.w(TAG, "Malformed quiet-hours time \"" + storedTime + "\"; using default " + defaultTime); - // The default is a compile-time resource constant, so this parse can't realistically fail; - // floor at midnight rather than propagate -1 into the range check just in case. - return Math.max(0, GeneralHelper.parseTimeToMinutes(defaultTime)); + return false; // Permission isn't required before API 33, so never lacking } /** - * Pure minute-of-day range check. Handles same-day, overnight and equal-times windows - * (the previous implementation ignored minutes and mishandled overnight ranges that shared - * the same hour bucket). - *

- * Start is inclusive, end is exclusive. + * Post a notification, no-op when the NotificationManager is unavailable. * - * @param nowMinutes Current time as minutes since midnight - * @param startMinutes Window start as minutes since midnight - * @param endMinutes Window end as minutes since midnight - * @return true if now falls inside the window + * @param context The application context + * @param id The notification id to post under + * @param notification The notification to post */ - static boolean isWithinTimeRange(final int nowMinutes, final int startMinutes, final int endMinutes) { - if (startMinutes == endMinutes) { - return true; // Whole day - } - if (startMinutes < endMinutes) { - return nowMinutes >= startMinutes && nowMinutes < endMinutes; + private static void post(Context context, int id, Notification notification) { + final NotificationManager manager = getNotificationManager(context); + if (nonNull(manager)) { + manager.notify(id, notification); } - // Overnight window wraps past midnight - return nowMinutes >= startMinutes || nowMinutes < endMinutes; } /** @@ -1094,7 +612,7 @@ static boolean isWithinTimeRange(final int nowMinutes, final int startMinutes, f * @param context The application context * @return NotificationManager instance, or null if unavailable */ - private static NotificationManager getNotificationManager(final Context context) { + private static NotificationManager getNotificationManager(Context context) { return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); } @@ -1107,7 +625,7 @@ private static NotificationManager getNotificationManager(final Context context) * @param context The application context * @return Launcher icon bitmap */ - private static Bitmap getLauncherIcon(final Context context) { + private static Bitmap getLauncherIcon(Context context) { Bitmap bitmap = null; if (nonNull(cachedLauncherIcon)) { bitmap = cachedLauncherIcon.get(); @@ -1127,351 +645,9 @@ private static Bitmap getLauncherIcon(final Context context) { * @param context The application context * @return PendingIntent for MainActivity */ - private static PendingIntent createMainActivityIntent(final Context context) { + private static PendingIntent createMainActivityIntent(Context context) { final Intent intent = new Intent(context, MainActivity.class); final int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; return PendingIntent.getActivity(context, 0, intent, flags); } - - /** - * Build the title line of the ongoing status notification: the stable headline "85% · Discharging". - *

- * 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 - * @return Formatted title text - */ - 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, statusLabel); - } - - /** - * 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 collapsed text (empty when there's no snapshot at all) - */ - static String statusDetail(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { - final List parts = new ArrayList<>(3); - 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 (parts.isEmpty()) { - return nonNull(batteryDO) ? isolate(TemperatureUtils.format(context, batteryDO.getTemperature())) : ""; - } - return String.join(DETAIL_SEPARATOR, parts); - } - - /** - * 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 - * @return newline-joined expanded text (may be a single line or empty) - */ - 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 = 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 isolate(power); - } - } - 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; - } - 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; - } - 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 — 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 powerSegment(Context context, BatteryDO batteryDO) { - final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); - if (!speed.isKnown() || speed.getWatts() < 1) { - return null; - } - // Western digits (0-9) in every locale (#96). - return context.getString(R.string.notification_status_watts, String.valueOf(speed.getWatts())); - } - - /** - * 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 value the Latin value to isolate - * @return the value, bidi-isolated in RTL locales; unchanged in LTR - */ - private static String isolate(String value) { - if (TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) { - return BidiFormatter.getInstance().unicodeWrap(value); - } - return value; - } - - /** - * 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); - } - - /** - * 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 - * level. (A charged-but-still-plugged battery reads as full, without a bolt.) - * - * @param batteryDO Current battery snapshot, or null if unavailable - * @return Drawable resource id - */ - private static int ongoingIconRes(final BatteryDO batteryDO) { - if (isNull(batteryDO)) { - return R.drawable.ic_stat_battery_full; - } - if (batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING) { - return R.drawable.ic_stat_battery_charging; - } - return batteryDO.getBatteryPercentageInt() <= 50 - ? R.drawable.ic_stat_battery_low - : R.drawable.ic_stat_battery_full; - } - - /** - * Configuration object for notification creation (reduces parameter count) - *

- * This class encapsulates all configuration needed to build a notification, - * extracted from SharedPreferences and notification type. - */ - private static class NotificationConfig { - final AlertType type; - final String channelId; - final int iconRes; - final String ticker; - final String title; - final String content; - final String bigContent; - final String alarmSound; - final boolean alertsAllowed; - final boolean ignoreSilent; - final boolean vibrate; - final boolean stickyNotification; - - /** - * Create a NotificationConfig from preferences and type - * - * @param context The application context - * @param prefs SharedPreferences containing user settings - * @param type Which battery-level alert to configure (non-null) - */ - NotificationConfig(final Context context, final SharedPreferences prefs, final AlertType type) { - this.type = type; - - // Load common preferences - final int warningLevel = AppPrefs.warningLevel(context); - final int criticalLevel = AppPrefs.criticalLevel(context); - - this.stickyNotification = prefs.getBoolean(context.getString(R.string._pref_key_notifications_sticky), false); - final boolean withinWindow = isWithinNotificationWindow(context, prefs); - final boolean criticalIgnoresQuietHours = prefs.getBoolean(context.getString(R.string._pref_key_critical_ignore_quiet_hours), true); - this.alertsAllowed = alertsAllowedNow(withinWindow, type == AlertType.CRITICAL, criticalIgnoresQuietHours); - this.ignoreSilent = shouldIgnoreSilentMode(context, prefs); - this.vibrate = isVibrationEnabled(context, prefs); - - final String defaultSound = context.getString(R.string._default_notification_sound_uri); - - // A switch EXPRESSION over the enum is exhaustive at compile time — the old int switch - // needed a default branch, which posted a completely blank notification on any invalid - // type value (issue #160). That branch can no longer exist. - final AlertStyle style = switch (type) { - case CRITICAL -> new AlertStyle( - CHANNEL_ID_CRITICAL, - R.drawable.ic_stat_device_battery_charging_20, - prefs.getString(context.getString(R.string._pref_key_notifications_alert_sound_ringtone), defaultSound), - context.getString(R.string.notification_critical_ticker, criticalLevel), - context.getString(R.string.notification_critical_title), - context.getString(R.string.notification_critical_content, criticalLevel), - context.getString(R.string.notification_critical_content_big, criticalLevel)); - case WARNING -> new AlertStyle( - CHANNEL_ID_WARNING, - R.drawable.ic_stat_device_battery_charging_50, - prefs.getString(context.getString(R.string._pref_key_notifications_warning_sound_ringtone), defaultSound), - context.getString(R.string.notification_warning_ticker, warningLevel), - context.getString(R.string.notification_warning_title), - context.getString(R.string.notification_warning_content, warningLevel), - context.getString(R.string.notification_warning_content_big, warningLevel)); - case FULL -> new AlertStyle( - CHANNEL_ID_FULL, - R.drawable.ic_stat_device_battery_charging_full, - prefs.getString(context.getString(R.string._pref_key_notifications_full_sound_ringtone), defaultSound), - context.getString(R.string.notification_full_level_ticker), - context.getString(R.string.notification_full_level_title), - context.getString(R.string.notification_full_level_content), - context.getString(R.string.notification_full_level_content_big)); - }; - this.channelId = style.channelId(); - this.iconRes = style.iconRes(); - this.alarmSound = style.alarmSound(); - this.ticker = style.ticker(); - this.title = style.title(); - this.content = style.content(); - this.bigContent = style.bigContent(); - } - } - - /** - * The per-type presentation of a battery-level alert, produced by the exhaustive type switch in - * {@link NotificationConfig} (#160). - * - * @param channelId the audible base channel ID for this type - * @param iconRes the small-icon resource - * @param alarmSound the user's chosen alarm sound for this type - * @param ticker the ticker text - * @param title the notification title - * @param content the collapsed content line - * @param bigContent the expanded (BigTextStyle) content - */ - private record AlertStyle(String channelId, int iconRes, String alarmSound, String ticker, - String title, String content, String bigContent) { - } } diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContent.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContent.java new file mode 100644 index 0000000..5e63bb9 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContent.java @@ -0,0 +1,285 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.os.BatteryManager; +import android.view.View; + +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; +import com.almothafar.simplebatterynotifier.model.ChargeSpeed; +import com.almothafar.simplebatterynotifier.util.BatteryPercentFormatter; +import com.almothafar.simplebatterynotifier.util.TemperatureUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +/** + * Builds the text of the persistent (ongoing) battery-status notification (issue #166): its stable + * title, the collapsed detail line and the expanded (BigText) breakdown, plus the small icon that + * reflects the current battery state. + *

+ * Split out of {@code NotificationService} — which now only wires this text into a {@code Notification} — + * so the AccuBattery-style status layout (#194) and its RTL bidi-isolation (#194) live in one place and + * stay directly unit-testable ({@code OngoingStatusContentTest}). + */ +final class OngoingStatusContent { + + /** Joins the ongoing notification's detail segments (rate/power · current · time). */ + private static final String DETAIL_SEPARATOR = " · "; + + private OngoingStatusContent() { + // Utility class - prevent instantiation + } + + /** + * Build the title line of the ongoing status notification: the stable headline "85% · Discharging". + *

+ * 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 + * @return Formatted title text + */ + 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, statusLabel); + } + + /** + * 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 collapsed text (empty when there's no snapshot at all) + */ + static String statusDetail(Context context, BatteryDO batteryDO, BatteryRateTracker.BatteryRate rate) { + final List parts = new ArrayList<>(3); + 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 (parts.isEmpty()) { + return nonNull(batteryDO) ? isolate(TemperatureUtils.format(context, batteryDO.getTemperature())) : ""; + } + return String.join(DETAIL_SEPARATOR, parts); + } + + /** + * 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 + * @return newline-joined expanded text (may be a single line or empty) + */ + 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); + } + + /** + * 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 + * level. (A charged-but-still-plugged battery reads as full, without a bolt.) + * + * @param batteryDO Current battery snapshot, or null if unavailable + * @return Drawable resource id + */ + static int ongoingIconRes(BatteryDO batteryDO) { + if (isNull(batteryDO)) { + return R.drawable.ic_stat_battery_full; + } + if (batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING) { + return R.drawable.ic_stat_battery_charging; + } + return batteryDO.getBatteryPercentageInt() <= 50 + ? R.drawable.ic_stat_battery_low + : R.drawable.ic_stat_battery_full; + } + + 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 = 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 isolate(power); + } + } + 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; + } + 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; + } + 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 — 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 powerSegment(Context context, BatteryDO batteryDO) { + final ChargeSpeed speed = ChargeSpeed.fromMeasurements(batteryDO.getCurrentMicroAmps(), batteryDO.getVoltage()); + if (!speed.isKnown() || speed.getWatts() < 1) { + return null; + } + // Western digits (0-9) in every locale (#96). + return context.getString(R.string.notification_status_watts, String.valueOf(speed.getWatts())); + } + + /** + * 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 value the Latin value to isolate + * @return the value, bidi-isolated in RTL locales; unchanged in LTR + */ + private static String isolate(String value) { + if (TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) { + return BidiFormatter.getInstance().unicodeWrap(value); + } + return value; + } + + /** + * 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/java/com/almothafar/simplebatterynotifier/service/QuietHours.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/QuietHours.java new file mode 100644 index 0000000..4ab9105 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/QuietHours.java @@ -0,0 +1,140 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.util.GeneralHelper; + +import java.time.LocalTime; + +/** + * The quiet-hours and silent-mode policy for alerts (issue #166): given the user's time-range + * window and the critical-alert override, decide whether an alert may sound/vibrate right now. + *

+ * Split out of {@code NotificationService} so the single-responsibility line is clear and the pure + * decision cores stay easy to unit-test. The core checks ({@link #isWithinTimeRange}, + * {@link #alertsAllowedNow}, {@link #boundOrDefaultMinutes}) are Android-free and directly tested; + * {@link #boundOrDefaultMinutes} never throws so a corrupt stored bound can't turn every alert into a + * crash loop (issue #154). + */ +final class QuietHours { + private static final String TAG = QuietHours.class.getSimpleName(); + + private QuietHours() { + // Utility class - prevent instantiation + } + + /** + * Whether the current time falls inside the user's allowed notification window + * (always true when the time-range limit is disabled). + * + * @param context The application context + * @param prefs SharedPreferences containing user settings + * @return true if alerts are allowed at the current time + */ + static boolean isWithinNotificationWindow(Context context, SharedPreferences prefs) { + // Default ON to match the toggle's XML default (pref_behaviour.xml). Reading false here let a + // fresh install that never opened Time Settings alert around the clock, so quiet hours silently + // did nothing (issue #111). Now quiet hours apply by default (06:30–23:30). + final boolean limitedTime = prefs.getBoolean(context.getString(R.string._pref_key_notifications_time_range), true); + final String defaultStartTime = context.getString(R.string._pref_value_notifications_time_range_start); + final String defaultEndTime = context.getString(R.string._pref_value_notifications_time_range_end); + final String startTime = prefs.getString(context.getString(R.string._pref_key_notifications_time_range_start), defaultStartTime); + final String endTime = prefs.getString(context.getString(R.string._pref_key_notifications_time_range_end), defaultEndTime); + return isWithinTime(startTime, endTime, defaultStartTime, defaultEndTime) || !limitedTime; + } + + /** + * Whether an alert may sound/vibrate right now, given the quiet-hours window and the critical-alert + * override. Alerts are allowed inside the window; a critical alert may additionally be allowed to + * break through quiet hours when the user has left that option on (default), so a genuinely low + * battery still wakes them (issue #111). Pure and Android-free so it is unit-testable. + * + * @param withinWindow whether now falls inside the allowed notification window + * @param isCritical whether this is a critical (about-to-die) alert + * @param criticalIgnoresQuietHours whether critical alerts are allowed to break through quiet hours + * @return true when the alert may sound/vibrate now + */ + static boolean alertsAllowedNow(boolean withinWindow, boolean isCritical, boolean criticalIgnoresQuietHours) { + return withinWindow || (isCritical && criticalIgnoresQuietHours); + } + + /** + * Whether the user opted to override silent/DND mode for alerts. + * + * @param context The application context + * @param prefs SharedPreferences containing user settings + * @return true if silent mode should be overridden + */ + static boolean shouldIgnoreSilentMode(Context context, SharedPreferences prefs) { + return !prefs.getBoolean(context.getString(R.string._pref_key_notifications_apply_silent_mode), false); + } + + /** + * Check if current time is within notification time range. Never throws: this runs on the + * broadcast path of every alert, and a corrupt stored bound must not turn alerting into a crash + * loop (issue #154) — a malformed bound falls back to that bound's default instead. + * + * @param startTime Start time string (HH:mm format) + * @param endTime End time string (HH:mm format) + * @param defaultStartTime Default window start, used when {@code startTime} is malformed + * @param defaultEndTime Default window end, used when {@code endTime} is malformed + * @return true if current time is within range + */ + private static boolean isWithinTime(String startTime, String endTime, String defaultStartTime, String defaultEndTime) { + final LocalTime now = LocalTime.now(); + final int nowMinutes = now.getHour() * 60 + now.getMinute(); + final int startMinutes = boundOrDefaultMinutes(startTime, defaultStartTime); + final int endMinutes = boundOrDefaultMinutes(endTime, defaultEndTime); + return isWithinTimeRange(nowMinutes, startMinutes, endMinutes); + } + + /** + * A quiet-hours window bound as minutes since midnight, falling back to the bound's default when + * the stored value is malformed (backup/restore corruption, prefs damage — issue #154). The + * fallback is logged: a corrupt pref is unexpected and should be visible, just never fatal. + * + * @param storedTime the persisted "HH:MM" bound + * @param defaultTime the bound's default from resources, expected to always parse + * @return the bound in minutes since midnight + */ + static int boundOrDefaultMinutes(String storedTime, String defaultTime) { + final int minutes = GeneralHelper.parseTimeToMinutes(storedTime); + if (minutes >= 0) { + return minutes; + } + Log.w(TAG, "Malformed quiet-hours time \"" + storedTime + "\"; using default " + defaultTime); + // The default is a compile-time resource constant, so this parse can't realistically fail; + // floor at midnight rather than propagate -1 into the range check just in case. + return Math.max(0, GeneralHelper.parseTimeToMinutes(defaultTime)); + } + + /** + * Pure minute-of-day range check. Handles same-day, overnight and equal-times windows + * (the previous implementation ignored minutes and mishandled overnight ranges that shared + * the same hour bucket). + *

+ * Start is inclusive, end is exclusive. + * + * @param nowMinutes Current time as minutes since midnight + * @param startMinutes Window start as minutes since midnight + * @param endMinutes Window end as minutes since midnight + * @return true if now falls inside the window + */ + static boolean isWithinTimeRange(int nowMinutes, int startMinutes, int endMinutes) { + if (startMinutes == endMinutes) { + return true; // Whole day + } + if (startMinutes < endMinutes) { + return nowMinutes >= startMinutes && nowMinutes < endMinutes; + } + // Overnight window wraps past midnight + return nowMinutes >= startMinutes || nowMinutes < endMinutes; + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java index bda8063..8359376 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java @@ -47,6 +47,11 @@ public final class SystemService { private static final String POWER_SUPPLY_DIR = "/sys/class/power_supply"; private static final String CHARGE_FULL_DESIGN_NODE = "charge_full_design"; + // The single alert vibration pattern (issue #166): the alert channels' vibration and the manual + // silent-mode-override buzz in vibratePhone() both use it, so the two can't drift. Waveform is + // {delay, on, off, on, off} in milliseconds; -1 = don't repeat. + static final long[] VIBRATION_PATTERN = {0, 500, 250, 500, 250}; + private SystemService() { // Utility class - prevent instantiation } @@ -555,9 +560,8 @@ public static void vibratePhone(final Context context) { } if (vibrator.hasVibrator()) { - final long[] pattern = {0, 500, 250, 500, 250}; // Use VibrationEffect for O+ (API 26 is minSdk, so this is always available) - vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + vibrator.vibrate(VibrationEffect.createWaveform(VIBRATION_PATTERN, -1)); } else { Log.w(TAG, "Device cannot vibrate"); } diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/util/AppPrefs.java b/app/src/main/java/com/almothafar/simplebatterynotifier/util/AppPrefs.java index a768e08..a5630d8 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/util/AppPrefs.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/util/AppPrefs.java @@ -22,7 +22,10 @@ * and the pair travels as a {@link LevelThresholds}; *
  • the shared "high drain" limit — its default, accepted range and clamp ({@link #drainLimitPph} * + {@link #clampDrainLimit}) moved here from {@code BatteryRateTracker}, so "a corrupt stored - * value can't defeat the feature" lives in one place.
  • + * value can't defeat the feature" lives in one place; + *
  • the "Vibrate" flag ({@link #vibrateEnabled}) — previously re-read with an inline {@code true} + * default in three spots (channel creation, the level-alert config and the manual override path), + * which meant the alert channels and the silent-mode buzz could drift apart.
  • * * The one restatement that remains is the XML-declared slider default in {@code pref_alerts.xml}, which * the framework instantiates from XML and so cannot share a constant with — a comment ties the two, and @@ -42,6 +45,9 @@ public final class AppPrefs { /** Highest accepted drain limit in %/h; mirrors the slider's {@code android:max} in pref_alerts.xml. */ public static final int MAX_DRAIN_LIMIT_PPH = 60; + /** Default for the "Vibrate" preference — mirrors the switch's {@code android:defaultValue} in pref_behaviour.xml. */ + public static final boolean DEFAULT_VIBRATE = true; + private AppPrefs() { // Utility class } @@ -124,6 +130,19 @@ public static int clampDrainLimit(final int stored) { return Math.max(MIN_DRAIN_LIMIT_PPH, Math.min(MAX_DRAIN_LIMIT_PPH, stored)); } + /** + * Whether the "Vibrate" preference is on (default {@link #DEFAULT_VIBRATE}). It drives both the alert + * channels' vibration and the manual silent-mode-override vibration, so those two reads can't disagree + * about whether to buzz. + * + * @param context Application context + * + * @return true when vibration is enabled + */ + public static boolean vibrateEnabled(Context context) { + return prefs(context).getBoolean(context.getString(R.string._pref_key_notifications_vibrate), DEFAULT_VIBRATE); + } + private static SharedPreferences prefs(final Context context) { return PreferenceManager.getDefaultSharedPreferences(context); } diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationChannelsTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationChannelsTest.java new file mode 100644 index 0000000..8926e51 --- /dev/null +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationChannelsTest.java @@ -0,0 +1,43 @@ +package com.almothafar.simplebatterynotifier.service; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; + +/** + * {@link NotificationChannels#versionedChannelId(String, int)} — versioned alert-channel IDs so a + * Vibrate change creates genuinely new channels instead of un-deleting old ones (issue #153). + * Version 1 must stay the original unsuffixed ID so existing installs keep their channels. + */ +@RunWith(Parameterized.class) +public class NotificationChannelsTest { + + @Parameter(0) public String label; + @Parameter(1) public String baseId; + @Parameter(2) public int version; + @Parameter(3) public String expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"version 1 keeps the legacy unsuffixed ID", "battery_critical", 1, "battery_critical"}, + {"version 2 appends _v2", "battery_critical", 2, "battery_critical_v2"}, + {"later versions keep counting", "battery_warning", 7, "battery_warning_v7"}, + {"different base IDs stay distinct", "battery_full", 2, "battery_full_v2"}, + {"defensive: version 0 treated as legacy", "battery_critical", 0, "battery_critical"}, + {"defensive: negative version treated as legacy", "battery_critical", -3, "battery_critical"}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, NotificationChannels.versionedChannelId(baseId, version)); + } +} 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 311d84e..9590b01 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -2,18 +2,14 @@ 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; @@ -29,156 +25,16 @@ import java.util.Collection; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.robolectric.Shadows.shadowOf; /** - * Unit tests for the pure logic in {@link NotificationService}: quiet hours, charge-style - * normalization and versioned alert-channel IDs. - * Times are expressed as minutes since midnight (hour * 60 + minute). + * Tests for the {@link NotificationService} dispatch layer: the charge-connected notification wiring + * and the charge-style normalization. The channel/quiet-hours/ongoing-text logic it delegates to lives + * in {@link NotificationChannelsTest}, {@link QuietHoursTest} and {@link OngoingStatusContentTest}. */ @RunWith(Enclosed.class) public class NotificationServiceTest { - /** - * {@link NotificationService#isWithinTimeRange(int, int, int)} across daytime, overnight-wrap and - * degenerate windows. The label documents each row's intent. - */ - @RunWith(Parameterized.class) - public static class TimeRange { - - // Daytime window 08:00 (480) – 23:00 (1380) - private static final int DAY_START = 8 * 60; - private static final int DAY_END = 23 * 60; - - // Overnight window 22:00 (1320) – 06:00 (360) - private static final int NIGHT_START = 22 * 60; - private static final int NIGHT_END = 6 * 60; - - @Parameter(0) public String label; - @Parameter(1) public int now; - @Parameter(2) public int start; - @Parameter(3) public int end; - @Parameter(4) public boolean expected; - - @Parameters(name = "{0}") - public static Collection data() { - return Arrays.asList(new Object[][]{ - {"daytime inside", 10 * 60, DAY_START, DAY_END, true}, - {"daytime before start excluded", 6 * 60 + 40, DAY_START, DAY_END, false}, - {"daytime start is inclusive", DAY_START, DAY_START, DAY_END, true}, - {"daytime end is exclusive", DAY_END, DAY_START, DAY_END, false}, - {"overnight late night inside", 22 * 60 + 30, NIGHT_START, NIGHT_END, true}, - {"overnight early morning inside", 2 * 60, NIGHT_START, NIGHT_END, true}, - {"overnight midday outside", 12 * 60, NIGHT_START, NIGHT_END, false}, - // Overnight window whose start/end share the same hour bucket (22:30 -> 22:00). The old - // hour-only logic mishandled this; the minute-based logic must not. - {"same-hour-bucket 23:30 inside", 23 * 60 + 30, 22 * 60 + 30, 22 * 60, true}, - {"same-hour-bucket 22:15 outside", 22 * 60 + 15, 22 * 60 + 30, 22 * 60, false}, - // start == end means the whole day is covered. - {"equal times means whole day (00:00)", 0, 10 * 60, 10 * 60, true}, - {"equal times means whole day (23:59)", 23 * 60 + 59, 10 * 60, 10 * 60, true}, - }); - } - - @Test - public void matchesExpected() { - assertEquals(label, expected, NotificationService.isWithinTimeRange(now, start, end)); - } - } - - /** - * {@link NotificationService#alertsAllowedNow(boolean, boolean, boolean)} — quiet-hours gating with - * the critical override (issue #111). - */ - @RunWith(Parameterized.class) - public static class AlertsAllowedNow { - - @Parameter(0) public boolean withinWindow; - @Parameter(1) public boolean isCritical; - @Parameter(2) public boolean criticalIgnoresQuietHours; - @Parameter(3) public boolean expected; - - @Parameters(name = "within={0} critical={1} override={2} -> {3}") - public static Collection data() { - return Arrays.asList(new Object[][]{ - {true, false, false, true}, // inside window -> always allowed - {true, true, false, true}, // inside window -> allowed even without the override - {false, false, true, false}, // outside, non-critical -> silenced - {false, true, true, true}, // outside, critical breaks through when enabled - {false, true, false, false}, // outside, critical silenced when override off - }); - } - - @Test - public void matchesExpected() { - assertEquals(expected, - NotificationService.alertsAllowedNow(withinWindow, isCritical, criticalIgnoresQuietHours)); - } - } - - /** - * {@link NotificationService#versionedChannelId(String, int)} — versioned alert-channel IDs so a - * Vibrate change creates genuinely new channels instead of un-deleting old ones (issue #153). - * Version 1 must stay the original unsuffixed ID so existing installs keep their channels. - */ - @RunWith(Parameterized.class) - public static class VersionedChannelId { - - @Parameter(0) public String label; - @Parameter(1) public String baseId; - @Parameter(2) public int version; - @Parameter(3) public String expected; - - @Parameters(name = "{0}") - public static Collection data() { - return Arrays.asList(new Object[][]{ - {"version 1 keeps the legacy unsuffixed ID", "battery_critical", 1, "battery_critical"}, - {"version 2 appends _v2", "battery_critical", 2, "battery_critical_v2"}, - {"later versions keep counting", "battery_warning", 7, "battery_warning_v7"}, - {"different base IDs stay distinct", "battery_full", 2, "battery_full_v2"}, - {"defensive: version 0 treated as legacy", "battery_critical", 0, "battery_critical"}, - {"defensive: negative version treated as legacy", "battery_critical", -3, "battery_critical"}, - }); - } - - @Test - public void matchesExpected() { - assertEquals(label, expected, NotificationService.versionedChannelId(baseId, version)); - } - } - - /** - * {@link NotificationService#boundOrDefaultMinutes(String, String)} — a corrupt stored - * quiet-hours bound falls back to that bound's default instead of crashing the alert path - * (issue #154). Runs under Robolectric because the fallback is logged. - */ - @RunWith(RobolectricTestRunner.class) - @Config(sdk = 34) - public static class BoundOrDefaultMinutes { - - private static final String DEFAULT_START = "06:30"; - - @Test - public void validStoredBoundWins() { - assertEquals(22 * 60 + 15, NotificationService.boundOrDefaultMinutes("22:15", DEFAULT_START)); - } - - @Test - public void malformedStoredBoundFallsBackToDefault() { - assertEquals(6 * 60 + 30, NotificationService.boundOrDefaultMinutes("ab:cd", DEFAULT_START)); - assertEquals(6 * 60 + 30, NotificationService.boundOrDefaultMinutes(null, DEFAULT_START)); - assertEquals(6 * 60 + 30, NotificationService.boundOrDefaultMinutes("25:99", DEFAULT_START)); - } - - @Test - public void unparseableDefaultFloorsAtMidnightInsteadOfPropagating() { - // Can't happen with the real resource constants; the floor just keeps the impossible - // case inside the valid minutes-of-day range. - assertEquals(0, NotificationService.boundOrDefaultMinutes("bad", "also bad")); - } - } - /** * The charge-connected notification wiring (#155): posted under its own ID so it can never * replace a critical/warning/full level alert, cleared together with the level alert on @@ -245,149 +101,6 @@ public void nullAlertType_postsNothing() { } } - /** - * 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 - 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 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 title_isStableHeadline_notAppNameNorRate() { - final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rate(false, 9)); - assertEquals("85% · Discharging", String.valueOf(built.extras.getCharSequence(Notification.EXTRA_TITLE))); - } - - @Test - 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 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 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 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(); - // 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)); - } - } - /** * {@link NotificationService#resolveChargeStyle(String)} — normalizes the persisted charge-style * preference, defaulting a null/blank/unrecognized value to Toast (issue #122). diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContentTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContentTest.java new file mode 100644 index 0000000..5a84d46 --- /dev/null +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/OngoingStatusContentTest.java @@ -0,0 +1,167 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.app.Notification; +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.util.TemperatureUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * 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. + *

    + * The text lives in {@link OngoingStatusContent}; the built {@link Notification} is assembled by + * {@link NotificationService}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class OngoingStatusContentTest { + + private static final String SEP = " · "; + 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 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 title_isStableHeadline_notAppNameNorRate() { + final Notification built = NotificationService.buildOngoingNotification(context, discharging85(), rate(false, 9)); + assertEquals("85% · Discharging", String.valueOf(built.extras.getCharSequence(Notification.EXTRA_TITLE))); + } + + @Test + 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, OngoingStatusContent.statusDetail(context, discharging85(), rateFull(false, 9, -250, -338))); + } + + @Test + 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, OngoingStatusContent.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, + OngoingStatusContent.statusDetail(context, discharging85(), rate(false, 9))); + } + + @Test + 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, OngoingStatusContent.statusDetailExpanded(context, discharging85(), rateFull(false, 9, -250, -338))); + } + + @Test + 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, OngoingStatusContent.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, OngoingStatusContent.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(OngoingStatusContent.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(); + // Collapsed: bare temperature. Expanded: a single "Temperature: …" line → no BigText. + assertEquals(temp(), OngoingStatusContent.statusDetail(context, discharging85(), rateFull(false, 9, -250, -338))); + assertEquals(line(R.string.temperature, temp()), + OngoingStatusContent.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", OngoingStatusContent.statusTitle(context, null)); + assertEquals("", OngoingStatusContent.statusDetail(context, null, null)); + assertEquals("", OngoingStatusContent.statusDetailExpanded(context, null, null)); + } +} diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/QuietHoursTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/QuietHoursTest.java new file mode 100644 index 0000000..e483c05 --- /dev/null +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/QuietHoursTest.java @@ -0,0 +1,132 @@ +package com.almothafar.simplebatterynotifier.service; + +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; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for the pure quiet-hours / silent-mode policy in {@link QuietHours}: the time-range check, + * the critical-override gating and the corrupt-bound fallback. + * Times are expressed as minutes since midnight (hour * 60 + minute). + */ +@RunWith(Enclosed.class) +public class QuietHoursTest { + + /** + * {@link QuietHours#isWithinTimeRange(int, int, int)} across daytime, overnight-wrap and + * degenerate windows. The label documents each row's intent. + */ + @RunWith(Parameterized.class) + public static class TimeRange { + + // Daytime window 08:00 (480) – 23:00 (1380) + private static final int DAY_START = 8 * 60; + private static final int DAY_END = 23 * 60; + + // Overnight window 22:00 (1320) – 06:00 (360) + private static final int NIGHT_START = 22 * 60; + private static final int NIGHT_END = 6 * 60; + + @Parameter(0) public String label; + @Parameter(1) public int now; + @Parameter(2) public int start; + @Parameter(3) public int end; + @Parameter(4) public boolean expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"daytime inside", 10 * 60, DAY_START, DAY_END, true}, + {"daytime before start excluded", 6 * 60 + 40, DAY_START, DAY_END, false}, + {"daytime start is inclusive", DAY_START, DAY_START, DAY_END, true}, + {"daytime end is exclusive", DAY_END, DAY_START, DAY_END, false}, + {"overnight late night inside", 22 * 60 + 30, NIGHT_START, NIGHT_END, true}, + {"overnight early morning inside", 2 * 60, NIGHT_START, NIGHT_END, true}, + {"overnight midday outside", 12 * 60, NIGHT_START, NIGHT_END, false}, + // Overnight window whose start/end share the same hour bucket (22:30 -> 22:00). The old + // hour-only logic mishandled this; the minute-based logic must not. + {"same-hour-bucket 23:30 inside", 23 * 60 + 30, 22 * 60 + 30, 22 * 60, true}, + {"same-hour-bucket 22:15 outside", 22 * 60 + 15, 22 * 60 + 30, 22 * 60, false}, + // start == end means the whole day is covered. + {"equal times means whole day (00:00)", 0, 10 * 60, 10 * 60, true}, + {"equal times means whole day (23:59)", 23 * 60 + 59, 10 * 60, 10 * 60, true}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, QuietHours.isWithinTimeRange(now, start, end)); + } + } + + /** + * {@link QuietHours#alertsAllowedNow(boolean, boolean, boolean)} — quiet-hours gating with + * the critical override (issue #111). + */ + @RunWith(Parameterized.class) + public static class AlertsAllowedNow { + + @Parameter(0) public boolean withinWindow; + @Parameter(1) public boolean isCritical; + @Parameter(2) public boolean criticalIgnoresQuietHours; + @Parameter(3) public boolean expected; + + @Parameters(name = "within={0} critical={1} override={2} -> {3}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {true, false, false, true}, // inside window -> always allowed + {true, true, false, true}, // inside window -> allowed even without the override + {false, false, true, false}, // outside, non-critical -> silenced + {false, true, true, true}, // outside, critical breaks through when enabled + {false, true, false, false}, // outside, critical silenced when override off + }); + } + + @Test + public void matchesExpected() { + assertEquals(expected, + QuietHours.alertsAllowedNow(withinWindow, isCritical, criticalIgnoresQuietHours)); + } + } + + /** + * {@link QuietHours#boundOrDefaultMinutes(String, String)} — a corrupt stored quiet-hours bound + * falls back to that bound's default instead of crashing the alert path (issue #154). Runs under + * Robolectric because the fallback is logged. + */ + @RunWith(RobolectricTestRunner.class) + @Config(sdk = 34) + public static class BoundOrDefaultMinutes { + + private static final String DEFAULT_START = "06:30"; + + @Test + public void validStoredBoundWins() { + assertEquals(22 * 60 + 15, QuietHours.boundOrDefaultMinutes("22:15", DEFAULT_START)); + } + + @Test + public void malformedStoredBoundFallsBackToDefault() { + assertEquals(6 * 60 + 30, QuietHours.boundOrDefaultMinutes("ab:cd", DEFAULT_START)); + assertEquals(6 * 60 + 30, QuietHours.boundOrDefaultMinutes(null, DEFAULT_START)); + assertEquals(6 * 60 + 30, QuietHours.boundOrDefaultMinutes("25:99", DEFAULT_START)); + } + + @Test + public void unparseableDefaultFloorsAtMidnightInsteadOfPropagating() { + // Can't happen with the real resource constants; the floor just keeps the impossible + // case inside the valid minutes-of-day range. + assertEquals(0, QuietHours.boundOrDefaultMinutes("bad", "also bad")); + } + } +} diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/util/AppPrefsTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/util/AppPrefsTest.java index 50b5044..8669191 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/util/AppPrefsTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/util/AppPrefsTest.java @@ -25,7 +25,9 @@ import java.util.Collection; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; /** * Tests for the {@link AppPrefs} facade (#162). The context-backed cases run under Robolectric (typed @@ -99,6 +101,18 @@ public void drainLimitPph_defaultsWhenUnsetAndClampsStoredValue() { assertEquals(AppPrefs.MAX_DRAIN_LIMIT_PPH, AppPrefs.drainLimitPph(context)); } + @Test + public void vibrateEnabled_defaultsTrueAndReadsBack() { + // Defaults on (matches the switch's android:defaultValue in pref_behaviour.xml). + assertTrue(AppPrefs.DEFAULT_VIBRATE); + assertTrue(AppPrefs.vibrateEnabled(context)); + + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(context.getString(R.string._pref_key_notifications_vibrate), false) + .apply(); + assertFalse(AppPrefs.vibrateEnabled(context)); + } + /** * The drift guard for the one restatement the facade can't own: the range slider's XML-declared * defaults in {@code pref_alerts.xml} must equal the {@link AppPrefs} constants, since the