Create puller for batteryHealthData

Register the BATTERY_HEALTH puller and collect the data to populate the
corresponding atom.
All the operations are done through the class BatteryHealthPuller,
which, in addition to collecting all the health information, takes care
of reducing the domains of returned values otherwise to prevent the
identification of individual users.
In particular:
- the dates for when the battery is produced and first used are rounded
  from seconds precision to week precision (the day of the week is
  forced to Monday);
- the battery serial is hashed and then reduced to 8 bits with a 0xFF
  bitwise AND, so that the domain space is only 256 possibilities.

Flag: EXEMPT statsd atoms/metrics changes
Bug: 343691107
Test: statsd_testdrive 10220
Test: CtsStatsdAtomHostTestCases:android.cts.statsdatom.hardware.health
Change-Id: I9cf766d39b73e0484a93326a4550c303594cf7e7
Depends-On: I5412268d2904fe8afc6195dc1fab2bb782ffba44
Signed-off-by: Alessio Balsini <balsini@google.com>
diff --git a/services/core/java/com/android/server/stats/pull/BatteryHealthUtility.java b/services/core/java/com/android/server/stats/pull/BatteryHealthUtility.java
new file mode 100644
index 0000000..e0768fe
--- /dev/null
+++ b/services/core/java/com/android/server/stats/pull/BatteryHealthUtility.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.stats.pull;
+
+import android.util.StatsEvent;
+
+import com.android.internal.util.FrameworkStatsLog;
+
+import java.math.BigInteger;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+
+/**
+ * Utility class to redact Battery Health data from HealthServiceWrapper
+ *
+ * @hide
+ */
+public abstract class BatteryHealthUtility {
+    /**
+     * Create a StatsEvent corresponding to the Battery Health data, the fields
+     * of which are redacted to preserve users' privacy.
+     * The redaction consists in truncating the timestamps to the Monday of the
+     * corresponding week, and reducing the battery serial into the last byte
+     * of its MD5.
+     */
+    public static StatsEvent buildStatsEvent(int atomTag,
+            android.hardware.health.BatteryHealthData data, int chargeStatus, int chargePolicy)
+            throws NoSuchAlgorithmException {
+        int manufacturingDate = secondsToWeekYYYYMMDD(data.batteryManufacturingDateSeconds);
+        int firstUsageDate = secondsToWeekYYYYMMDD(data.batteryFirstUsageSeconds);
+        long stateOfHealth = data.batteryStateOfHealth;
+        int partStatus = data.batteryPartStatus;
+        int serialHashTruncated = stringToIntHash(data.batterySerialNumber) & 0xFF; // Last byte
+
+        return FrameworkStatsLog.buildStatsEvent(atomTag, manufacturingDate, firstUsageDate,
+                (int) stateOfHealth, serialHashTruncated, partStatus, chargeStatus, chargePolicy);
+    }
+
+    private static int secondsToWeekYYYYMMDD(long seconds) {
+        Calendar calendar = Calendar.getInstance();
+        long millis = seconds * 1000L;
+
+        calendar.setTimeInMillis(millis);
+
+        // Truncate all date information, up to week, which is rounded to
+        // MONDAY
+        calendar.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY);
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd", Locale.US);
+
+        String formattedDate = sdf.format(calendar.getTime());
+
+        return Integer.parseInt(formattedDate);
+    }
+
+    private static int stringToIntHash(String data) throws NoSuchAlgorithmException {
+        if (data == null || data.isEmpty()) {
+            return 0;
+        }
+
+        MessageDigest digest = MessageDigest.getInstance("MD5");
+        byte[] hashBytes = digest.digest(data.getBytes());
+
+        // Convert to integer (simplest way, but potential for loss of information)
+        BigInteger bigInt = new BigInteger(1, hashBytes);
+        return bigInt.intValue();
+    }
+}
diff --git a/services/core/java/com/android/server/stats/pull/StatsPullAtomService.java b/services/core/java/com/android/server/stats/pull/StatsPullAtomService.java
index c1b825b..0041d39 100644
--- a/services/core/java/com/android/server/stats/pull/StatsPullAtomService.java
+++ b/services/core/java/com/android/server/stats/pull/StatsPullAtomService.java
@@ -119,6 +119,8 @@
 import android.net.NetworkTemplate;
 import android.net.wifi.WifiManager;
 import android.os.AsyncTask;
+import android.os.BatteryManager;
+import android.os.BatteryProperty;
 import android.os.BatteryStats;
 import android.os.BatteryStatsInternal;
 import android.os.BatteryStatsManager;
@@ -243,6 +245,7 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.security.NoSuchAlgorithmException;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
@@ -769,6 +772,7 @@
                     case FrameworkStatsLog.FULL_BATTERY_CAPACITY:
                     case FrameworkStatsLog.BATTERY_VOLTAGE:
                     case FrameworkStatsLog.BATTERY_CYCLE_COUNT:
+                    case FrameworkStatsLog.BATTERY_HEALTH:
                         synchronized (mHealthHalLock) {
                             return pullHealthHalLocked(atomTag, data);
                         }
@@ -999,6 +1003,7 @@
         registerFullBatteryCapacity();
         registerBatteryVoltage();
         registerBatteryCycleCount();
+        registerBatteryHealth();
         registerSettingsStats();
         registerInstalledIncrementalPackages();
         registerKeystoreStorageStats();
@@ -4365,7 +4370,15 @@
         );
     }
 
-    int pullHealthHalLocked(int atomTag, List<StatsEvent> pulledData) {
+    private void registerBatteryHealth() {
+        int tagId = FrameworkStatsLog.BATTERY_HEALTH;
+        mStatsManager.setPullAtomCallback(tagId,
+                null, // use default PullAtomMetadata values
+                DIRECT_EXECUTOR, mStatsCallbackImpl);
+    }
+
+    @GuardedBy("mHealthHalLock")
+    private int pullHealthHalLocked(int atomTag, List<StatsEvent> pulledData) {
         if (mHealthService == null) {
             return StatsManager.PULL_SKIP;
         }
@@ -4396,6 +4409,44 @@
             case FrameworkStatsLog.BATTERY_CYCLE_COUNT:
                 pulledValue = healthInfo.batteryCycleCount;
                 break;
+            case FrameworkStatsLog.BATTERY_HEALTH:
+                android.hardware.health.BatteryHealthData bhd;
+                try {
+                    bhd = mHealthService.getBatteryHealthData();
+                } catch (RemoteException | IllegalStateException e) {
+                    return StatsManager.PULL_SKIP;
+                }
+                if (bhd == null) {
+                    return StatsManager.PULL_SKIP;
+                }
+
+                StatsEvent batteryHealthEvent;
+                try {
+                    BatteryProperty chargeStatusProperty = new BatteryProperty();
+                    BatteryProperty chargePolicyProperty = new BatteryProperty();
+
+                    if (0 > mHealthService.getProperty(
+                                BatteryManager.BATTERY_PROPERTY_STATUS, chargeStatusProperty)) {
+                        return StatsManager.PULL_SKIP;
+                    }
+                    if (0 > mHealthService.getProperty(
+                                BatteryManager.BATTERY_PROPERTY_CHARGING_POLICY,
+                                chargePolicyProperty)) {
+                        return StatsManager.PULL_SKIP;
+                    }
+                    int chargeStatus = (int) chargeStatusProperty.getLong();
+                    int chargePolicy = (int) chargePolicyProperty.getLong();
+                    batteryHealthEvent = BatteryHealthUtility.buildStatsEvent(
+                            atomTag, bhd, chargeStatus, chargePolicy);
+                    pulledData.add(batteryHealthEvent);
+
+                    return StatsManager.PULL_SUCCESS;
+                } catch (RemoteException | IllegalStateException e) {
+                    Slog.e(TAG, "Failed to add pulled data", e);
+                } catch (NoSuchAlgorithmException e) {
+                    Slog.e(TAG, "Could not find message digest algorithm", e);
+                }
+                return StatsManager.PULL_SKIP;
             default:
                 return StatsManager.PULL_SKIP;
         }