diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..99a731c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,30 @@ +# Simple Battery Notifier + +An Android app that monitors the device battery and raises notifications for low/critical/full +levels, high temperature, and battery health. This glossary pins down the terms the app's UI and +code use so they don't drift. + +## Language + +**Drain rate**: +How fast the battery level is falling while discharging, expressed in percentage-points per hour +(%/h). The normalized, device-size-independent headline metric for consumption. +_Avoid_: consumption, usage, speed. + +**Charge rate**: +How fast the battery level is rising while charging, in %/h. The charging counterpart of the drain +rate. +_Avoid_: charging speed. + +**Instantaneous current**: +The live current flowing in or out of the battery right now, in milliamps (mA), read from +`BatteryManager.BATTERY_PROPERTY_CURRENT_NOW`. Shown as raw detail; not normalized, so it is not +colored on its own. +_Avoid_: amperage, draw, consumption. + +**Design capacity**: +The battery's rated full capacity when new (mAh), from the manufacturer's spec. User-entered or +best-effort auto-detected from the kernel. Distinct from the measured current full capacity. +_Avoid_: rated capacity (in prose only), max capacity. + + diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java b/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java index c3c210a..2c12f88 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/model/BatteryDO.java @@ -13,6 +13,9 @@ public final class BatteryDO { private int voltage; private boolean present; private int capacity; + // Instantaneous current in µA from BATTERY_PROPERTY_CURRENT_NOW; Integer.MIN_VALUE when the device + // doesn't report it. Sign convention varies by OEM, so callers derive direction from the status. + private int currentMicroAmps = Integer.MIN_VALUE; private String technology; private String powerSource; private String health; @@ -124,6 +127,15 @@ public BatteryDO setCapacity(final int capacity) { return this; } + public int getCurrentMicroAmps() { + return currentMicroAmps; + } + + public BatteryDO setCurrentMicroAmps(final int currentMicroAmps) { + this.currentMicroAmps = currentMicroAmps; + return this; + } + public String getPowerSource() { return powerSource; } 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 185ced1..262d385 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/receiver/BatteryLevelReceiver.java @@ -10,6 +10,7 @@ import com.almothafar.simplebatterynotifier.R; import com.almothafar.simplebatterynotifier.model.BatteryDO; import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker; +import com.almothafar.simplebatterynotifier.service.BatteryRateTracker; import com.almothafar.simplebatterynotifier.service.NotificationService; import com.almothafar.simplebatterynotifier.service.SystemService; import com.almothafar.simplebatterynotifier.util.TemperatureUtils; @@ -64,8 +65,13 @@ public void onReceive(final Context context, final Intent intent) { // Reuse the sticky intent we already read above instead of triggering a second read. final BatteryDO batteryDO = SystemService.getBatteryInfo(context, batteryStatus); - // Keep the persistent foreground-service status notification live with the latest reading - NotificationService.updateOngoingNotification(context, batteryDO); + // Feed the charge/drain rate window from this broadcast (no polling timer of our own) so both the + // ongoing notification below and the details table reflect the latest reading (issue #108). + final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(context, batteryDO); + + // Keep the persistent foreground-service status notification live with the latest reading, + // reusing the rate just computed instead of re-parsing the persisted sample window. + NotificationService.updateOngoingNotification(context, batteryDO, rate); if (batteryDO == null) { // Without a real reading, don't assume a level. Previously this defaulted to 100%, diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java new file mode 100644 index 0000000..9845a94 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -0,0 +1,499 @@ +package com.almothafar.simplebatterynotifier.service; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.BatteryManager; +import androidx.preference.PreferenceManager; + +import com.almothafar.simplebatterynotifier.R; +import com.almothafar.simplebatterynotifier.model.BatteryDO; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Objects.isNull; + +/** + * Computes the live battery charge/drain rate in percentage-points per hour (%/h) and the + * signed instantaneous current, smoothed over a trailing window (issue #108). + *
+ * The rate is deliberately averaged over a stable window, not instantaneous: a wobbling number + * erodes trust, and the value of a drain readout is the sustained rate. There is no polling + * timer — samples piggyback the existing {@code ACTION_BATTERY_CHANGED} broadcasts and the + * MainActivity foreground refresh, both of which already run. The window is persisted in + * {@link SharedPreferences} (like {@link BatteryHealthTracker}) so it survives process restarts. + *
+ * The %/h is derived best-effort and degrades gracefully: + *
+ * The %/h magnitude prefers the averaged current ÷ capacity (source A) and falls back to the
+ * level change over the window (source B). A rate is only reported once the window has enough data
+ * (the post-unplug warm-up shows nothing), and never when it rounds to 0 (a static level) or exceeds
+ * a plausible ceiling (a garbage reading). The current is reported independently, signed by
+ * direction so it reads negative while discharging and positive while charging regardless of the
+ * device's raw sign convention.
+ *
+ * @param window samples oldest-first
+ * @param capacityMah measured full capacity in mAh, or 0 when unknown/untrusted (#69)
+ * @param charging direction (label + colouring): true charging, false discharging
+ * @param nowMillis current time in millis
+ * @param latestCurrentMicroAmps the freshest instantaneous current reading in µA (for the current row)
+ *
+ * @return the computed {@link BatteryRate}
+ */
+ static BatteryRate computeRate(final List
+ * The middle segment appends the charge/drain rate to the status label when available, falling back
+ * to the raw mA, then to the plain label — always showing the best number on hand (issue #108). The
+ * appended rate is gated by a user setting (default on); the plain label always shows.
*
* @param context The application context
* @param batteryDO Current battery snapshot, or null if unavailable
+ * @param rate The precomputed charge/drain rate
* @return Formatted status text
*/
- private static String statusText(final Context context, final BatteryDO batteryDO) {
+ private static String statusText(final Context context, final BatteryDO batteryDO,
+ final BatteryRateTracker.BatteryRate rate) {
final int percentage = isNull(batteryDO) ? 0 : Math.round(batteryDO.getBatteryPercentage());
final String statusLabel = SystemService.getStatusLabel(context, isNull(batteryDO) ? -1 : batteryDO.getStatus());
final String temperature = isNull(batteryDO) ? "" : TemperatureUtils.format(context, batteryDO.getTemperature());
- return context.getString(R.string.notification_status_content, percentage, statusLabel, temperature);
+ return context.getString(R.string.notification_status_content, percentage, statusWithRate(context, batteryDO, statusLabel, rate), temperature);
+ }
+
+ /**
+ * The status segment with the rate (or raw mA) appended, e.g. "Discharging 9%/h" — or the plain
+ * label when no reading is available or the user turned the appended rate off (issue #108).
+ *
+ * @param context The application context
+ * @param batteryDO Current battery snapshot, or null if unavailable
+ * @param statusLabel The plain localized status label
+ * @param rate The precomputed charge/drain rate
+ * @return The status label, optionally with the rate/current appended
+ */
+ private static String statusWithRate(final Context context, final BatteryDO batteryDO,
+ final String statusLabel, final BatteryRateTracker.BatteryRate rate) {
+ if (isNull(batteryDO) || isNull(rate)) {
+ return statusLabel;
+ }
+ final boolean showRate = PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string._pref_key_show_rate_in_notification), true);
+ if (!showRate) {
+ return statusLabel;
+ }
+ if (rate.hasRate()) {
+ return statusLabel + " " + BatteryRateTracker.formatRateValue(context, rate.percentPerHour());
+ }
+ if (rate.hasCurrent()) {
+ return statusLabel + " " + BatteryRateTracker.formatCurrentValue(context, rate.currentMilliAmps());
+ }
+ return statusLabel;
}
/**
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 49229a7..ca896d4 100644
--- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java
+++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/SystemService.java
@@ -80,8 +80,9 @@ public static BatteryDO getBatteryInfo(final Context context, final Intent batte
final BatteryExtras extras = extractBatteryExtras(batteryStatus);
final String chargerType = determineChargerType(extras.plugged, resources);
final int batteryCapacity = getBatteryCapacity(context);
+ final int currentMicroAmps = getInstantaneousCurrentMicroAmps(context);
- return buildBatteryDataObject(extras, chargerType, batteryCapacity, resources);
+ return buildBatteryDataObject(extras, chargerType, batteryCapacity, currentMicroAmps, resources);
}
/**
@@ -188,6 +189,7 @@ private static String determineChargerType(final int plugged, final Resources re
private static BatteryDO buildBatteryDataObject(final BatteryExtras extras,
final String chargerType,
final int batteryCapacity,
+ final int currentMicroAmps,
final Resources resources) {
final BatteryDO batteryDO = new BatteryDO();
batteryDO.setLevel(extras.level)
@@ -200,6 +202,7 @@ private static BatteryDO buildBatteryDataObject(final BatteryExtras extras,
.setTemperature(extras.temperature)
.setVoltage(extras.voltage)
.setCapacity(batteryCapacity)
+ .setCurrentMicroAmps(currentMicroAmps)
.setIntHealth(extras.health);
// Determine health status and set it on the battery object
@@ -287,24 +290,18 @@ public static int getBatteryCapacity(final Context context) {
/**
* 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.
+ * Combines {@link #getInstantaneousCurrentMicroAmps(Context)} with the battery voltage
+ * ({@link BatteryManager#EXTRA_VOLTAGE}, mV) 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 int currentMicroAmps = getInstantaneousCurrentMicroAmps(context);
final Intent batteryStatus = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
final int voltageMilliVolts = isNull(batteryStatus) ? 0 : batteryStatus.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0);
@@ -312,6 +309,27 @@ public static ChargeSpeed getChargeSpeed(final Context context) {
return ChargeSpeed.fromMeasurements(currentMicroAmps, voltageMilliVolts);
}
+ /**
+ * Read the live instantaneous battery current from {@link BatteryManager#BATTERY_PROPERTY_CURRENT_NOW}
+ * (µA), used to derive the charge/drain rate and the signed "Current" row (issue #108).
+ *
+ * The raw value is returned unfiltered: {@code getIntProperty} yields {@link Integer#MIN_VALUE} when
+ * the property is unsupported, and the sign convention varies by OEM. Callers gate plausibility and
+ * derive direction from the charging status (see {@link BatteryRateTracker}).
+ *
+ * @param context The application context
+ *
+ * @return instantaneous current in µA, or {@link Integer#MIN_VALUE} when unavailable
+ */
+ public static int getInstantaneousCurrentMicroAmps(final Context context) {
+ final BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE);
+ if (isNull(batteryManager)) {
+ Log.w(TAG, "BatteryManager service unavailable");
+ return Integer.MIN_VALUE;
+ }
+ return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW);
+ }
+
/**
* Estimate the full battery capacity (mAh) from the remaining charge and remaining percentage.
*
diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java
index 5813927..dc217ea 100644
--- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java
+++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java
@@ -4,6 +4,7 @@
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
+import android.content.Context;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
@@ -21,6 +22,7 @@
import com.almothafar.simplebatterynotifier.R;
import com.almothafar.simplebatterynotifier.model.BatteryDO;
import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker;
+import com.almothafar.simplebatterynotifier.service.BatteryRateTracker;
import com.almothafar.simplebatterynotifier.service.SystemService;
import com.almothafar.simplebatterynotifier.util.GeneralHelper;
import com.almothafar.simplebatterynotifier.util.TemperatureUtils;
@@ -55,6 +57,12 @@ public class BatteryDetailsFragment extends Fragment {
private boolean capacityUnreliable;
private String capacityLabel;
+ // #108: the drain-rate row's label, and the colour applied to its value cell (amber near the user's
+ // limit, red at/above it, while discharging; 0 = no special colour). Both set in fillBatteryInfo and
+ // applied by createDetailsTable to just that row, mirroring the capacity special-case above.
+ private String rateLabel;
+ private int rateValueColor;
+
/**
* Default constructor required for fragment instantiation
*/
@@ -118,7 +126,9 @@ public void createDetailsTable(final View view) {
for (final String key : valuesMap.keySet()) {
// Only the capacity row gets the "unreliable reading" info affordance (#94).
final boolean unreliable = capacityUnreliable && key.equals(capacityLabel);
- final TableRow row = createTableRow(view, key, valuesMap.get(key), cellPadding, cellPaddingTop, unreliable);
+ // Only the drain-rate row is coloured (amber/red near the limit while discharging) — #108.
+ final int valueColor = (rateValueColor != 0 && key.equals(rateLabel)) ? rateValueColor : 0;
+ final TableRow row = createTableRow(view, key, valuesMap.get(key), cellPadding, cellPaddingTop, unreliable, valueColor);
tableLayout.addView(row, rowIndex);
rowIndex++;
}
@@ -196,11 +206,13 @@ public void onAnimationEnd(final Animator animation) {
* @param cellPadding The horizontal cell padding
* @param cellPaddingTop The top cell padding
* @param valueUnreliable When true, decorate the value cell with a tappable "unreliable reading" icon
+ * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108)
*
* @return The created table row
*/
private TableRow createTableRow(final View view, final String label, final String value,
- final int cellPadding, final int cellPaddingTop, final boolean valueUnreliable) {
+ final int cellPadding, final int cellPaddingTop,
+ final boolean valueUnreliable, final int valueColor) {
final TableRow row = new TableRow(view.getContext());
final TableRow.LayoutParams layoutParams = new TableRow.LayoutParams(
TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1.0f);
@@ -212,7 +224,7 @@ private TableRow createTableRow(final View view, final String label, final Strin
final TextView textViewLabel = createLabelTextView(view, label, cellPadding, cellPaddingTop);
final TextView textViewSep = createSeparatorTextView(view);
- final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable);
+ final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable, valueColor);
// Add views in logical order (label -> separator -> value)
// RTL languages will automatically reverse the visual order
@@ -273,6 +285,7 @@ private TextView createSeparatorTextView(final View view) {
* @param cellPaddingTop The top cell padding
* @param unreliable When true, append a tappable amber info icon that opens the "unreliable
* reading" explanation (#94)
+ * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108)
*
* @return The created TextView
*/
@@ -280,11 +293,14 @@ private TextView createValueTextView(final View view,
final String text,
final int cellPadding,
final int cellPaddingTop,
- final boolean unreliable) {
+ final boolean unreliable,
+ final int valueColor) {
final TextView textView = new TextView(view.getContext());
textView.setTextAppearance(R.style.DefaultTextStyle);
textView.setText(text);
- textView.setTextColor(GeneralHelper.getColor(getResources(), R.color.battery_details_value_color));
+ textView.setTextColor(valueColor != 0
+ ? valueColor
+ : GeneralHelper.getColor(getResources(), R.color.battery_details_value_color));
// Start-align + relative padding + locale text direction so numeric (Latin) values sit right after
// the colon like the Arabic text values do, instead of flying to the far edge in RTL (#96).
textView.setGravity(Gravity.START);
@@ -309,6 +325,11 @@ private TextView createValueTextView(final View view,
private void fillBatteryInfo(final View view) {
valuesMap = new LinkedHashMap<>();
+ // #108: live charge/drain rate and signed current at the very top, each shown only when its
+ // reading is trustworthy. Recording here also feeds the smoothing window from the foreground
+ // refresh (the other feed is the battery broadcast) without any polling timer of our own.
+ addRateRows(view);
+
valuesMap.put(getResources().getString(R.string.technology), batteryDO.getTechnology());
// Capacity is an estimate from BatteryManager. Show "Unknown" rather than "0 mAh" when the device
@@ -341,6 +362,57 @@ private void fillBatteryInfo(final View view) {
valuesMap.put(getResources().getString(R.string.battery_condition), batteryDO.getHealth());
}
+ /**
+ * Adds the drain/charge rate and signed-current rows at the top of the table (#108).
+ *
+ * Each row appears only when its reading is trustworthy (independent gating like #94): the rate hides
+ * during the brief post-unplug warm-up or a static level, the current hides when the device doesn't
+ * report a plausible mA. The label flips between "Drain rate" and "Charge rate" with direction, and
+ * the rate value is coloured amber/red near the user's limit while discharging (see {@link #rateColor}).
+ *
+ * @param view The fragment view
+ */
+ private void addRateRows(final View view) {
+ rateLabel = null;
+ rateValueColor = 0;
+
+ final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(view.getContext(), batteryDO);
+
+ if (rate.hasRate()) {
+ rateLabel = getResources().getString(rate.charging() ? R.string.charge_rate : R.string.drain_rate);
+ valuesMap.put(rateLabel, BatteryRateTracker.formatRateValue(view.getContext(), rate.percentPerHour()));
+ rateValueColor = rateColor(view.getContext(), rate);
+ }
+ if (rate.hasCurrent()) {
+ valuesMap.put(getResources().getString(R.string.battery_current),
+ BatteryRateTracker.formatCurrentValue(view.getContext(), rate.currentMilliAmps()));
+ }
+ }
+
+ /**
+ * The colour for the drain-rate value: red at/above the user's limit, amber as it approaches (derived
+ * just below the limit), otherwise the default. Charging is left uncoloured in v1 — its context traps
+ * (thermal throttling, deliberate trickle near 100%) make a single limit misleading (#108).
+ *
+ * @param context The context for resolving colours and the limit preference
+ * @param rate The computed rate
+ *
+ * @return a colour int, or 0 to keep the default value colour
+ */
+ private int rateColor(final Context context, final BatteryRateTracker.BatteryRate rate) {
+ if (rate.charging()) {
+ return 0;
+ }
+ final int limit = BatteryRateTracker.getDrainLimitPercentPerHour(context);
+ if (rate.percentPerHour() >= limit) {
+ return GeneralHelper.getColor(getResources(), R.color.battery_rate_high);
+ }
+ if (rate.percentPerHour() >= BatteryRateTracker.amberThreshold(limit)) {
+ return GeneralHelper.getColor(getResources(), R.color.battery_rate_warn);
+ }
+ return 0;
+ }
+
/**
* Explains why the capacity (and the health figure derived from it) can't be trusted on this device,
* including the possibility that the battery is genuinely wearing out (#94). Shares its wording with
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 4d4c71e..f93995d 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -31,6 +31,11 @@