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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public final class BatteryDO {
// 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;
// OS-reported charge cycle count from EXTRA_CYCLE_COUNT (Android 14+); -1 when the device doesn't
// report it. Carried on the snapshot so per-tick consumers don't re-read the sticky broadcast (#159/#161).
private int cycleCount = -1;
private String technology;
private String powerSource;
private String health;
Expand Down Expand Up @@ -212,6 +215,15 @@ public BatteryDO setCurrentMicroAmps(final int currentMicroAmps) {
return this;
}

public int getCycleCount() {
return cycleCount;
}

public BatteryDO setCycleCount(final int cycleCount) {
this.cycleCount = cycleCount;
return this;
}

public String getPowerSource() {
return powerSource;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,24 @@ public static int getEffectiveCycleCount(final Context context) {
if (isNull(context)) {
return 0;
}
final int osCycleCount = SystemService.getChargeCycleCount(context);
return getEffectiveCycleCount(context, SystemService.getChargeCycleCount(context));
}

/**
* Same as {@link #getEffectiveCycleCount(Context)}, for callers that already hold the OS cycle
* count (e.g. from a {@code BatteryDO} snapshot) — avoids a second sticky-broadcast read on the
* per-tick refresh path (#161).
*
* @param context Application context
* @param osCycleCount the already-read OS cycle count, or -1/0 when the device doesn't report one
*
* @return Charge cycle count (OS-reported if available, else the tracked estimate) plus any
* debug-injected cycles
*/
public static int getEffectiveCycleCount(final Context context, final int osCycleCount) {
if (isNull(context)) {
return 0;
}
final int base = osCycleCount > 0 ? osCycleCount : getChargeCycles(context);
return base + getDebugChargeCycles(context);
}
Expand All @@ -201,17 +218,15 @@ public static int getEffectiveCycleCount(final Context context) {
* The distinction matters for honesty (#114): the OS count covers the battery's whole life, while
* the app-tracked estimate only counts charge delivered since the app was installed — on an older
* phone it starts at zero and therefore understates real wear. The insights screen labels the
* health basis differently for the two sources.
* health basis differently for the two sources. Takes the already-read count so the caller's
* single sticky read serves both this and {@link #getEffectiveCycleCount(Context, int)} (#161).
*
* @param context Application context
* @param osCycleCount the already-read OS cycle count, or -1/0 when the device doesn't report one
*
* @return true when the OS reports a cycle count for this device
*/
public static boolean isCycleCountFromOs(final Context context) {
if (isNull(context)) {
return false;
}
return SystemService.getChargeCycleCount(context) > 0;
public static boolean isCycleCountFromOs(final int osCycleCount) {
return osCycleCount > 0;
}

/**
Expand Down Expand Up @@ -345,17 +360,18 @@ public static void setDesignCapacity(final Context context, final int mAh) {
* capacity. It is shown regardless of charge level so it stays consistent with the displayed
* Capacity (#103); when no design capacity is set, callers fall back to the cycle-based estimate.
*
* @param context Application context
* @param context Application context
* @param currentFullMah the already-read current full capacity estimate in mAh (0 when unknown),
* e.g. {@code batteryDO.getCapacity()} — passed in so one capacity estimate
* serves the whole refresh (#161)
*
* @return Measured health percentage (1-100), or -1 when it cannot be determined
*/
public static int getMeasuredHealthPercentage(final Context context) {
public static int getMeasuredHealthPercentage(final Context context, final int currentFullMah) {
if (isNull(context)) {
return -1;
}
return computeMeasuredHealth(
SystemService.getBatteryCapacity(context),
getDesignCapacity(context));
return computeMeasuredHealth(currentFullMah, getDesignCapacity(context));
}

/**
Expand Down Expand Up @@ -394,18 +410,19 @@ static int computeMeasuredHealth(final int currentFullMah, final int designMah)
* keeps the figure but adds a warning — see issue #94. A genuinely worn-out battery can also read
* this low, a possibility the UI surfaces to the user.
*
* @param context Application context
* @param context Application context
* @param currentFullMah the already-read current full capacity estimate in mAh (0 when unknown),
* e.g. {@code batteryDO.getCapacity()} — passed in so one capacity estimate
* serves the whole refresh (#161)
*
* @return true when a design capacity is set, a charge-counter estimate exists, and the estimate is
* outside the plausible window around the design capacity
*/
public static boolean isBatteryReadingUnreliable(final Context context) {
public static boolean isBatteryReadingUnreliable(final Context context, final int currentFullMah) {
if (isNull(context)) {
return false;
}
return isEstimateImplausible(
SystemService.getBatteryCapacity(context),
getDesignCapacity(context));
return isEstimateImplausible(currentFullMah, getDesignCapacity(context));
}

/**
Expand Down Expand Up @@ -492,8 +509,18 @@ public static int getDaysSinceFirstUse(final Context context) {
* @return Estimated battery health percentage (0-100)
*/
public static int getEstimatedHealthPercentage(final Context context) {
final int cycles = getEffectiveCycleCount(context);
return estimatedHealthForCycles(getEffectiveCycleCount(context));
}

/**
* Pure core of {@link #getEstimatedHealthPercentage}, taking an already-known cycle count so a
* caller's single read can serve every cycle-derived figure on the screen (#161).
*
* @param cycles effective charge cycle count
*
* @return Estimated battery health percentage (40-100)
*/
public static int estimatedHealthForCycles(final int cycles) {
// Excellent health: 0-300 cycles (100% to 95%)
if (cycles < EXCELLENT_THRESHOLD) {
return 100 - (cycles * 5 / EXCELLENT_THRESHOLD);
Expand Down Expand Up @@ -527,8 +554,18 @@ public static int getEstimatedHealthPercentage(final Context context) {
* @return the matching {@link BatteryHealthGrade}
*/
public static BatteryHealthGrade getHealthGrade(final Context context) {
final int cycles = getEffectiveCycleCount(context);
return gradeForCycles(getEffectiveCycleCount(context));
}

/**
* Pure core of {@link #getHealthGrade}, taking an already-known cycle count (#161). Uses the same
* thresholds as {@link #estimatedHealthForCycles} so percentage and grade always agree.
*
* @param cycles effective charge cycle count
*
* @return the matching {@link BatteryHealthGrade}
*/
public static BatteryHealthGrade gradeForCycles(final int cycles) {
if (cycles < EXCELLENT_THRESHOLD) {
return BatteryHealthGrade.EXCELLENT;
} else if (cycles < GOOD_THRESHOLD) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ private static BatteryExtras extractBatteryExtras(final Intent batteryStatus) {
final boolean present = extras != null && extras.getBoolean(BatteryManager.EXTRA_PRESENT);
final String technology = extras != null ? extras.getString(BatteryManager.EXTRA_TECHNOLOGY) : "";

return new BatteryExtras(level, scale, status, health, plugged, temperature, voltage, present, technology);
// Absent below Android 14 (and on devices whose fuel gauge doesn't report it) — normalized to
// -1 like getChargeCycleCount, so snapshot consumers don't need a second sticky read (#161).
final int rawCycleCount = batteryStatus.getIntExtra(BatteryManager.EXTRA_CYCLE_COUNT, -1);
final int cycleCount = rawCycleCount > 0 ? rawCycleCount : -1;

return new BatteryExtras(level, scale, status, health, plugged, temperature, voltage, present, technology, cycleCount);
}

/**
Expand Down Expand Up @@ -210,6 +215,7 @@ private static BatteryDO buildBatteryDataObject(final BatteryExtras extras,
.setCapacity(batteryCapacity)
.setCurrentMicroAmps(currentMicroAmps)
.setChargeCounterMicroAmpHours(chargeCounterUah)
.setCycleCount(extras.cycleCount)
.setIntHealth(extras.health);

// Determine health status and set it on the battery object
Expand Down Expand Up @@ -614,6 +620,7 @@ private record BatteryExtras(int level,
int temperature,
int voltage,
boolean present,
String technology) {
String technology,
int cycleCount) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,22 @@ protected void onResume() {
* Updates all health data displays with current values from BatteryHealthTracker.
*/
private void updateHealthData() {
// One sticky read and one capacity estimate per refresh (#161): every cycle- and
// capacity-derived figure below is computed from these two values.
final int osCycles = SystemService.getChargeCycleCount(this);
final int cycles = BatteryHealthTracker.getEffectiveCycleCount(this, osCycles);
final int capacityMah = SystemService.getBatteryCapacity(this);

// Always show the resolved health figure (measured, else cycle-based).
showResolvedHealth();
showResolvedHealth(cycles, BatteryHealthTracker.isCycleCountFromOs(osCycles), capacityMah);

// When the device's charge counter can't be trusted (#94) the figure may be wrong: keep showing
// it, but flag it with a tappable warning that explains why (and the failing-battery edge case).
healthWarningIcon.setVisibility(BatteryHealthTracker.isBatteryReadingUnreliable(this)
healthWarningIcon.setVisibility(BatteryHealthTracker.isBatteryReadingUnreliable(this, capacityMah)
? View.VISIBLE : View.GONE);

// Metrics and the design-capacity row are shown the same way in every state.
chargeCyclesText.setText(String.valueOf(BatteryHealthTracker.getEffectiveCycleCount(this)));
chargeCyclesText.setText(String.valueOf(cycles));
daysInUseText.setText(String.valueOf(BatteryHealthTracker.getDaysSinceFirstUse(this)));

final int designCapacity = BatteryHealthTracker.getDesignCapacity(this);
Expand All @@ -113,17 +119,22 @@ private void updateHealthData() {
/**
* Shows the resolved health figure: the measured percentage (current capacity vs. user-entered
* design capacity) when available, otherwise the cycle-based estimate. See issue #32 / #7.
* Works entirely from the values the caller already read this refresh (#161).
*
* @param cycles the effective charge cycle count for this refresh
* @param cyclesFromOs whether that count came from the OS (labels the basis honestly, #114)
* @param capacityMah the current full capacity estimate in mAh (0 when unknown)
*/
private void showResolvedHealth() {
final int measuredHealth = BatteryHealthTracker.getMeasuredHealthPercentage(this);
private void showResolvedHealth(final int cycles, final boolean cyclesFromOs, final int capacityMah) {
final int measuredHealth = BatteryHealthTracker.getMeasuredHealthPercentage(this, capacityMah);
final boolean measured = measuredHealth >= 0;

final int healthPercentage = measured
? measuredHealth
: BatteryHealthTracker.getEstimatedHealthPercentage(this);
: BatteryHealthTracker.estimatedHealthForCycles(cycles);
final BatteryHealthGrade grade = measured
? BatteryHealthTracker.gradeForPercentage(measuredHealth)
: BatteryHealthTracker.getHealthGrade(this);
: BatteryHealthTracker.gradeForCycles(cycles);

// Update health percentage and color it based on grade
healthPercentageText.setText(healthPercentage + "%");
Expand All @@ -138,7 +149,7 @@ private void showResolvedHealth() {
// wear on a phone older than the app (#114).
final int basisRes = measured
? R.string.health_basis_measured
: BatteryHealthTracker.isCycleCountFromOs(this)
: cyclesFromOs
? R.string.health_basis_estimated_os
: R.string.health_basis_estimated_tracked;
healthBasisText.setText(basisRes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
import com.almothafar.simplebatterynotifier.R;
import com.almothafar.simplebatterynotifier.model.BatteryDO;
Expand Down Expand Up @@ -62,9 +61,12 @@ public class MainActivity extends BaseActivity {
private ActivityResultLauncher<Intent> settingsLauncher;
private ActivityResultLauncher<String> notificationPermissionLauncher;

// UI elements
// UI elements, looked up once in onCreate — the refresh loop runs every 3 s, so per-tick
// findViewById/findFragmentById traversals are pointless work (#161).
private MaterialButton batteryInsightsButton;
private RangeSlider thresholdSlider;
private HorseshoeProgressBar batteryGauge;
private BatteryDetailsFragment batteryDetailsFragment;

// Self-reposting refresh loop, bound to the foreground lifecycle (started in onPostResume,
// stopped in onPause) so it never stacks across resumes or keeps polling in the background.
Expand Down Expand Up @@ -165,6 +167,9 @@ protected void onCreate(final Bundle savedInstanceState) {

// Initialize UI elements
batteryInsightsButton = findViewById(R.id.batteryInsightsButton);
batteryGauge = findViewById(R.id.batteryPercentage);
// The details fragment is declared in the layout, so it exists for the activity's lifetime.
batteryDetailsFragment = (BatteryDetailsFragment) getSupportFragmentManager().findFragmentById(R.id.detailsFragmentLayout);

// Set up button click listeners
batteryInsightsButton.setOnClickListener(v -> openBatteryInsights());
Expand Down Expand Up @@ -195,8 +200,7 @@ protected void onPostResume() {

// Resume the motion paused in onPause(); restarts only what the battery state still
// warrants (charging/discharging wave, full pulse, or critical breathing).
final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
gauge.resumeAnimations();
batteryGauge.resumeAnimations();
}

/**
Expand All @@ -210,8 +214,7 @@ protected void onPause() {

// Motion is only auto-stopped when the view is destroyed (onDetachedFromWindow),
// not on backgrounding, so pause it here for the same reason we stop the timer.
final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
gauge.pauseAnimations();
batteryGauge.pauseAnimations();
}

/**
Expand Down Expand Up @@ -267,21 +270,16 @@ private void stopUpdateTimer() {
* Runs on the main thread via {@link #handler}.
*/
private void refreshBatteryUi() {
final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
fillBatteryInfo();
gauge.setLevel(batteryPercentage);
gauge.setTitle(batteryPercentageText);
gauge.setStatusText(subTitle);
batteryGauge.setLevel(batteryPercentage);
batteryGauge.setTitle(batteryPercentageText);
batteryGauge.setStatusText(subTitle);

// Drive the gauge motion: charging wave, full-on-charger idle pulse, or discharge wave.
if (nonNull(batteryDO)) {
gauge.setFlow(flowOf(batteryDO.getStatus()));
batteryGauge.setFlow(flowOf(batteryDO.getStatus()));
}

final FragmentManager fragmentManager = getSupportFragmentManager();
final BatteryDetailsFragment batteryDetailsFragment =
(BatteryDetailsFragment) fragmentManager.findFragmentById(R.id.detailsFragmentLayout);

if (nonNull(batteryDetailsFragment) && nonNull(batteryDO)) {
batteryDetailsFragment.updateBatteryDetails(batteryDO);
}
Expand Down Expand Up @@ -309,18 +307,17 @@ private void initializeFirstValues() {
fillBatteryInfo();
final SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(this);

final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
gauge.setThresholds(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20),
batteryGauge.setThresholds(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20),
sharedPref.getInt(getString(R.string._pref_key_warn_battery_level), 40));

// Keep the in-fly slider in sync with values that may have changed in Settings.
syncThresholdSlider();

// The ring still animates whole levels; only the final title carries the decimals (#158).
// Intermediate steps count up in whole percent, then the last step lands on the precise text.
gauge.animateLevelTo(batteryPercentage, progress -> {
gauge.setTitle(progress >= batteryPercentage ? batteryPercentageText : BatteryPercentFormatter.formatWhole(progress));
gauge.setStatusText(subTitle);
batteryGauge.animateLevelTo(batteryPercentage, progress -> {
batteryGauge.setTitle(progress >= batteryPercentage ? batteryPercentageText : BatteryPercentFormatter.formatWhole(progress));
batteryGauge.setStatusText(subTitle);
});
}

Expand Down Expand Up @@ -367,8 +364,7 @@ public void onStopTrackingTouch(@NonNull final RangeSlider slider) {
.putInt(getString(R.string._pref_key_warn_battery_level), warning)
.apply();

final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
gauge.setThresholds(critical, warning);
batteryGauge.setThresholds(critical, warning);
}
});

Expand Down
Loading