sdk: Implement Lineage health service

Change-Id: I772ccf6d323c24d681aa8468bf4318c7b73bd3f5
diff --git a/Android.bp b/Android.bp
index 7d83217..4c25331 100644
--- a/Android.bp
+++ b/Android.bp
@@ -47,11 +47,21 @@
     path: "src",
 }
 
+// The OmniRom Platform Framework Library
+// ============================================================
+
+omnirom_sdk_LOCAL_STATIC_JAVA_LIBRARIES = [
+    "vendor.lineage.health-V1-java",
+]
+
 // Used by services
 java_library {
     name: "omnirom.internal",
+    static_libs: omnirom_sdk_LOCAL_STATIC_JAVA_LIBRARIES,
     srcs: [
         "src/org/omnirom/omnilib/utils/*.java",
+        "sdk/**/*.java",
+        "sdk/**/I*.aidl",
 
          // For the generated R.java and Manifest.java
         ":omnirom-res{.aapt.srcjar}",
@@ -62,9 +72,12 @@
     name: "OmniLib",
     installable: true,
     sdk_version: "core_platform",
+    static_libs: omnirom_sdk_LOCAL_STATIC_JAVA_LIBRARIES,
 
     srcs: [
         "src/**/*.java",
+        "sdk/**/*.java",
+        "sdk/**/I*.aidl",
 
         // For the generated R.java and Manifest.java
         ":omnirom-res{.aapt.srcjar}",
@@ -75,4 +88,9 @@
         "OmniPreference",
         "services",
     ],
+
+    // Include aidl files from omnirom.app namespace as well as internal src aidl files
+    aidl: {
+        local_include_dirs: ["sdk/src"],
+    },
 }
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 1f24002..4392106 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -24,6 +24,10 @@
     coreApp="true"
     android:sharedUserLabel="@string/omni_system_label">
 
+    <uses-permission android:name="android.permission.WRITE_SETTINGS" />
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
     <protected-broadcast android:name="com.android.systemui.ACTION_DISMISS_KEYGUARD" />
+    <protected-broadcast android:name="omnirom.platform.intent.action.CHARGING_CONTROL_CANCEL_ONCE" />
 
 </manifest>
diff --git a/res/drawable/ic_charging_control.xml b/res/drawable/ic_charging_control.xml
new file mode 100644
index 0000000..bc68ed5
--- /dev/null
+++ b/res/drawable/ic_charging_control.xml
@@ -0,0 +1,16 @@
+<!--
+     SPDX-FileCopyrightText: 2018 The Android Open Source Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M16.2,22.5H7.8c-1.3,0 -2.3,-1 -2.3,-2.3V5.8c0,-1.3 1,-2.3 2.3,-2.3h0.7v-2h7v2h0.7c1.3,0 2.3,1.1 2.3,2.3v14.3C18.5,21.5 17.5,22.5 16.2,22.5zM7.8,5.5c-0.2,0 -0.3,0.2 -0.3,0.3v14.3c0,0.2 0.2,0.3 0.3,0.3h8.3c0.2,0 0.3,-0.1 0.3,-0.3V5.8c0,-0.2 -0.1,-0.3 -0.3,-0.3h-2.7v-2h-3v2H7.8z"/>
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M11.17,18.42v-4.58H9.5l3.33,-6.25v4.58h1.67L11.17,18.42z"/>
+</vector>
diff --git a/res/values/config.xml b/res/values/config.xml
index f1e549f..0bb0d55 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -24,8 +24,38 @@
     <!-- Defines external services to be started by the OmniRomSystemServer at boot. The service itself
          should publish as a binder services in its onStart -->
     <string-array name="config_externalOmniRomServices">
+            <item>org.omnirom.omnilib.internal.health.HealthInterfaceService</item>
     </string-array>
 
     <!-- The LineageSystemServer class that is invoked from Android's SystemServer -->
     <string name="config_externalSystemServer" translatable="false">org.omnirom.omnilib.internal.OmniRomSystemServer</string>
+
+    <!-- Whether charging control should be enabled by default -->
+    <bool name="config_chargingControlEnabled">false</bool>
+    <!-- Default charging control mode.
+         This integer should be set to:
+         1 - auto - Use the alarm to calculate the time range when to activate charging control
+         2 - custom - Use time range when the device is usually charging for hours
+         3 - limit - Just limit charging -->
+    <integer name="config_defaultChargingControlMode">1</integer>
+    <!-- Default time when charging control is activated.
+         Represented as seconds from midnight (e.g. 79200 == 10pm). -->
+    <integer name="config_defaultChargingControlStartTime">79200</integer>
+    <!-- Default time when battery will be fully charged.
+         Represented as seconds from midnight (e.g. 21600 == 6am). -->
+    <integer name="config_defaultChargingControlTargetTime">21600</integer>
+    <!-- Default charging limit. -->
+    <integer name="config_defaultChargingControlLimit">80</integer>
+    <!-- Considering the fact that the system might have an incorrect estimation of the time to
+         full. Set a time margin to make the device fully charged before the target time arrives.
+         The unit is minutes and the default value is 30 minutes. If you find that it is not enough
+         to make the device to be fully charged at the target time, increase the value
+    -->
+    <integer name="config_chargingControlTimeMargin">30</integer>
+    <!-- For a device that cannot bypass battery when charging stops (that is, the battery current
+         is 0mA when charging stops), the battery will gradually discharge. So we need to make it
+         recharge when the battery level is lower than a threshold. Set this so that the device
+         will be charged between (limit - val) and limit. -->
+    <integer name="config_chargingControlBatteryRechargeMargin">10</integer>
+
 </resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 6fe9232..b0c80c7 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -36,4 +36,13 @@
     <!-- advanced reboot to fastboot. -->
     <string name="reboot_to_fastboot_title">Reboot fastboot</string>
     <string name="reboot_to_fastboot_message">Rebooting to fastboot\u2026</string>
+
+    <!-- Health interface -->
+    <string name="charging_control_notification_channel">Charging control</string>
+    <string name="charging_control_notification_title">Charging control</string>
+    <string name="charging_control_notification_cancel_once">Cancel</string>
+    <string name="charging_control_notification_content_limit">Battery will be charged to %1$d%%</string>
+    <string name="charging_control_notification_content_limit_reached">Battery is charged to %1$d%%</string>
+    <string name="charging_control_notification_content_target">Battery will be fully charged at %1$s</string>
+    <string name="charging_control_notification_content_target_reached">Battery is charged</string>
 </resources>
diff --git a/res/values/symbols.xml b/res/values/symbols.xml
index 00e8cc7..201401d 100644
--- a/res/values/symbols.xml
+++ b/res/values/symbols.xml
@@ -50,4 +50,21 @@
     <!-- OmniRom system server -->
     <java-symbol type="string" name="config_externalSystemServer" />
 
+    <!-- Health interface -->
+    <java-symbol type="bool" name="config_chargingControlEnabled" />
+    <java-symbol type="integer" name="config_defaultChargingControlMode" />
+    <java-symbol type="integer" name="config_defaultChargingControlStartTime" />
+    <java-symbol type="integer" name="config_defaultChargingControlTargetTime" />
+    <java-symbol type="integer" name="config_defaultChargingControlLimit" />
+    <java-symbol type="drawable" name="ic_charging_control" />
+    <java-symbol type="integer" name="config_chargingControlTimeMargin" />
+    <java-symbol type="integer" name="config_chargingControlBatteryRechargeMargin" />
+    <java-symbol type="string" name="charging_control_notification_channel" />
+    <java-symbol type="string" name="charging_control_notification_title" />
+    <java-symbol type="string" name="charging_control_notification_cancel_once" />
+    <java-symbol type="string" name="charging_control_notification_content_limit" />
+    <java-symbol type="string" name="charging_control_notification_content_limit_reached" />
+    <java-symbol type="string" name="charging_control_notification_content_target" />
+    <java-symbol type="string" name="charging_control_notification_content_target_reached" />
+
 </resources>
diff --git a/sdk/src/omnirom/app/OmniRomContextConstants.java b/sdk/src/omnirom/app/OmniRomContextConstants.java
new file mode 100644
index 0000000..33a3694
--- /dev/null
+++ b/sdk/src/omnirom/app/OmniRomContextConstants.java
@@ -0,0 +1,58 @@
+/**
+ * Copyright (C) 2015, The CyanogenMod Project
+ *               2017-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 omnirom.app;
+
+import android.annotation.SdkConstant;
+
+/**
+ * @hide
+ * TODO: We need to somehow make these managers accessible via getSystemService
+ */
+public final class OmniRomContextConstants {
+
+    /**
+     * @hide
+     */
+    private OmniRomContextConstants() {
+        // Empty constructor
+    }
+
+    /**
+     * 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";
+
+    /**
+     * Features supported by the Lineage SDK.
+     */
+    public static class Features {
+        /**
+         * Feature for {@link PackageManager#getSystemAvailableFeatures} and
+         * {@link PackageManager#hasSystemFeature}: The device includes the lineage health
+         * service utilized by the omnirom sdk and Omnirom Control.
+         */
+        @SdkConstant(SdkConstant.SdkConstantType.FEATURE)
+        public static final String HEALTH = "org.lineageos.health";
+    }
+}
diff --git a/sdk/src/omnirom/health/HealthInterface.java b/sdk/src/omnirom/health/HealthInterface.java
new file mode 100644
index 0000000..3a8e787
--- /dev/null
+++ b/sdk/src/omnirom/health/HealthInterface.java
@@ -0,0 +1,282 @@
+/*
+ * 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 omnirom.health;
+
+import android.content.Context;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.util.Log;
+
+import omnirom.app.OmniRomContextConstants;
+
+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();
+
+        if (context.getPackageManager().hasSystemFeature(
+                OmniRomContextConstants.Features.HEALTH) && sService == null) {
+            throw new RuntimeException("Unable to get HealthInterfaceService. The service" +
+                    " either crashed, was not started, or the interface has been called too early" +
+                    " in SystemServer init");
+        }
+    }
+
+    /**
+     * Get or create an instance of the {@link omnirom.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(OmniRomContextConstants.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 OmniRomHardwareManagerService");
+            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/sdk/src/omnirom/health/IHealthInterface.aidl b/sdk/src/omnirom/health/IHealthInterface.aidl
new file mode 100644
index 0000000..30928c5
--- /dev/null
+++ b/sdk/src/omnirom/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 omnirom.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/src/org/omnirom/omnilib/internal/OmniRomBaseFeature.java b/src/org/omnirom/omnilib/internal/OmniRomBaseFeature.java
new file mode 100644
index 0000000..a9dba7f
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/OmniRomBaseFeature.java
@@ -0,0 +1,111 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.UserHandle;
+
+import org.omnirom.omnilib.internal.common.UserContentObserver;
+
+import java.io.PrintWriter;
+
+import android.provider.Settings;
+
+public abstract class OmniRomBaseFeature {
+    protected final Context mContext;
+    protected final Handler mHandler;
+    protected SettingsObserver mSettingsObserver;
+
+    public OmniRomBaseFeature(Context context, Handler handler) {
+        mContext = context;
+        mHandler = handler;
+    }
+
+    public abstract void onStart();
+
+    protected abstract void onSettingsChanged(Uri uri);
+
+    public abstract void dump(PrintWriter pw);
+
+    public void start() {
+        if (mSettingsObserver == null) {
+            mSettingsObserver = new SettingsObserver(mHandler);
+            onStart();
+        }
+    }
+
+    protected final void registerSettings(Uri... settings) {
+        mSettingsObserver.register(settings);
+    }
+
+    protected final boolean getBoolean(String setting, boolean defaultValue) {
+        return Settings.System.getIntForUser(mContext.getContentResolver(),
+                setting, (defaultValue ? 1 : 0), UserHandle.USER_CURRENT) == 1;
+    }
+
+    protected final void putBoolean(String setting, boolean value) {
+        Settings.System.putIntForUser(mContext.getContentResolver(),
+                setting, (value ? 1 : 0), UserHandle.USER_CURRENT);
+    }
+
+    protected final int getInt(String setting, int defaultValue) {
+        return Settings.System.getIntForUser(mContext.getContentResolver(),
+                setting, defaultValue, UserHandle.USER_CURRENT);
+    }
+
+    protected final void putInt(String setting, int value) {
+        Settings.System.putIntForUser(mContext.getContentResolver(),
+                setting, value, UserHandle.USER_CURRENT);
+    }
+
+    protected final String getString(String setting) {
+        return Settings.System.getStringForUser(mContext.getContentResolver(),
+                setting, UserHandle.USER_CURRENT);
+    }
+
+    protected final void putString(String setting, String value) {
+        Settings.System.putStringForUser(mContext.getContentResolver(),
+                setting, value, UserHandle.USER_CURRENT);
+    }
+
+    public void onDestroy() {
+        mSettingsObserver.unregister();
+    }
+
+    final class SettingsObserver extends UserContentObserver {
+
+        public SettingsObserver(Handler handler) {
+            super(handler);
+        }
+
+        public void register(Uri... uris) {
+            final ContentResolver cr = mContext.getContentResolver();
+            for (Uri uri : uris) {
+                cr.registerContentObserver(uri, false, this, UserHandle.USER_ALL);
+            }
+
+            observe();
+        }
+
+        public void unregister() {
+            mContext.getContentResolver().unregisterContentObserver(this);
+            unobserve();
+        }
+
+        @Override
+        protected void update() {
+            onSettingsChanged(null);
+        }
+
+        @Override
+        public void onChange(boolean selfChange, Uri uri) {
+            onSettingsChanged(uri);
+        }
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/common/UserContentObserver.java b/src/org/omnirom/omnilib/internal/common/UserContentObserver.java
new file mode 100644
index 0000000..916bc4b
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/common/UserContentObserver.java
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2016 The CyanogenMod Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package org.omnirom.omnilib.internal.common;
+
+import android.app.ActivityManagerNative;
+import android.app.IUserSwitchObserver;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.IRemoteCallback;
+import android.os.RemoteException;
+import android.util.Log;
+
+/**
+ * Simple extension of ContentObserver that also listens for user switch events to call update
+ */
+public abstract class UserContentObserver extends ContentObserver {
+    private static final String TAG = "UserContentObserver";
+
+    private Runnable mUpdateRunnable;
+
+    private IUserSwitchObserver mUserSwitchObserver = new IUserSwitchObserver.Stub() {
+        @Override
+        public void onBeforeUserSwitching(int newUserId) throws RemoteException {
+        }
+        @Override
+        public void onUserSwitching(int newUserId, IRemoteCallback reply) {
+        }
+        @Override
+        public void onUserSwitchComplete(int newUserId) throws RemoteException {
+            mHandler.post(mUpdateRunnable);
+        }
+        @Override
+        public void onForegroundProfileSwitch(int newProfileId) {
+        }
+        @Override
+        public void onLockedBootComplete(int newUserId) {
+        }
+    };
+
+    private Handler mHandler;
+
+    /**
+     * Content observer that tracks user switches
+     * to allow clients to re-load settings for current user
+     */
+    public UserContentObserver(Handler handler) {
+        super(handler);
+        mHandler = handler;
+        mUpdateRunnable = new Runnable() {
+            @Override
+            public void run() {
+                update();
+            }
+        };
+    }
+
+    protected void observe() {
+        try {
+            ActivityManagerNative.getDefault().registerUserSwitchObserver(mUserSwitchObserver, TAG);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Unable to register user switch observer!", e);
+        }
+    }
+
+    protected void unobserve() {
+        try {
+            mHandler.removeCallbacks(mUpdateRunnable);
+            ActivityManagerNative.getDefault().unregisterUserSwitchObserver(mUserSwitchObserver);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Unable to unregister user switch observer!", e);
+        }
+    }
+
+    /**
+     *  Called to notify of registered uri changes and user switches.
+     *  Always invoked on the handler passed in at construction
+     */
+    protected abstract void update();
+
+    @Override
+    public void onChange(boolean selfChange, Uri uri) {
+        update();
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/health/ChargingControlController.java b/src/org/omnirom/omnilib/internal/health/ChargingControlController.java
new file mode 100644
index 0000000..6263e56
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/ChargingControlController.java
@@ -0,0 +1,874 @@
+/*
+ * 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 org.omnirom.omnilib.internal.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.provider.Settings;
+import android.text.format.DateUtils;
+import android.util.Log;
+
+import org.omnirom.omnilib.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 org.omnirom.omnilib.utils.OmniSettings;
+
+import vendor.lineage.health.ChargingControlSupportedMode;
+import vendor.lineage.health.IChargingControl;
+
+import static omnirom.health.HealthInterface.MODE_NONE;
+import static omnirom.health.HealthInterface.MODE_AUTO;
+import static omnirom.health.HealthInterface.MODE_MANUAL;
+import static omnirom.health.HealthInterface.MODE_LIMIT;
+
+public class ChargingControlController extends OmniRomHealthFeature {
+    private final IChargingControl mChargingControl;
+    private final ContentResolver mContentResolver;
+    private final ChargingControlNotification mChargingNotification;
+    private OmniRomHealthBatteryBroadcastReceiver 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(
+            OmniSettings.OMNI_CHARGING_CONTROL_MODE);
+    private final Uri LIMIT_URI = Settings.System.getUriFor(
+            OmniSettings.OMNI_CHARGING_CONTROL_LIMIT);
+    private final Uri ENABLED_URI = Settings.System.getUriFor(
+            OmniSettings.OMNI_CHARGING_CONTROL_ENABLED);
+    private final Uri START_TIME_URI = Settings.System.getUriFor(
+            OmniSettings.OMNI_CHARGING_CONTROL_START_TIME);
+    private final Uri TARGET_TIME_URI = Settings.System.getUriFor(
+            OmniSettings.OMNI_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 =
+            "org.omnirom.control.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, "OmniRom 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(OmniSettings.OMNI_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(OmniSettings.OMNI_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(OmniSettings.OMNI_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(OmniSettings.OMNI_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(OmniSettings.OMNI_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 OmniRomHealthBatteryBroadcastReceiver();
+        }
+        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,
+                OmniSettings.OMNI_CHARGING_CONTROL_ENABLED, 0)
+                != 0;
+        mConfigLimit = Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_LIMIT,
+                mDefaultLimit);
+        mConfigMode = Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_MODE,
+                mDefaultMode);
+        mConfigStartTime = Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_START_TIME,
+                mDefaultStartTime);
+        mConfigTargetTime = Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_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 OmniRomHealthBatteryBroadcastReceiver 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 =
+                "omnirom.platform.intent.action.CHARGING_CONTROL_CANCEL_ONCE";
+        private static final String CHARGING_CONTROL_CHANNEL_ID = "OmniRomHealthChargingControl";
+
+        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 OmniRomHealthNotificationBroadcastReceiver(),
+                    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 OmniRomHealthNotificationBroadcastReceiver 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/src/org/omnirom/omnilib/internal/health/HealthInterfaceService.java b/src/org/omnirom/omnilib/internal/health/HealthInterfaceService.java
new file mode 100644
index 0000000..2c8bbc4
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/HealthInterfaceService.java
@@ -0,0 +1,181 @@
+/*
+ * 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 org.omnirom.omnilib.internal.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 org.omnirom.omnilib.internal.OmniRomSystemService;
+
+import omnirom.app.OmniRomContextConstants;
+import omnirom.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 OmniRomSystemService {
+
+    private static final String TAG = "OmniRomHealth";
+    private final Context mContext;
+    private final Handler mHandler;
+    private final ServiceThread mHandlerThread;
+
+    private final List<OmniRomHealthFeature> mFeatures = new ArrayList<OmniRomHealthFeature>();
+
+    // 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 String getFeatureDeclaration() {
+        return OmniRomContextConstants.Features.HEALTH;
+    }
+
+    @Override
+    public boolean isCoreService() {
+        return false;
+    }
+
+    @Override
+    public void onStart() {
+        if (!mContext.getPackageManager().hasSystemFeature(
+                OmniRomContextConstants.Features.HEALTH)) {
+            Log.wtf(TAG, "OmniRom Health service started by system server but feature xml "
+                    + "not declared. Not publishing binder service!");
+            return;
+        }
+        mCCC = new ChargingControlController(mContext, mHandler);
+        if (mCCC.isSupported()) {
+            mFeatures.add(mCCC);
+        }
+
+        if (!mFeatures.isEmpty()) {
+            publishBinderService(OmniRomContextConstants.LINEAGE_HEALTH_INTERFACE, mService);
+            Log.i(TAG, "OmniRom Health service started");
+        }
+    }
+
+    @Override
+    public void onBootPhase(int phase) {
+        if (phase != PHASE_BOOT_COMPLETED) {
+            return;
+        }
+
+        // start and update all features
+        for (OmniRomHealthFeature 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("OmniRomHealth Service State:");
+
+            for (OmniRomHealthFeature feature : mFeatures) {
+                feature.dump(pw);
+            }
+        }
+    };
+}
diff --git a/src/org/omnirom/omnilib/internal/health/OmniRomHealthFeature.java b/src/org/omnirom/omnilib/internal/health/OmniRomHealthFeature.java
new file mode 100644
index 0000000..79ff7c1
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/OmniRomHealthFeature.java
@@ -0,0 +1,32 @@
+/*
+ * 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 org.omnirom.omnilib.internal.health;
+
+import android.content.Context;
+import android.os.Handler;
+
+import org.omnirom.omnilib.internal.OmniRomBaseFeature;
+
+public abstract class OmniRomHealthFeature extends OmniRomBaseFeature {
+    protected static final String TAG = "OmniRomHealth";
+
+    public OmniRomHealthFeature(Context context, Handler handler) {
+        super(context, handler);
+    }
+
+    public abstract boolean isSupported();
+}