Battery (Android): adds temperature and remaining time detection

This commit is contained in:
Carter Li
2026-10-01 19:56:19 +08:00
parent 4d8d6a5fe9
commit ab200636f1
2 changed files with 444 additions and 35 deletions
+1
View File
@@ -6,6 +6,7 @@ Changes:
Features:
* Improved performance of LocalIP, NetIO and DNS on Windows
* Added Sound detection on Android. (Sound, Android)
* Added battery temperature, remaining time detection and the charger type (AC / USB / wireless) on Android. (Battery, Android)
Bugfixes:
* Fixed `--config`/`-c` bash completion offering one candidate per word instead of one candidate per file for paths containing spaces. (Completion, Bash)
+443 -35
View File
@@ -4,20 +4,58 @@
#include "common/android/binder.h"
#include "common/android/dex.h"
#include "common/debug.h"
#include "common/io.h"
#include "common/processing.h"
#include "common/properties.h"
#include "common/settings.h"
#include "common/strutil.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <string.h>
// Android exposes the battery through BatteryManager, which the NDK does not wrap and whose sysfs
// backing is unreadable from an app UID. What does work without any permission is
// android.os.IBatteryPropertiesRegistrar in system_server, reachable either over /dev/binder (see
// common/android/binder.h) or through a `dumpsys battery` subprocess. The binder route is the cheap
// one: ~0.3 ms for both properties, and no child process.
// backing is unreadable from an app UID -- /sys/class/power_supply is closed to untrusted_app
// outright, directory and all. What does work without any permission is
// android.os.IBatteryPropertiesRegistrar in system_server, over /dev/binder (see
// common/android/binder.h).
//
// The registrar only answers properties that do not need BATTERY_STATS, and that rules out
// everything the module would otherwise like to show. Manufacturer, model name, technology, serial
// number, manufacture date, temperature and cycle count all sit behind that permission.
// `dumpsys battery` is the other route, tried first for shell and root because it is the richer of
// the two: a flat `key: value` list read by name, so it carries what neither the registrar nor a
// property has -- the technology and the critical capacity level -- and neither a vendor that prints
// extra keys in the middle nor a release that appends a field can break it. The binder reply is
// positional and breaks silently instead. What it costs is a fork/exec, which is why it is only
// reached where it can succeed; see the UID gate in ffDetectBattery.
//
// An app UID gets none of that, and not because Android withholds it. `ACTION_BATTERY_CHANGED` is a
// sticky broadcast, and a sticky broadcast is read without a permission: it carries the technology,
// and a cycle count since Android 14 -- the device's own BatteryManager declares `EXTRA_CYCLE_COUNT`,
// spelled "android.os.extra.CYCLE_COUNT". What is out of reach is the call that fetches it. A sticky
// broadcast is read by `registerReceiver`, the NDK does not wrap it, and a native binary has no Java
// side to make that call from, so every route to it is a Java process. termux-api is one, and it is
// not the answer: on the device this was written on it hung on six of six runs, each killed at 30
// seconds having written nothing, after exec'ing `am broadcast` and waiting on a socket that has no
// timeout of its own.
//
// What is left within reach is a pair of system properties BatteryService mirrors out of every update
// it processes: `debug.tracing.plug_type` and `debug.tracing.battery_status`. A system property is
// world readable, which is the whole point of them here -- the charger bits used to need `dumpsys
// battery`, and so the shell UID, while the status used to cost a second transaction. Both are now
// free, to any UID; see the two defines below.
//
// The temperature comes from neither service. The kernel publishes it as a thermal zone, and
// /sys/class/thermal is readable from an app UID -- unlike /sys/class/power_supply, which is closed
// outright, directory and all -- so it is read there whenever `temp` asks for it. The dump has a
// `temperature` line of its own and that one wins where the dump runs; see `detectTemperature` below
// for why the zone is looked up by name rather than addressed by number.
//
// The registrar answers nothing else. `BatteryProperty` has no temperature, no technology and no
// cycle count, and manufacturer and model name are not battery data at all. Of the properties that
// do exist, ids 7 to 12 -- the ones Android 15 added -- are behind BATTERY_STATS, and ids 1 to 6 are
// not. The one reading the module wants that is not a property either, how long the charge will
// last, comes from a different service; see the `batterystats` block below.
// The reply layout is positional and is not negotiated with the service, so it only stays correct as
// long as AOSP keeps the order it already has. It has, because `BatteryProperty` has only ever grown
@@ -44,6 +82,39 @@
#define FF_BATTERY_ANDROID_STUB "Landroid/os/IBatteryPropertiesRegistrar$Stub;"
#define FF_BATTERY_ANDROID_GET_PROPERTY "TRANSACTION_getProperty"
// How long the charge will last is not a property of the pack: it is a forecast BatteryStatsService
// makes from the discharge history, so it lives on a second service. That service is `batterystats`,
// whose interface is com.android.internal.app.IBatteryStats, and it is also in framework.jar --
// com.android.internal.app is not one of the namespaces that moved into an APEX -- so the same read
// resolves this code as the one above.
//
// The interface has 120 methods and the dex cannot say which of them an app UID may call: neither
// IBatteryStats nor its $Stub carries an `_enforcePermission` wrapper, so the wall is inside
// BatteryStatsService, which lives in services.jar and is not available to read. It was therefore
// measured, as uid 10478 on an Android 16 device. The three this module needs answer with no
// permission at all -- and so do `getAllWakeLocks` (117) and `getAllWakeLocksEx` (118), which is why
// the claim is not "only three of 120". Every read-only statistic getter tried (`getAwakeTimeBattery`,
// `getAwakeTimePlugged`, and the cellular / wifi / gps / bluetooth ones) came back with
// "Access denied, requires: android.permission.BATTERY_STATS", or an `anyOf` of that and
// UPDATE_DEVICE_STATS.
//
// The code is read out of the jar for the same reason as `getProperty`, and the numbers make the
// point better than the argument does: this method is transaction 19 on that Android 16 device, 22
// on a Mi 10 (Android 14) and 20 on a Mi 9 (Android 10). A hardcoded 19 would reach a different
// method on two of the three.
#define FF_BATTERY_ANDROID_STATS_SERVICE "batterystats"
#define FF_BATTERY_ANDROID_STATS_DESCRIPTOR "com.android.internal.app.IBatteryStats"
#define FF_BATTERY_ANDROID_STATS_STUB "Lcom/android/internal/app/IBatteryStats$Stub;"
#define FF_BATTERY_ANDROID_COMPUTE_BATTERY_TIME_REMAINING "TRANSACTION_computeBatteryTimeRemaining"
// The temperature is a third source again, and not a service this time: the kernel publishes it as a
// thermal zone, and the zone Android's own thermal HAL names `battery` is the one to read. The name
// is what is matched -- not the number, and not a prefix -- for the reasons spelled out in
// detectTemperature below.
#define FF_BATTERY_ANDROID_THERMAL_DIR "/sys/class/thermal/"
#define FF_BATTERY_ANDROID_THERMAL_ZONE_PREFIX "thermal_zone"
#define FF_BATTERY_ANDROID_THERMAL_BATTERY_TYPE "battery"
// BatteryManager.BATTERY_PROPERTY_*
typedef enum FFBatteryAndroidProperty : uint32_t {
FF_BATTERY_ANDROID_PROPERTY_CAPACITY = 4,
@@ -51,19 +122,49 @@ typedef enum FFBatteryAndroidProperty : uint32_t {
} FFBatteryAndroidProperty;
// BatteryManager.BATTERY_STATUS_*, which the health HAL's `BatteryStatus` mirrors value for value.
// Only the three the module has something to say about are carried: NOT_CHARGING (4) and FULL (5)
// both mean a charger is attached, which the powered bits already say.
// The module has something to say about only three of the five: NOT_CHARGING (4) and FULL (5) both
// mean a charger is attached, and it answers nothing about the charger to put that on, while the dump
// route maps UNKNOWN (1) as well, because there it is a different statement from the service not
// answering at all. The two are named anyway, because the property route validates the whole range
// before it trusts a value it did not get from the service.
typedef enum FFBatteryAndroidStatus : uint32_t {
FF_BATTERY_ANDROID_STATUS_UNKNOWN = 1,
FF_BATTERY_ANDROID_STATUS_CHARGING = 2,
FF_BATTERY_ANDROID_STATUS_DISCHARGING = 3,
FF_BATTERY_ANDROID_STATUS_NOT_CHARGING = 4,
FF_BATTERY_ANDROID_STATUS_FULL = 5,
} FFBatteryAndroidStatus;
// The pair of system properties that stand in for two of the readings above. Both are published by
// `BatteryService.processValuesLocked` -- the two strings sit next to each other in the constant pool
// of classes.dex in this device's services.jar, which is what a pair of `SystemProperties.set` calls
// looks like after the fact -- and neither is a `BatteryProperty`, so nothing about the registrar's
// positional reply applies to them.
//
// debug.tracing.plug_type -- BatteryManager.BATTERY_PLUGGED_*: 1 AC, 2 USB, 4 wireless,
// 8 dock, and 0 when nothing is attached. The dock bit has no
// `FF_BATTERY_STATUS_*` counterpart and is masked off below.
// debug.tracing.battery_status -- BatteryManager.BATTERY_STATUS_*, the value
// `getProperty(BATTERY_PROPERTY_STATUS)` answers with as well:
// 1 UNKNOWN, 2 CHARGING, 3 DISCHARGING, 4 NOT_CHARGING, 5 FULL.
#define FF_BATTERY_ANDROID_PROP_PLUG_TYPE "debug.tracing.plug_type"
#define FF_BATTERY_ANDROID_PROP_BATTERY_STATUS "debug.tracing.battery_status"
// The encoding of plug_type, read out of the device's own framework.jar rather than assumed: the
// values are bits of one field and not a sequence, so `value != 0` is not a test for "wired", and a
// device reporting the dock would be told it is on neither AC nor USB nor wireless.
typedef enum FFBatteryAndroidPlugged : uint32_t {
FF_BATTERY_ANDROID_PLUGGED_AC = 1,
FF_BATTERY_ANDROID_PLUGGED_USB = 2,
FF_BATTERY_ANDROID_PLUGGED_WIRELESS = 4,
} FFBatteryAndroidPlugged;
// BatteryCapacityLevel.BATTERY_CAPACITY_LEVEL_CRITICAL, the level the framework itself shuts the
// device down on. This is the health HAL's numbering, not the one the older
// `BatteryManager.BATTERY_CAPACITY_LEVEL_*` constants used, which had CRITICAL at 4. The rest of the
// enum -- -1 UNSUPPORTED, 0 UNKNOWN, 2 LOW, 3 NORMAL, 4 HIGH, 5 FULL -- only restates the
// percentage, so only this value is carried.
// enum is skipped: -1 (UNSUPPORTED) and 0 (UNKNOWN) are what a HAL that does not implement the
// property reports, and 2 to 5 only restate the percentage. Only the dump carries this -- it is not a
// `BatteryProperty`, so the binder route never sees it.
typedef enum FFBatteryAndroidCapacityLevel : int32_t {
FF_BATTERY_ANDROID_CAPACITY_LEVEL_CRITICAL = 1,
} FFBatteryAndroidCapacityLevel;
@@ -75,6 +176,11 @@ typedef enum FFBatteryAndroidCapacityLevel : int32_t {
#define FF_BATTERY_ANDROID_VALUE_OFFSET 12
#define FF_BATTERY_ANDROID_VALUE_UNSET 0x8000000000000000ULL // Long.MIN_VALUE, i.e. never filled in
// A method that answers with a bare `long` writes [int32 exception][int64 value] and nothing else:
// there is no out parameter, so no non-null marker sits in front of the value the way it does in a
// `getProperty` reply. Twelve bytes in all on the device.
#define FF_BATTERY_ANDROID_LONG_VALUE_OFFSET 4
// One `getProperty` reply is [exception code][return value][value present][int64 mValueLong], and it
// measures 24 bytes on the device: API 35 appended [string8 mValueString] to `BatteryProperty`, and a
// reply carries a word for it even when it is empty. A dump of the first 32 covers every word.
@@ -170,16 +276,267 @@ static const char* getProperty(FFBinder* binder, uint32_t handle, uint32_t trans
return nullptr;
}
static const char* parseBinder(FFlist* results) {
// Resolved before the binder is opened, so that a jar which does not declare the method says so
// rather than the module failing later with a message about the transport. The reader reports a
// code it cannot find by leaving the sentinel in the result and says nothing more -- what a
// missing code means for this module is decided here, not there.
int32_t transaction = FF_DEX_STATIC_INT_UNRESOLVED;
const FFDexStaticIntRequest request = { FF_BATTERY_ANDROID_STUB, FF_BATTERY_ANDROID_GET_PROPERTY, &transaction };
const char* error = ffDexStaticInts(FF_BATTERY_ANDROID_JAR, &request, 1);
// Reads either of the two properties above as a number. Both are written by `Integer.toString`, so a
// buffer whose text is not a number is the property saying nothing rather than a 0 -- 0 is a plug type,
// and it is not a status.
static bool readPropertyUInt(const char* name, uint32_t* value) {
FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate();
if (!ffSettingsGetAndroidProperty(name, &buffer)) {
FF_DEBUG("The \"%s\" property is not set, so nothing is read from it", name);
return false;
}
// `ffStrbufToUInt` answers the default for text that does not start with a digit, which is why the
// default is a value neither property can hold rather than a 0.
const uint64_t parsed = ffStrbufToUInt(&buffer, UINT64_MAX);
if (parsed == UINT64_MAX) {
FF_DEBUG("The \"%s\" property reads \"%s\", which is not a number", name, buffer.chars);
return false;
}
*value = (uint32_t) parsed;
return true;
}
// The charger type. This is what the property route has and the registrar does not: `getProperty`
// answers nothing about the charger, so the three bits used to come from `dumpsys battery`, i.e. from
// the shell UID and root alone. A device that does not publish the property leaves the bits clear,
// which is also what an unplugged charger looks like -- the module cannot tell those apart, and neither
// can the framework, whose sticky broadcast carries this same value as `EXTRA_PLUGGED`.
static void fillPlugged(FFBatteryResult* battery) {
uint32_t plugged = 0;
if (!readPropertyUInt(FF_BATTERY_ANDROID_PROP_PLUG_TYPE, &plugged)) {
return;
}
// A mask rather than a comparison: the three are bits of one field, and it is the bits that have a
// `FF_BATTERY_STATUS_*` counterpart. BATTERY_PLUGGED_DOCK (8) is the one that does not, so a device
// on a dock reports no powered bit at all -- and `plugged != 0` would have called that "wired".
if (plugged & FF_BATTERY_ANDROID_PLUGGED_AC) {
battery->status |= FF_BATTERY_STATUS_AC_CONNECTED;
}
if (plugged & FF_BATTERY_ANDROID_PLUGGED_USB) {
battery->status |= FF_BATTERY_STATUS_USB_CONNECTED;
}
if (plugged & FF_BATTERY_ANDROID_PLUGGED_WIRELESS) {
battery->status |= FF_BATTERY_STATUS_WIRELESS_CONNECTED;
}
FF_DEBUG("The \"%s\" property is %u, which becomes status bits 0x%x (1 AC, 2 USB, 4 wireless; 0 is "
"what both an unplugged charger and a device that does not publish the property look like)",
FF_BATTERY_ANDROID_PROP_PLUG_TYPE, plugged, (unsigned) battery->status);
}
// The status, read the cheap way first. The property carries the same `mHealthInfo.batteryStatus` the
// transaction would fetch, so the two can only differ inside the window between the health HAL reporting
// a value and the service storing it, while what the property costs is a read of a shared page instead
// of a round trip through /dev/binder -- a round trip measured at about 0.04 ms on the device this was
// written on, and what it saves is a positional reply that has to be decoded as well.
//
// It stays a preference rather than a replacement: a property that is not set (an early boot, or a
// build whose BatteryService does not publish it) or that does not carry one of the five values sends
// the caller to the service, which is the one answer that cannot be stale.
static bool detectStatusFromProperty(uint64_t* status) {
uint32_t value = 0;
if (!readPropertyUInt(FF_BATTERY_ANDROID_PROP_BATTERY_STATUS, &value)) {
return false;
}
if (value < FF_BATTERY_ANDROID_STATUS_UNKNOWN || value > FF_BATTERY_ANDROID_STATUS_FULL) {
FF_DEBUG("The \"%s\" property is %u, which is not a battery status, so the service is asked",
FF_BATTERY_ANDROID_PROP_BATTERY_STATUS, value);
return false;
}
*status = value;
return true;
}
// Calls a method that takes no argument and answers with one `long`, which is how
// IBatteryStats.computeBatteryTimeRemaining() is declared. `transaction` comes out of the jar, and the
// sentinel the reader leaves for a code it could not find is caught here, so that a build which does
// not declare the method is a message rather than a transaction that means something else.
//
// A negative answer is not an error: it is how the interface spells "no estimate", so it is passed
// back as it came and the caller decides what that is worth. The unit is milliseconds.
static const char* getTimeRemaining(FFBinder* binder, uint32_t handle, int32_t transaction, int64_t* milliseconds) {
if (transaction == FF_DEX_STATIC_INT_UNRESOLVED) {
return "The battery stats service does not declare computeBatteryTimeRemaining";
}
uint8_t parcelBuffer[128];
FFBinderParcel parcel = ffBinderParcelCreate(parcelBuffer, sizeof(parcelBuffer));
ffBinderParcelPutInterfaceToken(&parcel, FF_BATTERY_ANDROID_STATS_DESCRIPTOR);
uint8_t replyBuffer[128];
FFBinderReply reply = ffBinderReplyCreate(replyBuffer, sizeof(replyBuffer));
const char* error = ffBinderTransact(binder, handle, (uint32_t) transaction, 0, &parcel, &reply);
if (error != nullptr) {
FF_DEBUG("Reading \"%s\" from \"%s\" failed: %s", FF_BATTERY_ANDROID_GET_PROPERTY, FF_BATTERY_ANDROID_JAR, error);
FF_DEBUG("computeBatteryTimeRemaining could not be transacted: %s", error);
return error;
}
if (ffBinderReplyIsStatus(&reply)) {
FF_DEBUG("computeBatteryTimeRemaining came back as a status reply of %d instead of a parcel",
(int) ffBinderReadI32(reply.data, reply.size, 0));
return "The battery stats service rejected the request";
}
// The exception word is read first and the value only once it is known to be a value: a denied
// call answers [exception -1][string16 message], so the eight bytes at the value's offset are
// then the length and the first characters of the message rather than a number.
const int32_t exception = ffBinderReadI32(reply.data, reply.size, 0);
if (exception != 0) {
FF_DEBUG("computeBatteryTimeRemaining is transaction %d, reply %zu bytes, exception %d",
transaction, reply.size, exception);
return "The battery stats service raised an exception";
}
*milliseconds = (int64_t) ffBinderReadU64(reply.data, reply.size, FF_BATTERY_ANDROID_LONG_VALUE_OFFSET);
FF_DEBUG("computeBatteryTimeRemaining is transaction %d: reply is %zu bytes, value %" PRId64 " ms",
transaction, reply.size, *milliseconds);
return nullptr;
}
// Writes the time estimate onto the battery just reported. It is not one of the registrar's
// properties -- it is a forecast BatteryStatsService makes -- so it comes from a second service and
// is filled in here, after the capacity and the status are already in place.
//
// `binder` and `handle` are the caller's, already open on `batterystats`. Nothing here can fail the
// module: an estimate that is not available is what `FFBatteryResult` already spells with -1.
static void fillTimeRemaining(FFBinder* binder, uint32_t handle, int32_t transaction, FFlist* results) {
if (results->length == 0) {
FF_DEBUG("No time remaining is reported: no battery was reported to put it on");
return;
}
int64_t milliseconds = 0;
const char* error = getTimeRemaining(binder, handle, transaction, &milliseconds);
if (error != nullptr) {
FF_DEBUG("The time remaining could not be computed, so none is reported: %s", error);
return;
}
if (milliseconds <= 0) {
FF_DEBUG("The battery stats service answered %" PRId64 " ms, which is how it says it has no estimate",
milliseconds);
return;
}
// The interface answers in milliseconds and `FFBatteryResult` carries seconds, which is what the
// other platform modules fill in. An answer under a second truncates to 0, which the module reads
// as "unknown" as well, so there is nothing to guard against.
FF_LIST_GET(FFBatteryResult, *results, 0)->timeRemaining = (int32_t) (milliseconds / 1000);
}
// The battery temperature is neither a registrar property nor part of the health HAL: the kernel
// publishes it as a thermal zone, and the zone Android's thermal HAL names `battery` is the one to
// read. It is read from /sys/class/thermal, which an app UID may open -- the reason the module does
// not simply read /sys/class/power_supply/battery/temp is that that directory is closed to it.
//
// The zone is *found*, not addressed. Its number is a property of the probe order rather than of the
// hardware -- the battery is `thermal_zone67` on the device this was written on, and nothing promises
// the next one -- so the directory is walked and each entry is matched on the `type` it reports.
//
// That match is an exact string, and has to stay one. A prefix would be cheaper and wrong: the same
// directory holds hardware trip points (`cpu-hw-trip-0` reports 95000, a perfectly believable 95
// degrees), current and battery levels (`pmih010x-ibat-lvl0`, 0 or a small integer) and raw registers
// (`vbat`, in millivolts). None of those are a temperature, and every one of them would be printed as
// one. Reading a different zone is not a smaller error than reading none.
//
// A device that names its battery zone something else therefore reports no temperature rather than a
// wrong one, which is what FF_BATTERY_TEMP_UNSET already means.
static double detectTemperature(void) {
FF_AUTO_CLOSE_DIR DIR* dirp = opendir(FF_BATTERY_ANDROID_THERMAL_DIR);
if (dirp == nullptr) {
FF_DEBUG("Opening \"%s\" failed, so no temperature is reported: %s", FF_BATTERY_ANDROID_THERMAL_DIR, strerror(errno));
return FF_BATTERY_TEMP_UNSET;
}
FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate();
struct dirent* entry;
while ((entry = readdir(dirp)) != nullptr) {
if (!ffStrStartsWith(entry->d_name, FF_BATTERY_ANDROID_THERMAL_ZONE_PREFIX)) {
continue;
}
FF_AUTO_CLOSE_FD int zone = openat(dirfd(dirp), entry->d_name, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (zone < 0) {
continue;
}
// The type file ends with a newline, which the comparison below would otherwise never survive.
if (!ffReadFileBufferRelative(zone, "type", &buffer)) {
continue;
}
ffStrbufTrimRightSpace(&buffer);
if (!ffStrbufEqualS(&buffer, FF_BATTERY_ANDROID_THERMAL_BATTERY_TYPE)) {
continue;
}
if (!ffReadFileBufferRelative(zone, "temp", &buffer)) {
FF_DEBUG("The battery zone \"%s\" has no readable temperature", entry->d_name);
return FF_BATTERY_TEMP_UNSET;
}
// sysfs thermal publishes millidegrees Celsius. That is the one thing every zone here agrees
// on, even the ones whose reading is not a temperature at all.
const double value = ffStrbufToDouble(&buffer, FF_BATTERY_TEMP_UNSET);
if (value == FF_BATTERY_TEMP_UNSET) {
FF_DEBUG("The battery zone \"%s\" reports \"%s\", which is not a number", entry->d_name, buffer.chars);
return FF_BATTERY_TEMP_UNSET;
}
FF_DEBUG("The battery zone is \"%s\", reporting %.2f degrees", entry->d_name, value / 1000.);
return value / 1000.;
}
FF_DEBUG("No zone in \"%s\" is named \"%s\", so no temperature is reported",
FF_BATTERY_ANDROID_THERMAL_DIR, FF_BATTERY_ANDROID_THERMAL_BATTERY_TYPE);
return FF_BATTERY_TEMP_UNSET;
}
// Temperature is opt-in on every platform, and on Android that is not only about the display: the
// walk above touches a directory with a hundred-odd entries on the device this was written on, and
// none of that is paid for unless `temp` asks for it.
static void fillTemperature(FFBatteryOptions* options, FFlist* results) {
if (!options->temp) {
FF_DEBUG("Temperature is not enabled, so the thermal zones are not walked");
return;
}
if (results->length == 0) {
return;
}
const double temperature = detectTemperature();
if (temperature != FF_BATTERY_TEMP_UNSET) {
FF_LIST_GET(FFBatteryResult, *results, 0)->temperature = temperature;
}
}
static const char* parseBinder(FFBatteryOptions* options, FFlist* results) {
// Resolved before the binder is opened, so that a jar which does not declare the method says so
// rather than the module failing later with a message about the transport. Both codes are asked
// for in one call, which is cheaper than two: the reader walks the jar's dex entries in order and
// stops once every request has been answered, so a second request only costs the walk to the
// entry that defines the second class. That walk is not free here, because the two classes are
// not in the same entry -- `IBatteryPropertiesRegistrar$Stub` is in classes3.dex and
// `IBatteryStats$Stub` in classes5.dex -- and on the device this was measured on it added 1.3 ms
// to a module that had been 3.3 ms, while the service lookup and the transaction behind it stayed
// inside the noise. The reader reports a code it cannot find by leaving the sentinel in the
// result and says nothing more -- what a missing code means for this module is decided here, not
// there.
//
// The two are not equally important. `getProperty` is what the module is made of, so a build
// without it has nothing to report; `computeBatteryTimeRemaining` only adds the time estimate,
// and a build without it still has the capacity and the status, so its sentinel is carried on to
// where the estimate is asked for instead of failing the route here.
int32_t transaction = FF_DEX_STATIC_INT_UNRESOLVED;
int32_t timeRemainingTransaction = FF_DEX_STATIC_INT_UNRESOLVED;
const FFDexStaticIntRequest requests[] = {
{ FF_BATTERY_ANDROID_STUB, FF_BATTERY_ANDROID_GET_PROPERTY, &transaction },
{ FF_BATTERY_ANDROID_STATS_STUB, FF_BATTERY_ANDROID_COMPUTE_BATTERY_TIME_REMAINING, &timeRemainingTransaction },
};
const char* error = ffDexStaticInts(FF_BATTERY_ANDROID_JAR, requests, ARRAY_SIZE(requests));
if (error != nullptr) {
FF_DEBUG("Reading the transaction codes from \"%s\" failed: %s", FF_BATTERY_ANDROID_JAR, error);
return error;
}
if (transaction == FF_DEX_STATIC_INT_UNRESOLVED) {
@@ -209,11 +566,21 @@ static const char* parseBinder(FFlist* results) {
return error;
}
// The status is asked of the property first, because that is where the second transaction on the
// service is saved. The service is still asked when the property has nothing to say, and a failure
// there is still fatal: the capacity and the status are what this route is made of, and a battery
// whose status is unknown is not a battery this route can describe.
uint64_t status = 0;
error = getProperty(&binder, service.handle, (uint32_t) transaction, FF_BATTERY_ANDROID_PROPERTY_STATUS, &status);
if (error != nullptr) {
FF_DEBUG("The status is what failed, so no battery is reported at all");
return error;
if (detectStatusFromProperty(&status)) {
FF_DEBUG("The status came from the \"%s\" property and is %" PRIu64 ", so the service is not asked "
"for it",
FF_BATTERY_ANDROID_PROP_BATTERY_STATUS, status);
} else {
error = getProperty(&binder, service.handle, (uint32_t) transaction, FF_BATTERY_ANDROID_PROPERTY_STATUS, &status);
if (error != nullptr) {
FF_DEBUG("The status is what failed, so no battery is reported at all");
return error;
}
}
FFBatteryResult* battery = FF_LIST_ADD(FFBatteryResult, *results);
@@ -224,13 +591,39 @@ static const char* parseBinder(FFlist* results) {
} else if (status == FF_BATTERY_ANDROID_STATUS_DISCHARGING) {
battery->status |= FF_BATTERY_STATUS_DISCHARGING;
}
// The charger type is the one reading this route has that the registrar does not, and it is a
// property as well: the three bits used to come from `dumpsys battery`, and so from the shell UID
// and root alone.
fillPlugged(battery);
// The temperature comes from outside this route entirely, so it is asked for here rather than
// inside it: nothing about the binder session above has any bearing on it, and a device without a
// zone by that name simply leaves the field at the sentinel.
fillTemperature(options, results);
// The time estimate is the one part of this route that is allowed to be missing: a jar without
// the code, a service that is not running and a service that answers -1 all leave the field at
// the -1 the module starts it with, which is how `FFBatteryResult` spells "unknown". It is asked
// for last for that reason -- a failure here cannot cost the capacity and the status that have
// already been read.
[[gnu::cleanup(ffBinderServiceHandleRelease)]] FFBinderServiceHandle stats = { .binder = &binder };
error = ffBinderLookupService(&binder, FF_BATTERY_ANDROID_STATS_SERVICE, FF_BINDER_SM_GET_SERVICE, &stats.handle);
if (error != nullptr) {
FF_DEBUG("Looking up the \"%s\" service failed, so no time remaining is reported: %s",
FF_BATTERY_ANDROID_STATS_SERVICE, error);
} else {
fillTimeRemaining(&binder, stats.handle, timeRemainingTransaction, results);
}
// `BatteryStatus` has five values and only two of them say something the powered bits do not, so
// the trace spells the mapping out: anything that is not 2 or 3 leaves the bits clear, which is
// what UNKNOWN (1), NOT_CHARGING (4) and FULL (5) all come to.
// the trace spells the mapping out: anything that is not 2 or 3 leaves the charging / discharging
// bits clear, which is what UNKNOWN (1), NOT_CHARGING (4) and FULL (5) all come to -- that a
// charger is attached is stated by the bits the plug_type property fills in, not by this one. The
// temperature is not repeated here: it was traced where it was read, and it is the field of the
// three that can be missing without anything being wrong.
FF_DEBUG("Reporting one battery: capacity %.0f%%, status %" PRIu64 " -> bits 0x%x "
"(2 charging, 3 discharging; 1/4/5 leave them clear). The registrar answers no "
"temperature and no technology, so both stay unset",
battery->capacity, status, (unsigned) battery->status);
"(2 charging, 3 discharging; 1/4/5 leave them clear), %d s remaining (-1 when there is no "
"estimate). The technology stays unset: it rides the sticky broadcast, which this route "
"does not read",
battery->capacity, status, (unsigned) battery->status, battery->timeRemaining);
return nullptr;
}
@@ -387,17 +780,32 @@ static const char* parseDumpsys(FFBatteryOptions* options, FFlist* results) {
ffParsePropLines(start, "technology: ", &battery->technology);
// The trace the binder route has and this one did not, so that a device reporting the wrong thing
// says which of the two routes answered. The temperature is left out: it is only read when `temp`
// asks for it, and the sentinel would print as a number.
FF_DEBUG("Reporting one battery from `/system/bin/dumpsys battery`: capacity %.0f%%, status bits 0x%x, "
"technology \"%s\"",
battery->capacity, (unsigned) battery->status, battery->technology.chars);
return nullptr;
}
const char* ffDetectBattery(FFBatteryOptions* options, FFlist* results) {
// `dumpsys battery` needs android.permission.DUMP, which only the shell UID and root hold: an app
// UID is answered with `Can't find service: battery` on stdout and a zero exit status, so forking
// it there costs a child process and cannot succeed. It is also the richer of the two routes --
// the registrar answers neither temperature nor technology, and the dump is what spells out the
// status enum -- so it is tried first wherever it is allowed to run, and the binder route covers
// everything else at ~0.3 ms. termux-api was dropped: it returns nothing on this device and can
// hang for minutes, the same reason its camera path was removed.
// it there costs a child process and cannot succeed. Where it is allowed to run it is still the
// richer of the two -- the technology and the critical capacity level are things only it has, now
// that the AC / USB / wireless bits come from a property -- so it goes first, and the binder route
// covers everything else at ~0.3 ms.
//
// What the dump does not have is the time estimate, and it returns before the binder route gets
// to ask for it: `timeRemaining` is therefore only filled for an app UID, where the binder route
// is the only one that runs. That is the same answer 2.69.0 gave (its dump route did not fill it
// either), so it is a gap rather than a regression, but it is a gap.
//
// termux-api is the third way to reach the same fields and is not used. It does answer, but it
// execs `am broadcast` and then reads a socket waiting for an app to write back, with no timeout
// of its own, and on the device this was measured on it hung on six of six runs, each killed at
// 30 seconds having produced nothing.
if (ffAndroidIsRootOrShell(instance.state.platform.uid)) {
const char* error = parseDumpsys(options, results);
if (error == nullptr) {
@@ -406,5 +814,5 @@ const char* ffDetectBattery(FFBatteryOptions* options, FFlist* results) {
FF_DEBUG("`/system/bin/dumpsys battery` failed: %s. Using the binder route instead", error);
}
return parseBinder(results);
return parseBinder(options, results);
}