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 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
- * 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).
- *
- * 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
- * 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
+ * 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.
+ * 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".
- *
+ *
+ * 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};
*