diff --git a/Android.bp b/Android.bp index ed3ecd747194..c6f414a2ac8a 100644 --- a/Android.bp +++ b/Android.bp @@ -251,6 +251,7 @@ java_library { "vendor.lineage.livedisplay-V2.0-java", "vendor.lineage.livedisplay-V2.1-java", "vendor.lineage.touch-V1.0-java", + "vendor.lineage.health-V1-java", ], sdk_version: "core_platform", installable: false, diff --git a/core/java/com/android/internal/lineage/app/LineageContextConstants.java b/core/java/com/android/internal/lineage/app/LineageContextConstants.java index 5400ddf45ff2..f9e108e8e5ff 100644 --- a/core/java/com/android/internal/lineage/app/LineageContextConstants.java +++ b/core/java/com/android/internal/lineage/app/LineageContextConstants.java @@ -43,6 +43,17 @@ private LineageContextConstants() { */ public static final String LINEAGE_HARDWARE_SERVICE = "lineagehardware"; + /** + * Use with {@link android.content.Context#getSystemService} to retrieve a + * {@link lineageos.health.HealthInterface} to access the Health interface. + * + * @see android.content.Context#getSystemService + * @see lineageos.health.HealthInterface + * + * @hide + */ + public static final String LINEAGE_HEALTH_INTERFACE = "lineagehealth"; + /** * Manages display color adjustments * diff --git a/core/java/com/android/internal/lineage/health/HealthInterface.java b/core/java/com/android/internal/lineage/health/HealthInterface.java new file mode 100644 index 000000000000..d854e0036894 --- /dev/null +++ b/core/java/com/android/internal/lineage/health/HealthInterface.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2023 The LineageOS 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.internal.lineage.health; + +import android.content.Context; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.util.Log; + +import com.android.internal.lineage.app.LineageContextConstants; + +public class HealthInterface { + /** + * No config set. This value is invalid and does not have any effects + */ + public static final int MODE_NONE = 0; + + /** + * Automatic config + */ + public static final int MODE_AUTO = 1; + + /** + * Manual config mode + */ + public static final int MODE_MANUAL = 2; + + /** + * Limit config mode + */ + public static final int MODE_LIMIT = 3; + + private static final String TAG = "HealthInterface"; + private static IHealthInterface sService; + private static HealthInterface sInstance; + private Context mContext; + private HealthInterface(Context context) { + Context appContext = context.getApplicationContext(); + mContext = appContext == null ? context : appContext; + sService = getService(); + } + /** + * Get or create an instance of the {@link lineageos.health.HealthInterface} + * + * @param context Used to get the service + * @return {@link HealthInterface} + */ + public static synchronized HealthInterface getInstance(Context context) { + if (sInstance == null) { + sInstance = new HealthInterface(context); + } + return sInstance; + } + /** @hide **/ + public static IHealthInterface getService() { + if (sService != null) { + return sService; + } + IBinder b = ServiceManager.getService(LineageContextConstants.LINEAGE_HEALTH_INTERFACE); + sService = IHealthInterface.Stub.asInterface(b); + if (sService == null) { + Log.e(TAG, "null health service, SAD!"); + return null; + } + return sService; + } + + /** + * @return true if service is valid + */ + private boolean checkService() { + if (sService == null) { + Log.w(TAG, "not connected to LineageHardwareManagerService"); + return false; + } + return true; + } + + /** + * Returns whether charging control is supported + * + * @return true if charging control is supported + */ + public boolean isChargingControlSupported() { + try { + return checkService() && sService.isChargingControlSupported(); + } catch (RemoteException e) { + Log.e(TAG, e.getLocalizedMessage(), e); + } + + return false; + } + + /** + * Returns the charging control enabled status + * + * @return whether charging control has been enabled + */ + public boolean getEnabled() { + try { + return checkService() && sService.getChargingControlEnabled(); + } catch (RemoteException e) { + return false; + } + } + + /** + * Set charging control enable status + * + * @param enabled whether charging control should be enabled + * @return true if the enabled status was successfully set + */ + public boolean setEnabled(boolean enabled) { + try { + return checkService() && sService.setChargingControlEnabled(enabled); + } catch (RemoteException e) { + return false; + } + } + + /** + * Returns the current charging control mode + * + * @return id of the charging control mode + */ + public int getMode() { + try { + return checkService() ? sService.getChargingControlMode() : MODE_NONE; + } catch (RemoteException e) { + return MODE_NONE; + } + } + + /** + * Selects the new charging control mode + * + * @param mode the new charging control mode + * @return true if the mode was successfully set + */ + public boolean setMode(int mode) { + try { + return checkService() && sService.setChargingControlMode(mode); + } catch (RemoteException e) { + return false; + } + } + + /** + * Gets the charging control start time + * + * @return the seconds of the day of the start time + */ + public int getStartTime() { + try { + return checkService() ? sService.getChargingControlStartTime() : 0; + } catch (RemoteException e) { + return 0; + } + } + + /** + * Sets the charging control start time + * + * @param time the seconds of the day of the start time + * @return true if the start time was successfully set + */ + public boolean setStartTime(int time) { + try { + return checkService() && sService.setChargingControlStartTime(time); + } catch (RemoteException e) { + return false; + } + } + + /** + * Gets the charging control target time + * + * @return the seconds of the day of the target time + */ + public int getTargetTime() { + try { + return checkService() ? sService.getChargingControlTargetTime() : 0; + } catch (RemoteException e) { + return 0; + } + } + + /** + * Sets the charging control target time + * + * @param time the seconds of the day of the target time + * @return true if the target time was successfully set + */ + public boolean setTargetTime(int time) { + try { + return checkService() && sService.setChargingControlTargetTime(time); + } catch (RemoteException e) { + return false; + } + } + + /** + * Gets the charging control limit + * + * @return the charging control limit + */ + public int getLimit() { + try { + return checkService() ? sService.getChargingControlLimit() : 100; + } catch (RemoteException e) { + return 0; + } + } + + /** + * Sets the charging control limit + * + * @param limit the charging control limit + * @return true if the limit was successfully set + */ + public boolean setLimit(int limit) { + try { + return checkService() && sService.setChargingControlLimit(limit); + } catch (RemoteException e) { + return false; + } + } + + /** + * Resets the charging control setting to default + * + * @return true if the setting was successfully reset + */ + public boolean reset() { + try { + return checkService() && sService.resetChargingControl(); + } catch (RemoteException e) { + return false; + } + } + + /** + * Returns whether the device's battery control bypasses battery + * + * @return true if the charging control bypasses battery + */ + public boolean allowFineGrainedSettings() { + try { + return checkService() && sService.allowFineGrainedSettings(); + } catch (RemoteException e) { + return false; + } + } +} diff --git a/core/java/com/android/internal/lineage/health/IHealthInterface.aidl b/core/java/com/android/internal/lineage/health/IHealthInterface.aidl new file mode 100644 index 000000000000..c2e9759ce6f1 --- /dev/null +++ b/core/java/com/android/internal/lineage/health/IHealthInterface.aidl @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023 The LineageOS 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.internal.lineage.health; + +/** @hide */ +interface IHealthInterface { + boolean isChargingControlSupported(); + + boolean getChargingControlEnabled(); + boolean setChargingControlEnabled(boolean enabled); + + int getChargingControlMode(); + boolean setChargingControlMode(int mode); + + int getChargingControlStartTime(); + boolean setChargingControlStartTime(int time); + + int getChargingControlTargetTime(); + boolean setChargingControlTargetTime(int time); + + int getChargingControlLimit(); + boolean setChargingControlLimit(int limit); + + boolean resetChargingControl(); + boolean allowFineGrainedSettings(); +} diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index 7e51cc510a2c..ca50241341b7 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -6611,6 +6611,9 @@ + + + diff --git a/core/res/res/drawable/ic_charging_control.xml b/core/res/res/drawable/ic_charging_control.xml new file mode 100644 index 000000000000..830853230a47 --- /dev/null +++ b/core/res/res/drawable/ic_charging_control.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/core/res/res/values/custom_config.xml b/core/res/res/values/custom_config.xml index 9b3fa91561a2..1ba78ec74f6b 100644 --- a/core/res/res/values/custom_config.xml +++ b/core/res/res/values/custom_config.xml @@ -133,4 +133,33 @@ + + + false + + 1 + + 79200 + + 21600 + + 80 + + 30 + + 10 + diff --git a/core/res/res/values/custom_strings.xml b/core/res/res/values/custom_strings.xml index fca35af6f9d3..c16d828b6abf 100644 --- a/core/res/res/values/custom_strings.xml +++ b/core/res/res/values/custom_strings.xml @@ -61,4 +61,13 @@ 1.\u0020 2.\u0020 + + Charging control + Charging control + Cancel + Battery will be charged to %1$d%% + Battery is charged to %1$d%% + Battery will be fully charged at %1$s + Battery is charged + diff --git a/core/res/res/values/custom_symbols.xml b/core/res/res/values/custom_symbols.xml index 534369fec244..6d5dd58af436 100644 --- a/core/res/res/values/custom_symbols.xml +++ b/core/res/res/values/custom_symbols.xml @@ -160,4 +160,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/services/core/java/com/android/server/lineage/health/ChargingControlController.java b/services/core/java/com/android/server/lineage/health/ChargingControlController.java new file mode 100644 index 000000000000..918eaac1cb51 --- /dev/null +++ b/services/core/java/com/android/server/lineage/health/ChargingControlController.java @@ -0,0 +1,873 @@ +/* + * Copyright (C) 2023 The LineageOS 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.lineage.health; + +import static java.time.format.FormatStyle.SHORT; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.BatteryManager; +import android.os.BatteryStatsManager; +import android.os.BatteryUsageStats; +import android.os.Handler; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.text.format.DateUtils; +import android.util.Log; + +import com.android.internal.R; + +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Calendar; + +import android.provider.Settings; + +import vendor.lineage.health.ChargingControlSupportedMode; +import vendor.lineage.health.IChargingControl; + +import static com.android.internal.lineage.health.HealthInterface.MODE_NONE; +import static com.android.internal.lineage.health.HealthInterface.MODE_AUTO; +import static com.android.internal.lineage.health.HealthInterface.MODE_MANUAL; +import static com.android.internal.lineage.health.HealthInterface.MODE_LIMIT; + +public class ChargingControlController extends LineageHealthFeature { + private final IChargingControl mChargingControl; + private final ContentResolver mContentResolver; + private final ChargingControlNotification mChargingNotification; + private LineageHealthBatteryBroadcastReceiver mBattReceiver; + + // Defaults + private final boolean mDefaultEnabled; + private final int mDefaultMode; + private final int mDefaultLimit; + private final int mDefaultStartTime; + private final int mDefaultTargetTime; + + // User configs + private boolean mConfigEnabled = false; + private int mConfigMode = MODE_NONE; + private int mConfigLimit = 100; + private int mConfigStartTime = 0; + private int mConfigTargetTime = 0; + + // Settings uris + private final Uri MODE_URI = Settings.System.getUriFor( + Settings.System.CHARGING_CONTROL_MODE); + private final Uri LIMIT_URI = Settings.System.getUriFor( + Settings.System.CHARGING_CONTROL_LIMIT); + private final Uri ENABLED_URI = Settings.System.getUriFor( + Settings.System.CHARGING_CONTROL_ENABLED); + private final Uri START_TIME_URI = Settings.System.getUriFor( + Settings.System.CHARGING_CONTROL_START_TIME); + private final Uri TARGET_TIME_URI = Settings.System.getUriFor( + Settings.System.CHARGING_CONTROL_TARGET_TIME); + + // Internal state + private float mBatteryPct = 0; + private boolean mIsPowerConnected = false; + private int mChargingStopReason = 0; + private long mEstimatedFullTime = 0; + private long mSavedAlarmTime = 0; + private long mSavedTargetTime = 0; + private boolean mIsControlCancelledOnce = false; + private final boolean mIsChargingToggleSupported; + private final boolean mIsChargingBypassSupported; + private final boolean mIsChargingDeadlineSupported; + private final int mChargingTimeMargin; + private final int mChargingLimitMargin; + + private static final DateTimeFormatter mFormatter = DateTimeFormatter.ofLocalizedTime(SHORT); + private static final SimpleDateFormat mDateFormatter = new SimpleDateFormat("hh:mm:ss a"); + + // Only when the battery level is above this limit will the charging control be activated. + private static int CHARGE_CTRL_MIN_LEVEL = 80; + private static final String INTENT_PARTS = + "com.android.settings.lineage.health.CHARGING_CONTROL_SETTINGS"; + + private static class ChargingStopReason { + private static int BIT(int shift) { + return 1 << shift; + } + + /** + * No stop charging + */ + public static final int NONE = 0; + + /** + * The charging stopped because it reaches limit + */ + public static final int REACH_LIMIT = BIT(0); + + /** + * The charging stopped because the battery level is decent, and we are waiting to resume + * charging when the time approaches the target time. + */ + public static final int WAITING = BIT(1); + } + + public ChargingControlController(Context context, Handler handler) { + super(context, handler); + + mContentResolver = mContext.getContentResolver(); + mChargingControl = IChargingControl.Stub.asInterface( + ServiceManager.getService(IChargingControl.DESCRIPTOR + "/default")); + + if (mChargingControl == null) { + Log.i(TAG, "Lineage Health HAL not found"); + } + + mChargingNotification = new ChargingControlNotification(context); + + mChargingTimeMargin = mContext.getResources().getInteger( + R.integer.config_chargingControlTimeMargin) * 60 * 1000; + mChargingLimitMargin = mContext.getResources().getInteger( + R.integer.config_chargingControlBatteryRechargeMargin); + + mDefaultEnabled = mContext.getResources().getBoolean( + R.bool.config_chargingControlEnabled); + mDefaultMode = mContext.getResources().getInteger( + R.integer.config_defaultChargingControlMode); + mDefaultStartTime = mContext.getResources().getInteger( + R.integer.config_defaultChargingControlStartTime); + mDefaultTargetTime = mContext.getResources().getInteger( + R.integer.config_defaultChargingControlTargetTime); + mDefaultLimit = mContext.getResources().getInteger( + R.integer.config_defaultChargingControlLimit); + + mIsChargingToggleSupported = isChargingModeSupported(ChargingControlSupportedMode.TOGGLE); + mIsChargingBypassSupported = isChargingModeSupported(ChargingControlSupportedMode.BYPASS); + mIsChargingDeadlineSupported = isChargingModeSupported( + ChargingControlSupportedMode.DEADLINE); + } + + @Override + public boolean isSupported() { + return mChargingControl != null; + } + + public boolean isEnabled() { + return mConfigEnabled; + } + + public boolean setEnabled(boolean enabled) { + putBoolean(Settings.System.CHARGING_CONTROL_ENABLED, enabled); + return true; + } + + public int getMode() { + return mConfigMode; + } + + public boolean setMode(int mode) { + if (mode < MODE_NONE || mode > MODE_LIMIT) { + return false; + } + + putInt(Settings.System.CHARGING_CONTROL_MODE, mode); + return true; + } + + public int getStartTime() { + return mConfigStartTime; + } + + public boolean setStartTime(int time) { + if (time < 0 || time > 24 * 60 * 60) { + return false; + } + + putInt(Settings.System.CHARGING_CONTROL_START_TIME, time); + return true; + } + + public int getTargetTime() { + return mConfigTargetTime; + } + + public boolean setTargetTime(int time) { + if (time < 0 || time > 24 * 60 * 60) { + return false; + } + + putInt(Settings.System.CHARGING_CONTROL_TARGET_TIME, time); + return true; + } + + public int getLimit() { + return mConfigLimit; + } + + public boolean setLimit(int limit) { + if (limit < 0 || limit > 100) { + return false; + } + + putInt(Settings.System.CHARGING_CONTROL_LIMIT, limit); + return true; + } + + public boolean reset() { + return setEnabled(mDefaultEnabled) && setMode(mDefaultMode) && setLimit(mDefaultLimit) + && setStartTime(mDefaultStartTime) && setTargetTime(mDefaultTargetTime); + } + + public boolean isChargingModeSupported(int mode) { + try { + return (mChargingControl.getSupportedMode() & mode) != 0; + } catch (RemoteException e) { + throw new RuntimeException(e); + } + } + + @Override + public void onStart() { + if (mChargingControl == null) { + return; + } + + // Register setting observer + registerSettings(MODE_URI, LIMIT_URI, ENABLED_URI, START_TIME_URI, TARGET_TIME_URI); + + // For devices that do not support bypass, we can only always listen to battery change + // because we can't distinguish between "unplugged" and "plugged in but not charging". + if (mIsChargingToggleSupported && !mIsChargingBypassSupported) { + mIsPowerConnected = true; + onPowerStatus(true); + handleSettingChange(); + return; + } + + // Start monitor battery status when power connected + IntentFilter connectedFilter = new IntentFilter(Intent.ACTION_POWER_CONNECTED); + mContext.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Power connected, start monitoring battery"); + mIsPowerConnected = true; + onPowerStatus(true); + } + }, connectedFilter); + + // Stop monitor battery status when power disconnected + IntentFilter disconnectedFilter = new IntentFilter(Intent.ACTION_POWER_DISCONNECTED); + mContext.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Power disconnected, stop monitoring battery"); + mIsPowerConnected = false; + onPowerStatus(false); + } + }, disconnectedFilter); + + // Initial monitor + IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = mContext.registerReceiver(null, ifilter); + mIsPowerConnected = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) != 0; + if (mIsPowerConnected) { + onPowerConnected(); + } + + // Restore settings + handleSettingChange(); + } + + private void resetInternalState() { + mSavedAlarmTime = 0; + mSavedTargetTime = 0; + mEstimatedFullTime = 0; + mChargingStopReason = 0; + mIsControlCancelledOnce = false; + mChargingNotification.cancel(); + } + + private void onPowerConnected() { + if (mBattReceiver == null) { + mBattReceiver = new LineageHealthBatteryBroadcastReceiver(); + } + IntentFilter battFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + mContext.registerReceiver(mBattReceiver, battFilter); + } + + private void onPowerDisconnected() { + if (mBattReceiver != null) { + mContext.unregisterReceiver(mBattReceiver); + } + + // On disconnected, reset internal state + resetInternalState(); + } + + private void onPowerStatus(boolean enable) { + if (enable) { + onPowerConnected(); + } else { + onPowerDisconnected(); + } + + updateChargeControl(); + } + + private void updateChargingReasonBitmask(int flag, boolean set) { + if (set) { + mChargingStopReason |= flag; + } else { + mChargingStopReason &= ~flag; + } + } + + private boolean isChargingReasonSet(int flag) { + return (mChargingStopReason & flag) != 0; + } + + private ChargeTime getChargeTime() { + // Get duration to target full time + final long currentTime = System.currentTimeMillis(); + Log.i(TAG, "Current time is " + msToString(currentTime)); + long targetTime = 0, startTime = currentTime; + if (mConfigMode == MODE_AUTO) { + // Use alarm as the target time. Maybe someday we can use a model. + AlarmManager m = mContext.getSystemService(AlarmManager.class); + if (m == null) { + Log.e(TAG, "Failed to get alarm service!"); + mChargingNotification.cancel(); + return null; + } + AlarmManager.AlarmClockInfo alarmClockInfo = m.getNextAlarmClock(); + if (alarmClockInfo == null) { + // We didn't find an alarm. Clear waiting flags because we can't predict anyway + mChargingNotification.cancel(); + return null; + } + targetTime = alarmClockInfo.getTriggerTime(); + } else if (mConfigMode == MODE_MANUAL) { + // User manually controlled time + startTime = getTimeMillisFromSecondOfDay(mConfigStartTime); + targetTime = getTimeMillisFromSecondOfDay(mConfigTargetTime); + + if (startTime > targetTime) { + if (currentTime > targetTime) { + targetTime += DateUtils.DAY_IN_MILLIS; + } else { + startTime -= DateUtils.DAY_IN_MILLIS; + } + } + } else { + Log.e(TAG, "invalid charging control mode " + mConfigMode); + return null; + } + + return new ChargeTime(startTime, targetTime); + } + + private void updateChargeControl() { + if (mIsChargingToggleSupported) { + updateChargeToggle(); + } else if (mIsChargingDeadlineSupported) { + updateChargeDeadline(); + } + } + + private boolean shouldSetLimitFlag() { + if (mConfigMode != MODE_LIMIT) { + return false; + } + + if (!mIsChargingBypassSupported + && isChargingReasonSet(ChargingStopReason.REACH_LIMIT)) { + return mBatteryPct >= mConfigLimit - mChargingLimitMargin; + } + + if (mBatteryPct >= mConfigLimit) { + mChargingNotification.post(null, true); + return true; + } else { + mChargingNotification.post(null, false); + return false; + } + } + + private boolean shouldSetWaitFlag() { + if (mConfigMode != MODE_AUTO && mConfigMode != MODE_MANUAL) { + return false; + } + + // Now it is time to see whether charging should be stopped. We make decisions in the + // following manner: + // + // 1. If STOP_REASON_WAITING is set, compare the remaining time with the saved estimated + // full time. Resume charging the remain time <= saved estimated time + // 2. If the system estimated remaining time already exceeds the target full time, continue + // 3. Otherwise, stop charging, save the estimated time, set stop reason to + // STOP_REASON_WAITING. + + final ChargeTime t = getChargeTime(); + + if (t == null) { + mChargingNotification.cancel(); + return false; + } + + final long targetTime = t.getTargetTime(); + final long startTime = t.getStartTime(); + final long currentTime = System.currentTimeMillis(); + + Log.i(TAG, "Got target time " + msToString(targetTime) + ", start time " + + msToString(startTime) + ", current time " + msToString(currentTime)); + + if (mConfigMode == MODE_AUTO) { + if (mSavedAlarmTime != targetTime) { + mChargingNotification.cancel(); + + if (mSavedAlarmTime != 0 && mSavedAlarmTime < currentTime) { + Log.i(TAG, "Not fully charged when alarm goes off, continue charging."); + mIsControlCancelledOnce = true; + return false; + } + + Log.i(TAG, "User changed alarm, reconstruct notification"); + mSavedAlarmTime = targetTime; + } + + // Don't activate if we are more than 9 hrs away from the target alarm + if (targetTime - currentTime >= 9 * 60 * 60 * 1000) { + mChargingNotification.cancel(); + return false; + } + } else if (mConfigMode == MODE_MANUAL) { + if (startTime > currentTime) { + // Not yet entering user configured time frame + mChargingNotification.cancel(); + return false; + } + } + + if (mBatteryPct == 100) { + mChargingNotification.post(targetTime, true); + return true; + } + + // Now we have the target time and current time, we can post a notification stating that + // the system will be charged by targetTime. + mChargingNotification.post(targetTime, false); + + // If current battery level is less than the fast charge limit, don't set this flag + if (mBatteryPct < CHARGE_CTRL_MIN_LEVEL) { + return false; + } + + long deltaTime = targetTime - currentTime; + Log.i(TAG, "Current time to target: " + msToString(deltaTime)); + + if (isChargingReasonSet(ChargingStopReason.WAITING)) { + Log.i(TAG, "Current saved estimation to full: " + msToString(mEstimatedFullTime)); + if (deltaTime <= mEstimatedFullTime) { + Log.i(TAG, "Unset waiting flag"); + return false; + } + return true; + } + + final BatteryUsageStats batteryUsageStats = mContext.getSystemService( + BatteryStatsManager.class).getBatteryUsageStats(); + if (batteryUsageStats == null) { + Log.e(TAG, "Failed to get battery usage stats"); + return false; + } + long remaining = batteryUsageStats.getChargeTimeRemainingMs(); + if (remaining == -1) { + Log.i(TAG, "not enough data for prediction for now, waiting for more data"); + return false; + } + + // Add margin here + remaining += mChargingTimeMargin; + Log.i(TAG, "Current estimated time to full: " + msToString(remaining)); + if (deltaTime > remaining) { + Log.i(TAG, "Stop charging and wait, saving remaining time"); + mEstimatedFullTime = remaining; + return true; + } + + return false; + } + + private void updateChargingStopReason() { + if (mIsControlCancelledOnce) { + mChargingStopReason = ChargingStopReason.NONE; + return; + } + + if (!mConfigEnabled) { + mChargingStopReason = ChargingStopReason.NONE; + return; + } + + if (!mIsPowerConnected) { + mChargingStopReason = ChargingStopReason.NONE; + return; + } + + updateChargingReasonBitmask(ChargingStopReason.REACH_LIMIT, shouldSetLimitFlag()); + updateChargingReasonBitmask(ChargingStopReason.WAITING, shouldSetWaitFlag()); + } + + private void updateChargeToggle() { + updateChargingStopReason(); + + Log.i(TAG, "Current mChargingStopReason: " + mChargingStopReason); + boolean isChargingEnabled = false; + try { + isChargingEnabled = mChargingControl.getChargingEnabled(); + } catch (IllegalStateException | RemoteException | UnsupportedOperationException e) { + Log.e(TAG, "Failed to get charging enabled status!"); + } + if (isChargingEnabled != (mChargingStopReason == 0)) { + try { + mChargingControl.setChargingEnabled(!isChargingEnabled); + } catch (IllegalStateException | RemoteException | UnsupportedOperationException e) { + Log.e(TAG, "Failed to set charging status"); + } + } + } + + private void updateChargeDeadline() { + if (!mIsPowerConnected) { + return; + } + + final ChargeTime t = getChargeTime(); + if (t != null && t.getTargetTime() == mSavedTargetTime) { + return; + } + + long deadline = 0; + if (t == null || mIsControlCancelledOnce) { + deadline = -1; + } else { + mSavedTargetTime = t.getTargetTime(); + final long targetTime = t.getTargetTime(); + final long currentTime = System.currentTimeMillis(); + deadline = (targetTime - currentTime) / 1000; + } + + try { + mChargingControl.setChargingDeadline(deadline); + } catch (IllegalStateException | RemoteException | UnsupportedOperationException e) { + Log.e(TAG, "Failed to set charge deadline"); + } + } + + private String msToString(long ms) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(ms); + return mDateFormatter.format(calendar.getTime()); + } + + /** + * Convert the seconds of the day to UTC milliseconds from epoch. + * + * @param time seconds of the day + * @return UTC milliseconds from epoch + */ + private long getTimeMillisFromSecondOfDay(int time) { + ZoneId utcZone = ZoneOffset.UTC; + LocalDate currentDate = LocalDate.now(); + LocalTime timeOfDay = LocalTime.ofSecondOfDay(time); + + ZonedDateTime zonedDateTime = ZonedDateTime.of(currentDate, timeOfDay, + ZoneId.systemDefault()) + .withZoneSameInstant(utcZone); + return zonedDateTime.toInstant().toEpochMilli(); + } + + private LocalTime getLocalTimeFromEpochMilli(long time) { + return Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).toLocalTime(); + } + + private void handleSettingChange() { + mConfigEnabled = Settings.System.getInt(mContentResolver, + Settings.System.CHARGING_CONTROL_ENABLED, 0) + != 0; + mConfigLimit = Settings.System.getInt(mContentResolver, + Settings.System.CHARGING_CONTROL_LIMIT, + mDefaultLimit); + mConfigMode = Settings.System.getInt(mContentResolver, + Settings.System.CHARGING_CONTROL_MODE, + mDefaultMode); + mConfigStartTime = Settings.System.getInt(mContentResolver, + Settings.System.CHARGING_CONTROL_START_TIME, + mDefaultStartTime); + mConfigTargetTime = Settings.System.getInt(mContentResolver, + Settings.System.CHARGING_CONTROL_TARGET_TIME, + mDefaultTargetTime); + + // Cancel notification, so that it can be updated later + mChargingNotification.cancel(); + + // Update based on those values + updateChargeControl(); + } + + + @Override + protected void onSettingsChanged(Uri uri) { + handleSettingChange(); + } + + @Override + public void dump(PrintWriter pw) { + pw.println(); + pw.println("ChargingControlController Configuration:"); + pw.println(" mConfigEnabled: " + mConfigEnabled); + pw.println(" mConfigMode: " + mConfigMode); + pw.println(" mConfigLimit: " + mConfigLimit); + pw.println(" mConfigStartTime: " + mConfigStartTime); + pw.println(" mConfigTargetTime: " + mConfigTargetTime); + pw.println(" mChargingTimeMargin: " + mChargingTimeMargin); + pw.println(); + pw.println("ChargingControlController State:"); + pw.println(" mBatteryPct: " + mBatteryPct); + pw.println(" mIsPowerConnected: " + mIsPowerConnected); + pw.println(" mChargingStopReason: " + mChargingStopReason); + pw.println(" mIsNotificationPosted: " + mChargingNotification.isPosted()); + pw.println(" mIsDoneNotification: " + mChargingNotification.isDoneNotification()); + pw.println(" mIsControlCancelledOnce: " + mIsControlCancelledOnce); + pw.println(" mSavedAlarmTime: " + msToString(mSavedAlarmTime)); + if (mIsChargingDeadlineSupported) { + pw.println(" mSavedTargetTime (Deadline): " + msToString(mSavedTargetTime)); + } + } + + /* Battery Broadcast Receiver */ + private class LineageHealthBatteryBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!mIsPowerConnected) { + return; + } + + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (level == -1 || scale == -1) { + return; + } + + mBatteryPct = level * 100 / (float) scale; + updateChargeControl(); + } + } + + /* Notification class */ + class ChargingControlNotification { + private final NotificationManager mNotificationManager; + private final Context mContext; + + private static final int CHARGING_CONTROL_NOTIFICATION_ID = 1000; + private static final String ACTION_CHARGING_CONTROL_CANCEL_ONCE = + "lineageos.platform.intent.action.CHARGING_CONTROL_CANCEL_ONCE"; + private static final String CHARGING_CONTROL_CHANNEL_ID = "LineageHealthChargingControl"; + + private boolean mIsDoneNotification = false; + private boolean mIsNotificationPosted = false; + + ChargingControlNotification(Context context) { + mContext = context; + + // Get notification manager + mNotificationManager = mContext.getSystemService(NotificationManager.class); + + // Register notification monitor + IntentFilter notificationFilter = new IntentFilter(ACTION_CHARGING_CONTROL_CANCEL_ONCE); + mContext.registerReceiver(new LineageHealthNotificationBroadcastReceiver(), + notificationFilter); + } + + public void post(Long targetTime, boolean done) { + if (mIsNotificationPosted && mIsDoneNotification == done) { + return; + } + + if (mIsNotificationPosted) { + cancel(); + } + + if (done) { + postChargingDoneNotification(targetTime); + } else { + postChargingControlNotification(targetTime); + } + + mIsNotificationPosted = true; + mIsDoneNotification = done; + } + + public void cancel() { + cancelChargingControlNotification(); + mIsNotificationPosted = false; + } + + public boolean isPosted() { + return mIsNotificationPosted; + } + + public boolean isDoneNotification() { + return mIsDoneNotification; + } + + private void handleNotificationIntent(Intent intent) { + if (intent.getAction().equals(ACTION_CHARGING_CONTROL_CANCEL_ONCE)) { + mIsControlCancelledOnce = true; + updateChargeControl(); + cancelChargingControlNotification(); + } + } + + private void postChargingControlNotification(Long targetTime) { + String title = mContext.getString(R.string.charging_control_notification_title); + String message; + if (targetTime != null) { + message = String.format( + mContext.getString(R.string.charging_control_notification_content_target), + getLocalTimeFromEpochMilli(targetTime).format(mFormatter)); + } else { + message = String.format( + mContext.getString(R.string.charging_control_notification_content_limit), + mConfigLimit); + } + + Intent mainIntent = new Intent(INTENT_PARTS); + mainIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent mainPendingIntent = PendingIntent.getActivity(mContext, 0, mainIntent, + PendingIntent.FLAG_IMMUTABLE); + + Intent cancelOnceIntent = new Intent(ACTION_CHARGING_CONTROL_CANCEL_ONCE); + PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(mContext, 0, + cancelOnceIntent, PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder notification = + new Notification.Builder(mContext, CHARGING_CONTROL_CHANNEL_ID) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(mainPendingIntent) + .setSmallIcon(R.drawable.ic_charging_control) + .setOngoing(true) + .addAction(R.drawable.ic_charging_control, + mContext.getString( + R.string.charging_control_notification_cancel_once), + cancelPendingIntent); + + createNotificationChannelIfNeeded(); + mNotificationManager.notify(CHARGING_CONTROL_NOTIFICATION_ID, notification.build()); + } + + private void postChargingDoneNotification(Long targetTime) { + cancelChargingControlNotification(); + + String title = mContext.getString(R.string.charging_control_notification_title); + String message; + if (targetTime != null) { + message = mContext.getString( + R.string.charging_control_notification_content_target_reached); + } else { + message = String.format( + mContext.getString( + R.string.charging_control_notification_content_limit_reached), + mConfigLimit); + } + + Intent mainIntent = new Intent(INTENT_PARTS); + mainIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + PendingIntent mainPendingIntent = PendingIntent.getActivity(mContext, 0, mainIntent, + PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder notification = new Notification.Builder(mContext, + CHARGING_CONTROL_CHANNEL_ID) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(mainPendingIntent) + .setSmallIcon(R.drawable.ic_charging_control) + .setOngoing(false); + + createNotificationChannelIfNeeded(); + mNotificationManager.notify(CHARGING_CONTROL_NOTIFICATION_ID, notification.build()); + } + + private void createNotificationChannelIfNeeded() { + String id = CHARGING_CONTROL_CHANNEL_ID; + NotificationChannel channel = mNotificationManager.getNotificationChannel(id); + if (channel != null) { + return; + } + + String name = mContext.getString(R.string.charging_control_notification_channel); + int importance = NotificationManager.IMPORTANCE_LOW; + NotificationChannel batteryHealthChannel = new NotificationChannel(id, name, + importance); + batteryHealthChannel.setBlockable(true); + mNotificationManager.createNotificationChannel(batteryHealthChannel); + } + + private void cancelChargingControlNotification() { + mNotificationManager.cancel(CHARGING_CONTROL_NOTIFICATION_ID); + } + + /* Notification Broadcast Receiver */ + private class LineageHealthNotificationBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + handleNotificationIntent(intent); + } + } + } + + /* A representation of start and target time */ + static final class ChargeTime { + private final long mStartTime; + private final long mTargetTime; + + ChargeTime(long startTime, long targetTime) { + mStartTime = startTime; + mTargetTime = targetTime; + } + + public long getStartTime() { + return mStartTime; + } + + public long getTargetTime() { + return mTargetTime; + } + } +} diff --git a/services/core/java/com/android/server/lineage/health/HealthInterfaceService.java b/services/core/java/com/android/server/lineage/health/HealthInterfaceService.java new file mode 100644 index 000000000000..86745d1d5928 --- /dev/null +++ b/services/core/java/com/android/server/lineage/health/HealthInterfaceService.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2023 The LineageOS 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.lineage.health; + +import android.Manifest; +import android.content.Context; +import android.os.Handler; +import android.os.IBinder; +import android.os.Process; +import android.util.Log; + +import com.android.server.ServiceThread; + +import com.android.server.SystemService; + +import com.android.internal.lineage.app.LineageContextConstants; +import com.android.internal.lineage.health.IHealthInterface; +import vendor.lineage.health.ChargingControlSupportedMode; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; + +public class HealthInterfaceService extends SystemService { + + private static final String TAG = "LineageHealth"; + private final Context mContext; + private final Handler mHandler; + private final ServiceThread mHandlerThread; + + private final List mFeatures = new ArrayList(); + + // Health features + private ChargingControlController mCCC; + + public HealthInterfaceService(Context context) { + super(context); + mContext = context; + + mHandlerThread = new ServiceThread(TAG, Process.THREAD_PRIORITY_DEFAULT, false); + mHandlerThread.start(); + mHandler = new Handler(mHandlerThread.getLooper()); + } + + @Override + public void onStart() { + mCCC = new ChargingControlController(mContext, mHandler); + if (mCCC.isSupported()) { + mFeatures.add(mCCC); + } + + if (!mFeatures.isEmpty()) { + publishBinderService(LineageContextConstants.LINEAGE_HEALTH_INTERFACE, mService); + } + } + + @Override + public void onBootPhase(int phase) { + if (phase != PHASE_BOOT_COMPLETED) { + return; + } + + // start and update all features + for (LineageHealthFeature feature : mFeatures) { + feature.start(); + } + } + + /* Service */ + private final IBinder mService = new IHealthInterface.Stub() { + @Override + public boolean isChargingControlSupported() { + return mCCC.isSupported(); + } + + @Override + public boolean getChargingControlEnabled() { + return mCCC.isEnabled(); + } + + @Override + public boolean setChargingControlEnabled(boolean enabled) { + return mCCC.setEnabled(enabled); + } + + @Override + public int getChargingControlMode() { + return mCCC.getMode(); + } + + @Override + public boolean setChargingControlMode(int mode) { + return mCCC.setMode(mode); + } + + @Override + public int getChargingControlStartTime() { + return mCCC.getStartTime(); + } + + @Override + public boolean setChargingControlStartTime(int startTime) { + return mCCC.setStartTime(startTime); + } + + @Override + public int getChargingControlTargetTime() { + return mCCC.getTargetTime(); + } + + @Override + public boolean setChargingControlTargetTime(int targetTime) { + return mCCC.setTargetTime(targetTime); + } + + @Override + public int getChargingControlLimit() { + return mCCC.getLimit(); + } + + @Override + public boolean setChargingControlLimit(int limit) { + return mCCC.setLimit(limit); + } + + @Override + public boolean resetChargingControl() { + return mCCC.reset(); + } + + @Override + public boolean allowFineGrainedSettings() { + // We allow fine-grained settings if allow toggle and bypass + return mCCC.isChargingModeSupported(ChargingControlSupportedMode.TOGGLE); + } + + @Override + public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { + mContext.enforceCallingOrSelfPermission(Manifest.permission.DUMP, TAG); + + pw.println(); + pw.println("LineageHealth Service State:"); + + for (LineageHealthFeature feature : mFeatures) { + feature.dump(pw); + } + } + }; +} diff --git a/services/core/java/com/android/server/lineage/health/LineageHealthFeature.java b/services/core/java/com/android/server/lineage/health/LineageHealthFeature.java new file mode 100644 index 000000000000..efc5df13132a --- /dev/null +++ b/services/core/java/com/android/server/lineage/health/LineageHealthFeature.java @@ -0,0 +1,33 @@ + +/* + * Copyright (C) 2023 The LineageOS 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.lineage.health; + +import android.content.Context; +import android.os.Handler; + +import com.android.server.lineage.LineageBaseFeature; + +public abstract class LineageHealthFeature extends LineageBaseFeature { + protected static final String TAG = "LineageHealth"; + + public LineageHealthFeature(Context context, Handler handler) { + super(context, handler); + } + + public abstract boolean isSupported(); +} diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java index 75b022293970..831302800f75 100644 --- a/services/java/com/android/server/SystemServer.java +++ b/services/java/com/android/server/SystemServer.java @@ -218,6 +218,7 @@ import com.android.server.wm.ActivityTaskManagerService; import com.android.server.wm.WindowManagerGlobalLock; import com.android.server.wm.WindowManagerService; +import com.android.server.lineage.health.HealthInterfaceService; import dalvik.system.VMRuntime; @@ -2556,6 +2557,9 @@ private void startOtherServices(@NonNull TimingsTraceAndSlog t) { t.traceBegin("StartLiveDisplayService"); mSystemServiceManager.startService(LiveDisplayService.class); t.traceEnd(); + t.traceBegin("StartHealthService"); + mSystemServiceManager.startService(HealthInterfaceService.class); + t.traceEnd(); } }