From 201cb36bdf9fab76aac087987e3384773d714da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 11:38:55 +0000 Subject: [PATCH] Redesign charger notification: charging speed, wired/wireless, styles (#122) Replace the misleading "AC charger connected" message with what's actually useful when plugging in: the estimated charging speed (tier + wattage) and whether charging is wired or wireless. - Add ChargeSpeed / ChargeSpeedTier with pure, unit-tested helpers that estimate charging power from CURRENT_NOW x voltage and bucket it into device-agnostic tiers (trickle -> super fast+), degrading to "unknown" when the device doesn't report instantaneous current. - Drop the unreliable AC/USB split (EXTRA_PLUGGED reports AC for many power banks and fast chargers); keep the reliable wired/wireless distinction. - Sample the current a short delay after connect (it reads 0/noisy at plug-in) rather than synchronously in the receiver. - Add a "Charge notification style" preference: Toast (default), Notification, or None, so plugging in stays low-clutter. - Localize all new strings (en + ar); remove the now-unused "charger" string. Tests: pure unit tests for the power->tier estimation and the style resolver; receiver tests updated for the deferred wired/wireless dispatch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012LC6Qt7Nr8MpJHXuszizNF --- README.md | 4 + .../model/ChargeSpeed.java | 138 ++++++++++++++++ .../model/ChargeSpeedTier.java | 18 +++ .../receiver/PowerConnectionReceiver.java | 124 +++++++++------ .../service/NotificationService.java | 150 ++++++++++++++++-- .../service/SystemService.java | 29 ++++ app/src/main/res/values-ar/strings.xml | 19 ++- app/src/main/res/values/arrays.xml | 14 ++ app/src/main/res/values/strings.xml | 26 ++- app/src/main/res/xml/pref_notification.xml | 9 ++ .../model/ChargeSpeedTest.java | 140 ++++++++++++++++ .../receiver/PowerConnectionReceiverTest.java | 47 ++++-- .../service/NotificationServiceTest.java | 29 ++++ 13 files changed, 676 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeed.java create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeedTier.java create mode 100644 app/src/test/java/com/almothafar/simplebatterynotifier/model/ChargeSpeedTest.java diff --git a/README.md b/README.md index bde3a60..7a3e94f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ No surprises. No clutter. Just simple battery notifications. - 📡 **Full Charge Notification** - Helpful if you charge your phone in **Airplane Mode** for faster charging — you’ll get reminded not to forget it there +- ⚡ **Charging Speed & Type** + - When you plug in, see the estimated **charging speed** (tier + wattage, e.g. *Fast charging · ~18 W*) and whether it’s **wired or wireless** + - Pick how it’s announced: a low-clutter **Toast** (default), a full **Notification**, or **None** + - 📊 **Battery Insights** - Estimated **battery health %**, **charge cycles**, temperature, voltage, and more - Enter your battery’s **design capacity** for a measured health estimate diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeed.java b/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeed.java new file mode 100644 index 0000000..9579341 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeed.java @@ -0,0 +1,138 @@ +package com.almothafar.simplebatterynotifier.model; + +/** + * An estimate of how fast the battery is charging, derived from the instantaneous charging current + * and battery voltage. + *

+ * Android exposes no public API for a device's "charging speed" label, but it does expose the + * instantaneous current ({@link android.os.BatteryManager#BATTERY_PROPERTY_CURRENT_NOW}, µA) and + * voltage ({@link android.os.BatteryManager#EXTRA_VOLTAGE}, mV). Their product is the charging + * power, which we bucket into a device-agnostic {@link ChargeSpeedTier}. This is an estimate, not a + * vendor-reported figure, and it degrades gracefully to {@link ChargeSpeedTier#UNKNOWN} when the + * inputs are unusable. + *

+ * The math lives in pure, Android-free static helpers so it can be unit-tested without a device. + */ +public final class ChargeSpeed { + + /** Sentinel power value meaning "could not be estimated". */ + public static final int UNKNOWN_POWER_MW = -1; + + // Tier thresholds in milliwatts. A reading below a threshold falls into that tier. + static final int TRICKLE_MAX_MW = 5_000; // < 5 W — trickle / very slow + static final int NORMAL_MAX_MW = 10_000; // < 10 W — normal + static final int FAST_MAX_MW = 25_000; // < 25 W — fast + static final int SUPER_FAST_MAX_MW = 45_000; // < 45 W — super fast; at or above -> super fast+ + + // Above this the reading is implausible for a phone (e.g. a device reporting current in the wrong + // unit, or garbage), so we treat it as unknown rather than showing an absurd wattage. + static final int MAX_PLAUSIBLE_MW = 200_000; // 200 W + + private final int milliwatts; + private final ChargeSpeedTier tier; + + private ChargeSpeed(final int milliwatts, final ChargeSpeedTier tier) { + this.milliwatts = milliwatts; + this.tier = tier; + } + + /** + * Build a {@link ChargeSpeed} from raw current and voltage readings. + * + * @param currentMicroAmps instantaneous current in µA (as reported by {@code CURRENT_NOW}; may be + * negative on devices that use a discharge-positive sign convention, or + * {@link Integer#MIN_VALUE} when unsupported) + * @param voltageMilliVolts battery voltage in mV (as reported by {@code EXTRA_VOLTAGE}) + * + * @return a {@link ChargeSpeed}; {@link #isKnown()} is false when the power can't be estimated + */ + public static ChargeSpeed fromMeasurements(final int currentMicroAmps, final int voltageMilliVolts) { + final int mw = powerMilliwatts(currentMicroAmps, voltageMilliVolts); + return new ChargeSpeed(mw, classify(mw)); + } + + /** A {@link ChargeSpeed} with no usable estimate. */ + public static ChargeSpeed unknown() { + return new ChargeSpeed(UNKNOWN_POWER_MW, ChargeSpeedTier.UNKNOWN); + } + + /** + * Estimate charging power (milliwatts) from current and voltage. Pure and Android-free. + *

+ * Handles the awkward real-world inputs: unsupported readings ({@link Integer#MIN_VALUE}) and the + * discharge-positive sign convention (the magnitude is what matters while charging). Rejects a + * zero/blank current and any implausibly large result as {@link #UNKNOWN_POWER_MW}. + * + * @param currentMicroAmps instantaneous current in µA + * @param voltageMilliVolts battery voltage in mV + * + * @return estimated power in mW, or {@link #UNKNOWN_POWER_MW} when it can't be determined + */ + static int powerMilliwatts(final int currentMicroAmps, final int voltageMilliVolts) { + if (voltageMilliVolts <= 0 || currentMicroAmps == 0 || currentMicroAmps == Integer.MIN_VALUE) { + return UNKNOWN_POWER_MW; + } + // Sign convention varies by device; while charging the magnitude is what we want. + final long currentMagnitudeUa = Math.abs((long) currentMicroAmps); + // mW = µA × mV / 1e6. Use long arithmetic: µA×mV can exceed the int range (e.g. 10 A × 5 V). + final long milliwatts = currentMagnitudeUa * voltageMilliVolts / 1_000_000L; + if (milliwatts <= 0 || milliwatts > MAX_PLAUSIBLE_MW) { + return UNKNOWN_POWER_MW; + } + return (int) milliwatts; + } + + /** + * Bucket an estimated power (milliwatts) into a {@link ChargeSpeedTier}. Pure and Android-free. + * + * @param milliwatts estimated power in mW, or {@link #UNKNOWN_POWER_MW} + * + * @return the matching tier, or {@link ChargeSpeedTier#UNKNOWN} for an unknown/negative input + */ + static ChargeSpeedTier classify(final int milliwatts) { + if (milliwatts < 0) { + return ChargeSpeedTier.UNKNOWN; + } + if (milliwatts < TRICKLE_MAX_MW) { + return ChargeSpeedTier.TRICKLE; + } + if (milliwatts < NORMAL_MAX_MW) { + return ChargeSpeedTier.NORMAL; + } + if (milliwatts < FAST_MAX_MW) { + return ChargeSpeedTier.FAST; + } + if (milliwatts < SUPER_FAST_MAX_MW) { + return ChargeSpeedTier.SUPER_FAST; + } + return ChargeSpeedTier.SUPER_FAST_PLUS; + } + + /** + * @return true when a power/tier could be estimated (i.e. not {@link ChargeSpeedTier#UNKNOWN}) + */ + public boolean isKnown() { + return tier != ChargeSpeedTier.UNKNOWN; + } + + /** + * @return estimated power rounded to the nearest watt, or 0 when unknown + */ + public int getWatts() { + return milliwatts <= 0 ? 0 : Math.round(milliwatts / 1000f); + } + + /** + * @return estimated power in milliwatts, or {@link #UNKNOWN_POWER_MW} when unknown + */ + public int getMilliwatts() { + return milliwatts; + } + + /** + * @return the charging-speed tier (never null; {@link ChargeSpeedTier#UNKNOWN} when unestimated) + */ + public ChargeSpeedTier getTier() { + return tier; + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeedTier.java b/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeedTier.java new file mode 100644 index 0000000..8247b5f --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/model/ChargeSpeedTier.java @@ -0,0 +1,18 @@ +package com.almothafar.simplebatterynotifier.model; + +/** + * Coarse charging-speed tier, derived from the estimated charging power. + *

+ * Android exposes no vendor "fast charging" label ("Super Fast Charging" and friends are marketing + * names, not APIs), so we bucket the estimated wattage into device-agnostic tiers. {@link #UNKNOWN} + * is used whenever the power can't be estimated (e.g. the device doesn't report instantaneous + * current), so callers can fall back to a plain "Charging" message. + */ +public enum ChargeSpeedTier { + UNKNOWN, + TRICKLE, + NORMAL, + FAST, + SUPER_FAST, + SUPER_FAST_PLUS +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiver.java b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiver.java index c354bc9..0d57022 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiver.java @@ -4,27 +4,26 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.res.Resources; import android.os.BatteryManager; +import android.os.Handler; +import android.os.Looper; import android.util.Log; -import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.ChargeSpeed; import com.almothafar.simplebatterynotifier.service.NotificationService; - -import static java.util.Objects.isNull; +import com.almothafar.simplebatterynotifier.service.SystemService; /** - * Broadcast receiver for power connection/disconnection events + * Broadcast receiver for power connection/disconnection events. *

- * This receiver monitors when the device is plugged in or unplugged from a charger - * and triggers appropriate notifications. It detects the charger type (AC, USB, Wireless) - * and determines whether the charging session qualifies as "healthy" based on battery level. + * When the device is plugged in, this reports what's actually useful — the estimated charging speed + * and whether it's wired or wireless — rather than the old, often-misleading "AC charger connected" + * message (issue #122). The AC/USB distinction was dropped because {@code EXTRA_PLUGGED} reports + * {@code BATTERY_PLUGGED_AC} for many power banks and fast chargers, so it couldn't be trusted; the + * wired/wireless split, by contrast, is reliable. *

- * User Feedback: This receiver relies on system notifications for user feedback rather than - * Toast messages. Notifications are preferred because they: - * - Are accessible to screen readers (TalkBack) - * - Persist and can be reviewed later - * - Support actions and rich content - * - Follow Material Design guidelines + * Charging current reads 0 or noisy for a moment right at plug-in, so the speed is sampled a short + * delay after connection (see {@link #CHARGE_SAMPLE_DELAY_MS}) rather than synchronously here. The + * foreground {@code PowerConnectionService} keeps the process alive across that delay. */ public class PowerConnectionReceiver extends BroadcastReceiver { @@ -35,6 +34,12 @@ public class PowerConnectionReceiver extends BroadcastReceiver { */ private static final int HEALTHY_CHARGE_THRESHOLD = 20; + /** + * Delay before sampling the charging current, giving it time to stabilise after plug-in. + * Package-visible so tests can advance the main looper by exactly this amount. + */ + static final long CHARGE_SAMPLE_DELAY_MS = 2000L; + /** * Previous plugged state to prevent duplicate notifications for the same state *

@@ -42,6 +47,11 @@ public class PowerConnectionReceiver extends BroadcastReceiver { */ private static int currentState = -1; + // Main-thread handler used to sample the charging speed a short delay after connection. Static so a + // stale pending sample can be cancelled if the charger is unplugged (or re-plugged) during the delay. + private static final Handler sampleHandler = new Handler(Looper.getMainLooper()); + private static Runnable pendingSample; + /** * Update the current plugged state (synchronized for thread safety) *

@@ -57,8 +67,8 @@ public static synchronized void setCurrentState(final int state) { /** * Called when a power connection broadcast is received *

- * This method determines the current battery state, detects charger type, - * and triggers appropriate notifications for the user. + * This method determines the current battery state, detects whether charging is wired or + * wireless, and schedules the charge-connected notification for the user. * * @param context The context in which the receiver is running * @param intent The intent being received @@ -81,11 +91,9 @@ public void onReceive(final Context context, final Intent intent) { final int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); final int percentage = (int) ((level / (float) scale) * 100); - final Resources resources = context.getResources(); - if (pluggedState > 0) { // Charger connected - handleChargerConnected(context, pluggedState, percentage, resources); + handleChargerConnected(context, pluggedState, percentage); } else { // Charger disconnected handleChargerDisconnected(context); @@ -93,39 +101,46 @@ public void onReceive(final Context context, final Intent intent) { } /** - * Handle charger connected event + * Handle charger connected event. *

- * Detects charger type, determines if charging is "healthy" (started at low battery), - * and sends appropriate notification to the user. + * Records whether this is a "healthy" charge (started at low battery), determines wired vs + * wireless, and schedules the speed sample + notification for a short delay later. * * @param context The application context * @param pluggedState The type of charger plugged in * @param percentage Current battery percentage - * @param resources Resources for string lookup */ - private void handleChargerConnected(final Context context, final int pluggedState, - final int percentage, final Resources resources) { - final ChargerInfo chargerInfo = detectChargerType(pluggedState, resources); - + private void handleChargerConnected(final Context context, final int pluggedState, final int percentage) { // Determine if this is a "healthy" charge (starting at low battery level) final boolean isHealthyCharge = percentage <= HEALTHY_CHARGE_THRESHOLD; NotificationService.setIsHealthy(isHealthyCharge); - // Send notification to user - NotificationService.sendChargeNotification(context, chargerInfo.source); - - Log.i(TAG, String.format("Charger connected: %s (Battery: %d%%, Healthy: %s)", - chargerInfo.source, percentage, isHealthyCharge)); + final boolean wireless = pluggedState == BatteryManager.BATTERY_PLUGGED_WIRELESS; + final Context appContext = context.getApplicationContext(); + + // Sample the charging speed after a short delay (the current is 0/noisy right at plug-in), then + // notify. Re-check that we're still plugged in, in case the charger was pulled during the delay. + scheduleSample(() -> { + if (!isStillPlugged(appContext)) { + return; + } + final ChargeSpeed speed = SystemService.getChargeSpeed(appContext); + NotificationService.notifyChargeConnected(appContext, speed, wireless, isHealthyCharge); + }); + + Log.i(TAG, String.format("Charger connected (Battery: %d%%, Wireless: %s, Healthy: %s)", + percentage, wireless, isHealthyCharge)); } /** * Handle charger disconnected event *

- * Resets battery monitoring state and clears any active battery notifications. + * Cancels any pending speed sample, resets battery monitoring state and clears active notifications. * * @param context The application context */ private void handleChargerDisconnected(final Context context) { + cancelPendingSample(); BatteryLevelReceiver.resetVariables(); NotificationService.clearNotifications(context); @@ -133,29 +148,38 @@ private void handleChargerDisconnected(final Context context) { } /** - * Detect charger type based on plugged state - *

- * Supports AC, USB, and Wireless charging. Falls back to generic "Charger" - * for unknown types (e.g., future Android versions may add new charger types). + * Whether a charger is still connected. Used to abort a pending speed sample if the charger was + * unplugged during the sampling delay. + * + * @param context The application context * - * @param pluggedState The EXTRA_PLUGGED value from battery status intent - * @param resources Resources for string lookup - * @return ChargerInfo containing messages and source type + * @return true when still plugged into a power source */ - private ChargerInfo detectChargerType(final int pluggedState, final Resources resources) { - return switch (pluggedState) { - case BatteryManager.BATTERY_PLUGGED_USB -> new ChargerInfo(resources.getString(R.string.charger_usb)); - case BatteryManager.BATTERY_PLUGGED_AC -> new ChargerInfo(resources.getString(R.string.charger_ac)); - case BatteryManager.BATTERY_PLUGGED_WIRELESS -> new ChargerInfo(resources.getString(R.string.charger_wireless)); - default -> new ChargerInfo(resources.getString(R.string.charger)); - }; + private static boolean isStillPlugged(final Context context) { + final Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + final int plugged = batteryStatus == null ? 0 : batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0); + return plugged > 0; } /** - * Simple data class to hold charger information + * Schedule the delayed charge sample, cancelling any previously scheduled one so a quick + * unplug/replug doesn't fire twice. * - * @param source Short description of charger type + * @param sample The sampling task to run after {@link #CHARGE_SAMPLE_DELAY_MS} + */ + private static synchronized void scheduleSample(final Runnable sample) { + cancelPendingSample(); + pendingSample = sample; + sampleHandler.postDelayed(sample, CHARGE_SAMPLE_DELAY_MS); + } + + /** + * Cancel any pending delayed charge sample. */ - private record ChargerInfo(String source) { + static synchronized void cancelPendingSample() { + if (pendingSample != null) { + sampleHandler.removeCallbacks(pendingSample); + pendingSample = null; + } } } 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 7286b42..89e36ff 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/NotificationService.java @@ -16,12 +16,17 @@ 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.widget.Toast; import androidx.core.content.ContextCompat; 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.model.ChargeSpeedTier; import com.almothafar.simplebatterynotifier.ui.MainActivity; import com.almothafar.simplebatterynotifier.util.GeneralHelper; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; @@ -79,6 +84,12 @@ public final class NotificationService { 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_notification.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 *

@@ -138,12 +149,136 @@ public static void sendNotification(final Context context, final int type) { } /** - * Send a charge-started notification + * Announce that charging has started, honoring the user's chosen style. + *

+ * Instead of the old, often-misleading "AC charger connected" message, this reports what's + * actually useful: the estimated charging speed (tier + wattage) and whether it's wired or + * wireless (issue #122). The user picks how it's surfaced via the "Charge notification style" + * preference: + *

* - * @param context The application context - * @param chargeSource The charging source description + * @param context The application context + * @param speed The estimated charging speed (may be {@link ChargeSpeed#unknown()}) + * @param wireless true when charging over a wireless charger, false when wired + * @param isHealthy true when charging started at a low battery level ("healthy" charge) + */ + public static void notifyChargeConnected(final Context context, final ChargeSpeed speed, + final boolean wireless, final boolean isHealthy) { + 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. + } + + final String content = chargeConnectedMessage(context, speed, wireless); + + if (CHARGE_STYLE_TOAST.equals(style)) { + showChargeToast(context, content); + return; + } + + postChargeNotification(context, content, isHealthy); + } + + /** + * 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). + * + * @param context The application context + * @param content The charge message to display + * @param isHealthy true when charging started at a low battery level ("healthy" charge) */ - public static void sendChargeNotification(final Context context, final String chargeSource) { + private static void postChargeNotification(final Context context, final String content, final boolean isHealthy) { if (lacksNotificationPermission(context)) { Log.w(TAG, "Missing POST_NOTIFICATIONS permission, notification not sent"); return; @@ -151,19 +286,16 @@ public static void sendChargeNotification(final Context context, final String ch createNotificationChannels(context); - final String title = isHealthyCharge + final String title = isHealthy ? context.getString(R.string.notification_charge_started_title_healthy) : context.getString(R.string.notification_charge_started_title_regular); - final String content = context.getString(R.string.notification_charge_started_content, chargeSource); final String ticker = title.concat(", ").concat(content); - final int iconRes = isHealthyCharge + final int iconRes = isHealthy ? R.drawable.ic_stat_device_battery_charging_20 : R.drawable.ic_stat_device_battery_charging_50; - // 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). final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final boolean withinWindow = isWithinNotificationWindow(context, prefs); 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 59514b1..49229a7 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java @@ -19,6 +19,7 @@ import com.almothafar.simplebatterynotifier.R; import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.model.BatteryHealthStatus; +import com.almothafar.simplebatterynotifier.model.ChargeSpeed; import java.io.BufferedReader; import java.io.File; @@ -283,6 +284,34 @@ public static int getBatteryCapacity(final Context context) { return estimateFullCapacityMah(chargeCounterUah, capacityPercent); } + /** + * Estimate the current charging speed from the instantaneous current and battery voltage. + *

+ * Reads {@link BatteryManager#BATTERY_PROPERTY_CURRENT_NOW} (µA) and the battery voltage + * ({@link BatteryManager#EXTRA_VOLTAGE}, mV) and combines them into a {@link ChargeSpeed}. Best + * sampled a moment after connection, once the current has stabilised — right at plug-in the + * current reads 0 or noisy. Returns {@link ChargeSpeed#unknown()} when the device doesn't report + * instantaneous current, so callers fall back to a plain "Charging" message. + * + * @param context The application context + * + * @return the estimated {@link ChargeSpeed}; never null + */ + public static ChargeSpeed getChargeSpeed(final Context context) { + final BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE); + if (isNull(batteryManager)) { + Log.w(TAG, "BatteryManager service unavailable"); + return ChargeSpeed.unknown(); + } + + final int currentMicroAmps = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + + final Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + final int voltageMilliVolts = isNull(batteryStatus) ? 0 : batteryStatus.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0); + + return ChargeSpeed.fromMeasurements(currentMicroAmps, voltageMilliVolts); + } + /** * Estimate the full battery capacity (mAh) from the remaining charge and remaining percentage. *

diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fe4344f..4d4c71e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -35,7 +35,6 @@ مقبس طاقة مدخل يو اس بي شاحن لاسلكي - شاحن عام التنبيهات والأصوات @@ -109,6 +108,24 @@ شحن اعتيادي بدأ البطارية تشحن بواسطة %1$s + + سلكي + لاسلكي + شحن بطيء + شحن عادي + شحن سريع + شحن فائق السرعة + شحن فائق السرعة+ + %1$s · %2$s · ~%3$d واط + %1$s · %2$s + شحن %1$s + + + أسلوب تنبيه الشحن + رسالة منبثقة + إشعار + بدون + البطارية عند %1$d بالمئة : diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a4ee6d7..0139c02 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -15,4 +15,18 @@ en + + + @string/charge_notification_style_toast + @string/charge_notification_style_notification + @string/charge_notification_style_none + + + toast + notification + none + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c545710..bfd0774 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,6 @@ AC Charger USB Port Wireless Charger - Charger General Notifications & Sounds @@ -112,6 +111,29 @@ Regular charging started Battery charging with %1$s + + Wired + Wireless + Slow charging + Normal charging + Fast charging + Super fast charging + Super fast charging+ + + %1$s · %2$s · ~%3$d W + + %1$s · %2$s + + %1$s charging + + + Charge notification style + Toast + Notification + None + Battery Status Ongoing battery status while monitoring is active @@ -226,6 +248,8 @@ key_critical_battery_level key_battery_level_range key_notifications_sticky + key_charge_notification_style + toast key_temperatures_unit key_notifications_vibrate key_notifications_alert_sound_ringtone diff --git a/app/src/main/res/xml/pref_notification.xml b/app/src/main/res/xml/pref_notification.xml index 0ae694c..a2b5670 100644 --- a/app/src/main/res/xml/pref_notification.xml +++ b/app/src/main/res/xml/pref_notification.xml @@ -5,6 +5,15 @@ android:title="@string/pref_cat_title_notification" app:iconSpaceReserved="false"> + + data() { + return Arrays.asList(new Object[][]{ + // Typical charging readings: mW = µA × mV / 1e6. + {"2 A @ 5 V = 10 W", 2_000_000, 5_000, 10_000}, + {"1.5 A @ 5 V = 7.5 W", 1_500_000, 5_000, 7_500}, + {"3 A @ 9 V = 27 W", 3_000_000, 9_000, 27_000}, + // Discharge-positive devices report a negative current while charging; magnitude is used. + {"negative sign convention", -3_000_000, 5_000, 15_000}, + // Unusable inputs -> unknown (-1). getIntProperty returns Integer.MIN_VALUE when unsupported. + {"unsupported current", Integer.MIN_VALUE, 5_000, ChargeSpeed.UNKNOWN_POWER_MW}, + {"zero current", 0, 5_000, ChargeSpeed.UNKNOWN_POWER_MW}, + {"zero voltage", 2_000_000, 0, ChargeSpeed.UNKNOWN_POWER_MW}, + {"negative voltage", 2_000_000, -1, ChargeSpeed.UNKNOWN_POWER_MW}, + // Implausibly large (e.g. wrong unit / garbage) -> unknown. + {"implausibly high", 50_000_000, 5_000, ChargeSpeed.UNKNOWN_POWER_MW}, + // Boundary around the plausibility cap (200 W): equal is kept, just above is rejected. + {"at plausibility cap", 40_000_000, 5_000, 200_000}, + {"just over plausibility cap", 41_000_000, 5_000, ChargeSpeed.UNKNOWN_POWER_MW}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expectedMilliwatts, + ChargeSpeed.powerMilliwatts(currentMicroAmps, voltageMilliVolts)); + } + } + + /** + * {@link ChargeSpeed#classify(int)} — buckets an estimated power (mW) into a + * {@link ChargeSpeedTier}, with the thresholds applied inclusively at the lower bound. + */ + @RunWith(Parameterized.class) + public static class Classify { + + @Parameter(0) public String label; + @Parameter(1) public int milliwatts; + @Parameter(2) public ChargeSpeedTier expectedTier; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"unknown (-1)", ChargeSpeed.UNKNOWN_POWER_MW, ChargeSpeedTier.UNKNOWN}, + {"zero -> trickle", 0, ChargeSpeedTier.TRICKLE}, + {"just below trickle max", ChargeSpeed.TRICKLE_MAX_MW - 1, ChargeSpeedTier.TRICKLE}, + {"trickle max -> normal", ChargeSpeed.TRICKLE_MAX_MW, ChargeSpeedTier.NORMAL}, + {"just below normal max", ChargeSpeed.NORMAL_MAX_MW - 1, ChargeSpeedTier.NORMAL}, + {"normal max -> fast", ChargeSpeed.NORMAL_MAX_MW, ChargeSpeedTier.FAST}, + {"just below fast max", ChargeSpeed.FAST_MAX_MW - 1, ChargeSpeedTier.FAST}, + {"fast max -> super fast", ChargeSpeed.FAST_MAX_MW, ChargeSpeedTier.SUPER_FAST}, + {"just below super-fast max", ChargeSpeed.SUPER_FAST_MAX_MW - 1, ChargeSpeedTier.SUPER_FAST}, + {"super-fast max -> super fast+", ChargeSpeed.SUPER_FAST_MAX_MW, ChargeSpeedTier.SUPER_FAST_PLUS}, + {"very high -> super fast+", 100_000, ChargeSpeedTier.SUPER_FAST_PLUS}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expectedTier, ChargeSpeed.classify(milliwatts)); + } + } + + /** + * {@link ChargeSpeed#fromMeasurements(int, int)} and the display accessors ({@link + * ChargeSpeed#isKnown()}, {@link ChargeSpeed#getWatts()}, {@link ChargeSpeed#getTier()}). + */ + public static class FromMeasurements { + + @Test + public void typicalReading_isKnownWithTierAndRoundedWatts() { + final ChargeSpeed speed = ChargeSpeed.fromMeasurements(2_000_000, 5_000); // 10 W + assertTrue(speed.isKnown()); + assertEquals(ChargeSpeedTier.FAST, speed.getTier()); + assertEquals(10, speed.getWatts()); + assertEquals(10_000, speed.getMilliwatts()); + } + + @Test + public void unsupportedReading_isUnknown() { + final ChargeSpeed speed = ChargeSpeed.fromMeasurements(Integer.MIN_VALUE, 5_000); + assertFalse(speed.isKnown()); + assertEquals(ChargeSpeedTier.UNKNOWN, speed.getTier()); + assertEquals(0, speed.getWatts()); + assertEquals(ChargeSpeed.UNKNOWN_POWER_MW, speed.getMilliwatts()); + } + + @Test + public void subWattTrickle_isKnownButRoundsToZeroWatts() { + final ChargeSpeed speed = ChargeSpeed.fromMeasurements(100_000, 4_000); // 0.4 W + assertTrue(speed.isKnown()); + assertEquals(ChargeSpeedTier.TRICKLE, speed.getTier()); + assertEquals(0, speed.getWatts()); + } + + @Test + public void unknownFactory_isUnknown() { + final ChargeSpeed speed = ChargeSpeed.unknown(); + assertFalse(speed.isKnown()); + assertEquals(ChargeSpeedTier.UNKNOWN, speed.getTier()); + } + } +} diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiverTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiverTest.java index ff2695b..63f1bd7 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiverTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/receiver/PowerConnectionReceiverTest.java @@ -3,10 +3,11 @@ import android.content.Context; import android.content.Intent; import android.os.BatteryManager; +import android.os.Looper; import androidx.test.core.app.ApplicationProvider; -import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.ChargeSpeed; import com.almothafar.simplebatterynotifier.service.NotificationService; import org.junit.Before; @@ -16,16 +17,24 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import java.time.Duration; + import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.robolectric.Shadows.shadowOf; /** - * Robolectric + Mockito tests for {@link PowerConnectionReceiver}: charger-type detection, the + * Robolectric + Mockito tests for {@link PowerConnectionReceiver}: wired/wireless detection, the * healthy-charge flag, plugged-state de-duplication, and the disconnect cleanup path. The * {@link NotificationService} static methods are mocked so we can assert what the receiver decides * to do from the sticky {@code ACTION_BATTERY_CHANGED} intent. + *

+ * The charge-connected notification is dispatched a short delay after connection (the charging + * current is noisy right at plug-in), so tests advance the main looper by + * {@link PowerConnectionReceiver#CHARGE_SAMPLE_DELAY_MS} to let the deferred sample run. */ @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) @@ -38,28 +47,32 @@ public void setUp() { context = ApplicationProvider.getApplicationContext(); // Reset the static plugged-state so each test starts from "unknown" PowerConnectionReceiver.setCurrentState(-1); + // Drop any sample left pending by a previous test so it can't fire mid-test. + PowerConnectionReceiver.cancelPendingSample(); } @Test - public void connectedAc_sendsChargeNotificationWithAcSource() { + public void connectedWired_notifiesWiredCharging() { publishBattery(BatteryManager.BATTERY_PLUGGED_AC, 50, 100); try (MockedStatic ns = mockStatic(NotificationService.class)) { receive(); - ns.verify(() -> NotificationService.sendChargeNotification(any(Context.class), - eq(context.getString(R.string.charger_ac)))); + runPendingSample(); + ns.verify(() -> NotificationService.notifyChargeConnected(any(Context.class), + any(ChargeSpeed.class), eq(false), eq(false))); ns.verify(() -> NotificationService.setIsHealthy(false)); } } @Test - public void connectedWireless_sendsChargeNotificationWithWirelessSource() { + public void connectedWireless_notifiesWirelessCharging() { publishBattery(BatteryManager.BATTERY_PLUGGED_WIRELESS, 60, 100); try (MockedStatic ns = mockStatic(NotificationService.class)) { receive(); - ns.verify(() -> NotificationService.sendChargeNotification(any(Context.class), - eq(context.getString(R.string.charger_wireless)))); + runPendingSample(); + ns.verify(() -> NotificationService.notifyChargeConnected(any(Context.class), + any(ChargeSpeed.class), eq(true), eq(false))); } } @@ -69,7 +82,10 @@ public void connectedAtLowBattery_marksChargeHealthy() { try (MockedStatic ns = mockStatic(NotificationService.class)) { receive(); + runPendingSample(); ns.verify(() -> NotificationService.setIsHealthy(true)); + ns.verify(() -> NotificationService.notifyChargeConnected(any(Context.class), + any(ChargeSpeed.class), eq(false), eq(true))); } } @@ -80,7 +96,9 @@ public void samePluggedState_sendsNoNotification() { try (MockedStatic ns = mockStatic(NotificationService.class)) { receive(); - ns.verify(() -> NotificationService.sendChargeNotification(any(Context.class), any(String.class)), never()); + runPendingSample(); + ns.verify(() -> NotificationService.notifyChargeConnected(any(Context.class), + any(ChargeSpeed.class), anyBoolean(), anyBoolean()), never()); } } @@ -91,8 +109,10 @@ public void disconnected_clearsNotifications() { try (MockedStatic ns = mockStatic(NotificationService.class)) { receive(); + runPendingSample(); ns.verify(() -> NotificationService.clearNotifications(any(Context.class))); - ns.verify(() -> NotificationService.sendChargeNotification(any(Context.class), any(String.class)), never()); + ns.verify(() -> NotificationService.notifyChargeConnected(any(Context.class), + any(ChargeSpeed.class), anyBoolean(), anyBoolean()), never()); } } @@ -102,6 +122,13 @@ private void receive() { new PowerConnectionReceiver().onReceive(context, new Intent(Intent.ACTION_POWER_CONNECTED)); } + /** + * Advance the main looper past the sampling delay so the deferred charge-connected task runs. + */ + private void runPendingSample() { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(PowerConnectionReceiver.CHARGE_SAMPLE_DELAY_MS)); + } + @SuppressWarnings("deprecation") private void publishBattery(final int plugged, final int level, final int scale) { final Intent battery = new Intent(Intent.ACTION_BATTERY_CHANGED); 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 181613a..ee6d707 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/NotificationServiceTest.java @@ -95,4 +95,33 @@ public void matchesExpected() { NotificationService.alertsAllowedNow(withinWindow, isCritical, criticalIgnoresQuietHours)); } } + + /** + * {@link NotificationService#resolveChargeStyle(String)} — normalizes the persisted charge-style + * preference, defaulting a null/blank/unrecognized value to Toast (issue #122). + */ + @RunWith(Parameterized.class) + public static class ResolveChargeStyle { + + @Parameter(0) public String label; + @Parameter(1) public String stored; + @Parameter(2) public String expected; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"toast kept", NotificationService.CHARGE_STYLE_TOAST, NotificationService.CHARGE_STYLE_TOAST}, + {"notification kept", NotificationService.CHARGE_STYLE_NOTIFICATION, NotificationService.CHARGE_STYLE_NOTIFICATION}, + {"none kept", NotificationService.CHARGE_STYLE_NONE, NotificationService.CHARGE_STYLE_NONE}, + {"null defaults to toast", null, NotificationService.CHARGE_STYLE_TOAST}, + {"blank defaults to toast", "", NotificationService.CHARGE_STYLE_TOAST}, + {"unknown defaults to toast", "bogus", NotificationService.CHARGE_STYLE_TOAST}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expected, NotificationService.resolveChargeStyle(stored)); + } + } }