Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.almothafar.simplebatterynotifier.model;

/**
* Coarse charging-speed tier, derived from the estimated charging power.
* <p>
* 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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.
* <p>
* 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 {

Expand All @@ -35,13 +34,24 @@ 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
* <p>
* This static field is thread-safe via synchronized access methods.
*/
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)
* <p>
Expand All @@ -57,8 +67,8 @@ public static synchronized void setCurrentState(final int state) {
/**
* Called when a power connection broadcast is received
* <p>
* 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
Expand All @@ -81,81 +91,95 @@ 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);
}
}

/**
* Handle charger connected event
* Handle charger connected event.
* <p>
* 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
* <p>
* 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);

Log.i(TAG, "Charger disconnected");
}

/**
* Detect charger type based on plugged state
* <p>
* 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;
}
}
}
Loading