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: + *
+ * 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 @@