ChargingControl: Decouple charging control and main logic

The existing Hardware Abstraction Layer (HAL) supports two distinct
control modes: TOGGLE and DEADLINE, each offering unique capabilities.
For instance, the TOGGLE mode allows for control over both charging
time and limit, while the DEADLINE mode only enables control over the
charging time. Managing these separate logic streams within a single
ChargingControlController class complicates the development process.

This commit separates the specific charging control logic — determining
what to send to the HAL—from the primary logic. The charging control
module now offers providers tailored to each HAL-supported charging
control mode, allowing for limit control, time control, or both. When
required, the ChargingControlController invokes these specific providers.

This commit also saparates other parts, like notifications, from the
main logic, to a saparate class.

This separation simplifies the codebase. Moreover, when introducing a new
mode in the HAL, developers only need to implement the corresponding
provider's logic based on the mode's capabilities. And minimal changes
are needed in the primary logic.

Change-Id: Ie40020c2df4141d4aa6385c8f5565821af942755
diff --git a/res/values/config.xml b/res/values/config.xml
index 0bb0d55..e132f1f 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -52,6 +52,10 @@
          to make the device to be fully charged at the target time, increase the value
     -->
     <integer name="config_chargingControlTimeMargin">30</integer>
+    <!-- Some devices might require always setting the toggle instead of set based on current toggle
+         value. If this is the case, set this config to true to let the toggle provider always set
+         charging enabled status regardless of the current status. -->
+    <bool name="config_chargingControlToggleSetAlways">false</bool>
     <!-- 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
diff --git a/res/values/symbols.xml b/res/values/symbols.xml
index 201401d..59302c5 100644
--- a/res/values/symbols.xml
+++ b/res/values/symbols.xml
@@ -52,6 +52,7 @@
 
     <!-- Health interface -->
     <java-symbol type="bool" name="config_chargingControlEnabled" />
+    <java-symbol type="bool" name="config_chargingControlToggleSetAlways" />
     <java-symbol type="integer" name="config_defaultChargingControlMode" />
     <java-symbol type="integer" name="config_defaultChargingControlStartTime" />
     <java-symbol type="integer" name="config_defaultChargingControlTargetTime" />
diff --git a/src/org/omnirom/omnilib/internal/health/ChargingControlController.java b/src/org/omnirom/omnilib/internal/health/ChargingControlController.java
index 83f1d5a..84dbb5f 100644
--- a/src/org/omnirom/omnilib/internal/health/ChargingControlController.java
+++ b/src/org/omnirom/omnilib/internal/health/ChargingControlController.java
@@ -1,28 +1,15 @@
 /*
- * 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.
+ * SPDX-FileCopyrightText: 2023-2024 The LineageOS Project
+ * SPDX-FileCopyrightText: 2023-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 package org.omnirom.omnilib.internal.health;
 
-import static java.time.format.FormatStyle.SHORT;
+import static org.omnirom.omnilib.internal.health.Util.getTimeMillisFromSecondOfDay;
+import static org.omnirom.omnilib.internal.health.Util.msToString;
 
 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;
@@ -30,8 +17,6 @@
 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;
@@ -40,21 +25,14 @@
 import android.util.Log;
 
 import org.omnirom.omnilib.R;
+import org.omnirom.omnilib.internal.health.ccprovider.ChargingControlProvider;
+import org.omnirom.omnilib.internal.health.ccprovider.Deadline;
+import org.omnirom.omnilib.internal.health.ccprovider.Toggle;
 
 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;
@@ -75,13 +53,6 @@
     private int mDefaultStartTime;
     private int mDefaultTargetTime;
 
-    // User configs
-    private boolean mConfigEnabled;
-    private int mConfigStartTime;
-    private int mConfigTargetTime;
-    private int mConfigMode = MODE_NONE;
-    private int mConfigLimit = 100;
-
     // Settings uris
     private final Uri MODE_URI = Settings.System.getUriFor(
             OmniSettings.OMNI_CHARGING_CONTROL_MODE);
@@ -96,47 +67,11 @@
 
     // Internal state
     private float mBatteryPct;
-    private long mEstimatedFullTime;
-    private long mSavedAlarmTime;
-    private long mSavedTargetTime;
     private boolean mIsPowerConnected;
     private boolean mIsControlCancelledOnce;
-    private boolean mIsChargingToggleSupported;
-    private boolean mIsChargingBypassSupported;
-    private boolean mIsChargingDeadlineSupported;
-    private int mChargingStopReason;
-    private int mChargingTimeMargin;
-    private 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);
-    }
+    // Current selected provider
+    private ChargingControlProvider mCurrentProvider;
 
     public ChargingControlController(Context context, Handler handler) {
         super(context, handler);
@@ -151,10 +86,7 @@
             return;
         }
 
-        mChargingNotification = new ChargingControlNotification(context);
-
-        mChargingTimeMargin = mContext.getResources().getInteger(
-                R.integer.config_chargingControlTimeMargin) * 60 * 1000;
+        mChargingNotification = new ChargingControlNotification(context, this);
 
         mDefaultEnabled = mContext.getResources().getBoolean(
                 R.bool.config_chargingControlEnabled);
@@ -167,20 +99,19 @@
         mDefaultLimit = mContext.getResources().getInteger(
                 R.integer.config_defaultChargingControlLimit);
 
-        mIsChargingToggleSupported = isChargingModeSupported(ChargingControlSupportedMode.TOGGLE);
-        mIsChargingBypassSupported = isChargingModeSupported(ChargingControlSupportedMode.BYPASS);
-        mIsChargingDeadlineSupported = isChargingModeSupported(
-                ChargingControlSupportedMode.DEADLINE);
-
-        if (mIsChargingBypassSupported) {
-            // This is a workaround for devices that support charging bypass, but is not able to
-            // hold the charging current to 0mA, which causes battery to lose power very slowly.
-            // This will become a problem in limit mode because it will stop charge at limit and
-            // immediately resume charging at (limit - 1). So we add a small margin here.
-            mChargingLimitMargin = 1;
-        } else {
-            mChargingLimitMargin = mContext.getResources().getInteger(
-                    R.integer.config_chargingControlBatteryRechargeMargin);
+        // Set up charging control providers
+        mCurrentProvider = new Toggle(mChargingControl, mContext);
+        if (!mCurrentProvider.isSupported()) {
+            mCurrentProvider = null;
+        }
+        if (mCurrentProvider == null) {
+            mCurrentProvider = new Deadline(mChargingControl, mContext);
+            if (!mCurrentProvider.isSupported()) {
+                mCurrentProvider = null;
+            }
+        }
+        if (mCurrentProvider == null) {
+            Log.wtf(TAG, "No charging control provider is supported");
         }
     }
 
@@ -190,7 +121,8 @@
     }
 
     public boolean isEnabled() {
-        return mConfigEnabled;
+        return Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_ENABLED, 0) != 0;
     }
 
     public boolean setEnabled(boolean enabled) {
@@ -199,7 +131,9 @@
     }
 
     public int getMode() {
-        return mConfigMode;
+        return Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_MODE,
+                mDefaultMode);
     }
 
     public boolean setMode(int mode) {
@@ -212,7 +146,9 @@
     }
 
     public int getStartTime() {
-        return mConfigStartTime;
+        return Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_START_TIME,
+                mDefaultStartTime);
     }
 
     public boolean setStartTime(int time) {
@@ -225,7 +161,9 @@
     }
 
     public int getTargetTime() {
-        return mConfigTargetTime;
+        return Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_TARGET_TIME,
+                mDefaultTargetTime);
     }
 
     public boolean setTargetTime(int time) {
@@ -238,7 +176,9 @@
     }
 
     public int getLimit() {
-        return mConfigLimit;
+        return Settings.System.getInt(mContentResolver,
+                OmniSettings.OMNI_CHARGING_CONTROL_LIMIT,
+                mDefaultLimit);
     }
 
     public boolean setLimit(int limit) {
@@ -255,14 +195,6 @@
                 && setStartTime(mDefaultStartTime) && setTargetTime(mDefaultTargetTime);
     }
 
-    public boolean isChargingModeSupported(int mode) {
-        try {
-            return isSupported() && (mChargingControl.getSupportedMode() & mode) != 0;
-        } catch (RemoteException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
     @Override
     public void onStart() {
         if (mChargingControl == null) {
@@ -274,7 +206,7 @@
 
         // 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) {
+        if (mCurrentProvider.requiresBatteryLevelMonitoring()) {
             mIsPowerConnected = true;
             onPowerStatus(true);
             handleSettingChange();
@@ -315,13 +247,42 @@
         handleSettingChange();
     }
 
-    private void resetInternalState() {
-        mSavedAlarmTime = 0;
-        mSavedTargetTime = 0;
-        mEstimatedFullTime = 0;
-        mChargingStopReason = 0;
+    public boolean isChargingModeSupported(int mode) {
+        try {
+            return isSupported() && (mChargingControl.getSupportedMode() & mode) != 0;
+        } catch (RemoteException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    protected void resetInternalState() {
         mIsControlCancelledOnce = false;
         mChargingNotification.cancel();
+
+        mCurrentProvider.reset();
+    }
+
+    protected void setChargingCancelledOnce() {
+        mIsControlCancelledOnce = true;
+
+        if (mCurrentProvider.requiresBatteryLevelMonitoring()) {
+            IntentFilter disconnectFilter = new IntentFilter(
+                    Intent.ACTION_POWER_DISCONNECTED);
+
+            // Register a one-time receiver that resets internal state on power
+            // disconnection
+            mContext.registerReceiver(new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    Log.i(TAG, "Power disconnected, reset internal states");
+                    resetInternalState();
+                    mContext.unregisterReceiver(this);
+                }
+            }, disconnectFilter);
+        }
+
+        mCurrentProvider.disable();
+        mChargingNotification.cancel();
     }
 
     private void onPowerConnected() {
@@ -342,25 +303,17 @@
     }
 
     private void onPowerStatus(boolean enable) {
+        // Don't do anything if it is not enabled
+        if (!isEnabled()) {
+            return;
+        }
+
         if (enable) {
             onPowerConnected();
+            updateChargeControl();
         } 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() {
@@ -368,7 +321,9 @@
         final long currentTime = System.currentTimeMillis();
         Log.i(TAG, "Current time is " + msToString(currentTime));
         long targetTime = 0, startTime = currentTime;
-        if (mConfigMode == MODE_AUTO) {
+        int mode = getMode();
+
+        if (mode == MODE_AUTO) {
             // Use alarm as the target time. Maybe someday we can use a model.
             AlarmManager m = mContext.getSystemService(AlarmManager.class);
             if (m == null) {
@@ -379,14 +334,18 @@
             AlarmManager.AlarmClockInfo alarmClockInfo = m.getNextAlarmClock();
             if (alarmClockInfo == null) {
                 // We didn't find an alarm. Clear waiting flags because we can't predict anyway
+                Log.w(TAG, "No alarm found, auto charging control has no effect");
                 mChargingNotification.cancel();
                 return null;
             }
             targetTime = alarmClockInfo.getTriggerTime();
-        } else if (mConfigMode == MODE_MANUAL) {
+
+            // Start time is 9 hours before the alarm
+            startTime = targetTime - DateUtils.HOUR_IN_MILLIS * 9;
+        } else if (mode == MODE_MANUAL) {
             // User manually controlled time
-            startTime = getTimeMillisFromSecondOfDay(mConfigStartTime);
-            targetTime = getTimeMillisFromSecondOfDay(mConfigTargetTime);
+            startTime = getTimeMillisFromSecondOfDay(getStartTime());
+            targetTime = getTimeMillisFromSecondOfDay(getTargetTime());
 
             if (startTime > targetTime) {
                 if (currentTime > targetTime) {
@@ -394,263 +353,70 @@
                 } else {
                     startTime -= DateUtils.DAY_IN_MILLIS;
                 }
+            } else if (currentTime >= targetTime) {
+                startTime += DateUtils.DAY_IN_MILLIS;
+                targetTime += DateUtils.DAY_IN_MILLIS;
             }
         } else {
-            Log.e(TAG, "invalid charging control mode " + mConfigMode);
+            Log.e(TAG, "invalid charging control mode " + mode);
             return null;
         }
 
-        Log.i(TAG, "Target time is " + msToString(targetTime));
+        Log.i(TAG, "Got target time " + msToString(targetTime) + ", start time " +
+                msToString(startTime) + ", current time " + msToString(currentTime));
+        Log.i(TAG, "Raw: " + targetTime + ", " + startTime + ", " + currentTime);
 
         return new ChargeTime(startTime, targetTime);
     }
 
-    private void updateChargeControl() {
-        if (mIsChargingToggleSupported) {
-            updateChargeToggle();
-        } else if (mIsChargingDeadlineSupported) {
-            updateChargeDeadline();
-        }
-    }
-
-    private boolean shouldSetLimitFlag() {
-        if (mConfigMode != MODE_LIMIT) {
-            return false;
+    protected void updateChargeControl() {
+        if (!isEnabled() || mIsControlCancelledOnce) {
+            mCurrentProvider.disable();
+            return;
         }
 
-        if (isChargingReasonSet(ChargingStopReason.REACH_LIMIT)) {
-            return mBatteryPct >= mConfigLimit - mChargingLimitMargin;
-        }
+        int mode = getMode();
+        int limit = getLimit();
 
-        if (mBatteryPct >= mConfigLimit) {
-            mChargingNotification.post(null, true);
-            return true;
+        mCurrentProvider.enable();
+
+        if (mode == MODE_LIMIT) {
+            if (mCurrentProvider.update(mBatteryPct, limit)) {
+                mChargingNotification.post(limit, mBatteryPct == limit);
+            }
         } 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;
+            ChargeTime chargeTime = getChargeTime();
+            if (chargeTime != null) {
+                if (mCurrentProvider.update(mBatteryPct, chargeTime.getStartTime(),
+                        chargeTime.getTargetTime(), mode)) {
+                    mChargingNotification.post(chargeTime.getTargetTime(),
+                            mBatteryPct == 100);
+                } else {
+                    mChargingNotification.cancel();
                 }
-
-                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;
-        }
-
-        long deadline = 0;
-        final long targetTime;
-        final ChargeTime t = getChargeTime();
-
-        if (!mConfigEnabled || t == null || mIsControlCancelledOnce) {
-            deadline = -1;
-            targetTime = 0;
-            Log.i(TAG, "Canceling charge deadline");
-        } else {
-            if (t.getTargetTime() == mSavedTargetTime) {
-                return;
-            }
-            targetTime = t.getTargetTime();
-            final long currentTime = System.currentTimeMillis();
-            deadline = (targetTime - currentTime) / 1000;
-            Log.i(TAG, "Setting charge deadline: Current time: " + msToString(currentTime));
-            Log.i(TAG, "Setting charge deadline: Target time: " + msToString(targetTime));
-            Log.i(TAG, "Setting charge deadline: Deadline (seconds): " + deadline);
-        }
-
-        try {
-            mChargingControl.setChargingDeadline(deadline);
-            mSavedTargetTime = targetTime;
-        } catch (IllegalStateException | RemoteException | UnsupportedOperationException e) {
-            Log.e(TAG, "Failed to set charge deadline", e);
-        }
-    }
-
-    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
+     * Whether the current charging control mode supports supports the mode.
+     * Available modes:
+     *     - ${@link omnirom.health.HealthInterface#MODE_AUTO}
+     *     - ${@link omnirom.health.HealthInterface#MODE_MANUAL}
+     *     - ${@link omnirom.health.HealthInterface#MODE_LIMIT}
      */
-    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 boolean isProvideSupportCCMode(int mode) {
+        return mCurrentProvider.isChargingControlModeSupported(mode);
     }
 
     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);
+        int mode = getMode();
+
+        if (!isProvideSupportCCMode(mode)) {
+            Log.e(TAG, "Current provider does not support mode: " + mode
+                    + ", setting to default mode");
+            setMode(mDefaultMode);
+        }
 
         // Reset internal states
         resetInternalState();
@@ -659,7 +425,6 @@
         updateChargeControl();
     }
 
-
     @Override
     protected void onSettingsChanged(Uri uri) {
         handleSettingChange();
@@ -669,34 +434,26 @@
     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("  Enabled: " + isEnabled());
+        pw.println("  Mode: " + getMode());
+        pw.println("  Limit: " + getLimit());
+        pw.println("  StartTime: " + getStartTime());
+        pw.println("  TargetTime: " + getTargetTime());
         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));
-        }
+        pw.println();
+        mCurrentProvider.dump(pw);
     }
 
     /* 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) {
@@ -708,194 +465,6 @@
         }
     }
 
-    /* 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;
-
-                if (!mIsChargingBypassSupported) {
-                    IntentFilter disconnectFilter = new IntentFilter(
-                            Intent.ACTION_POWER_DISCONNECTED);
-
-                    // Register a one-time receiver that resets internal state on power
-                    // disconnection
-                    mContext.registerReceiver(new BroadcastReceiver() {
-                        @Override
-                        public void onReceive(Context context, Intent intent) {
-                            Log.i(TAG, "Power disconnected, reset internal states");
-                            resetInternalState();
-                            mContext.unregisterReceiver(this);
-                        }
-                    }, disconnectFilter);
-                }
-                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);
-
-            if (targetTime == null) {
-                Intent cancelOnceIntent = new Intent(ACTION_CHARGING_CONTROL_CANCEL_ONCE);
-                PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(mContext, 0,
-                        cancelOnceIntent, PendingIntent.FLAG_IMMUTABLE);
-                notification.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 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;
diff --git a/src/org/omnirom/omnilib/internal/health/ChargingControlNotification.java b/src/org/omnirom/omnilib/internal/health/ChargingControlNotification.java
new file mode 100644
index 0000000..09e33d1
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/ChargingControlNotification.java
@@ -0,0 +1,213 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal.health;
+
+import static org.omnirom.omnilib.internal.health.Util.msToString;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import org.omnirom.omnilib.R;
+
+public class ChargingControlNotification {
+    private final NotificationManager mNotificationManager;
+    private final Context mContext;
+
+    private static final String INTENT_PARTS =
+            "org.omnirom.control.CHARGING_CONTROL_SETTINGS";
+
+    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 final ChargingControlController mChargingControlController;
+
+    private boolean mIsDoneNotification = false;
+    private boolean mIsNotificationPosted = false;
+
+    ChargingControlNotification(Context context, ChargingControlController controller) {
+        mContext = context;
+        mChargingControlController = controller;
+
+        // 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(int limit, boolean done) {
+        if (mIsNotificationPosted && mIsDoneNotification == done) {
+            return;
+        }
+
+        if (mIsNotificationPosted) {
+            cancel();
+        }
+
+        if (done) {
+            postChargingDoneNotification(null, limit);
+        } else {
+            postChargingControlNotification(null, limit);
+        }
+
+        mIsNotificationPosted = true;
+        mIsDoneNotification = done;
+    }
+
+    public void post(Long targetTime, boolean done) {
+        if (mIsNotificationPosted && mIsDoneNotification == done) {
+            return;
+        }
+
+        if (mIsNotificationPosted) {
+            cancel();
+        }
+
+        if (done) {
+            postChargingDoneNotification(targetTime, 0);
+        } else {
+            postChargingControlNotification(targetTime, 0);
+        }
+
+        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)) {
+            mChargingControlController.setChargingCancelledOnce();
+        }
+    }
+
+    private void postChargingControlNotification(Long targetTime, int limit) {
+        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),
+                    msToString(targetTime));
+        } else {
+            message = String.format(
+                    mContext.getString(R.string.charging_control_notification_content_limit),
+                    limit);
+        }
+
+        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, int limit) {
+        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),
+                    limit);
+        }
+
+        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);
+
+        if (targetTime == null) {
+            Intent cancelOnceIntent = new Intent(ACTION_CHARGING_CONTROL_CANCEL_ONCE);
+            PendingIntent cancelPendingIntent = PendingIntent.getBroadcast(mContext, 0,
+                    cancelOnceIntent, PendingIntent.FLAG_IMMUTABLE);
+            notification.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 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);
+        }
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/health/Util.java b/src/org/omnirom/omnilib/internal/health/Util.java
new file mode 100644
index 0000000..7552607
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/Util.java
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal.health;
+
+import static java.time.format.FormatStyle.SHORT;
+
+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 java.util.TimeZone;
+
+public class Util {
+    private static final DateTimeFormatter mFormatter = DateTimeFormatter.ofLocalizedTime(SHORT);
+
+    /**
+     * Convert milliseconds to a string in the format "hh:mm:ss a".
+     *
+     * @param ms milliseconds from epoch
+     * @return formatted time string in current time zone
+     */
+    static public String msToString(long ms) {
+        final SimpleDateFormat dateFormat = new SimpleDateFormat("hh:mm:ss a");
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTimeInMillis(ms);
+        return dateFormat.format(calendar.getTime());
+    }
+
+    /**
+     * Convert seconds of the day to a string in the format "hh:mm:ss".
+     * in UTC.
+     *
+     * @param ms milliseconds from epoch
+     * @return formatted time string in UTC time zone
+     */
+    static public String msToUTCString(long ms) {
+        final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
+        Calendar calendar = Calendar.getInstance();
+        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+        calendar.setTimeInMillis(ms);
+        return dateFormat.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
+     */
+    static public 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();
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/health/ccprovider/ChargingControlProvider.java b/src/org/omnirom/omnilib/internal/health/ccprovider/ChargingControlProvider.java
new file mode 100644
index 0000000..5d0bc02
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/ccprovider/ChargingControlProvider.java
@@ -0,0 +1,167 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal.health.ccprovider;
+
+import android.content.Context;
+import android.os.RemoteException;
+import android.util.Log;
+
+import vendor.lineage.health.IChargingControl;
+
+import java.io.PrintWriter;
+
+public abstract class ChargingControlProvider {
+    protected final IChargingControl mChargingControl;
+    protected final Context mContext;
+
+    protected static final String TAG = "OmniRomHealth";
+
+    protected boolean isEnabled = false;
+
+    ChargingControlProvider(Context context, IChargingControl chargingControl) {
+        mContext = context;
+        mChargingControl = chargingControl;
+    }
+
+    public final boolean update(float batteryPct, int targetPct) {
+        if (!isEnabled) {
+            return false;
+        }
+        return onBatteryChanged(batteryPct, targetPct);
+    }
+
+    public final boolean update(float batteryPct, long startTime, long targetTime, int configMode) {
+        if (!isEnabled) {
+            return false;
+        }
+        return onBatteryChanged(batteryPct, startTime, targetTime, configMode);
+    }
+
+    public final void reset() {
+        onReset();
+    }
+
+    /**
+     * Enables the provider
+     */
+    public final void enable() {
+        // Don't enable a provider twice
+        if (isEnabled) {
+            return;
+        }
+        isEnabled = true;
+        onEnabled();
+        Log.i(TAG, getClass() + " is enabled");
+    }
+
+    /**
+     * Disable any effect of this provider.
+     */
+    public final void disable() {
+        // Don't disable a provider twice
+        if (!isEnabled) {
+            return;
+        }
+        isEnabled = false;
+        Log.i(TAG, getClass() + " is disabled");
+        onDisable();
+    }
+
+    /**
+     * Return whether the provider is enabled
+     */
+    public final boolean isEnabled() {
+        return isEnabled;
+    }
+
+    /**
+     * Called when the mode is {@link omnirom.health.HealthInterface#MODE_LIMIT} and
+     * the {@link android.content.Intent#ACTION_BATTERY_CHANGED} is received.
+     *
+     * @param currentPct Current battery percentage
+     * @param targetPct  The user-configured target charging limit
+     * @return Whether a notification should be posted
+     */
+    protected boolean onBatteryChanged(float currentPct, int targetPct) {
+        throw new RuntimeException("Unsupported operation");
+    }
+
+    /**
+     * Called when the mode is {@link omnirom.health.HealthInterface#MODE_AUTO} or
+     * {@link omnirom.health.HealthInterface#MODE_MANUAL} and the
+     * {@link android.content.Intent#ACTION_BATTERY_CHANGED} is received.
+     *
+     * @param batteryPct Current battery percentage
+     * @param startTime  The time when the charging control should start
+     * @param targetTime The expected time when the battery should be full
+     * @param configMode The current charging control mode, either
+     *                   {@link omnirom.health.HealthInterface#MODE_AUTO} or
+     *                   {@link omnirom.health.HealthInterface#MODE_MANUAL}
+     * @return Whether a notification should be posted
+     */
+    protected boolean onBatteryChanged(float batteryPct, long startTime, long targetTime,
+            int configMode) {
+        throw new RuntimeException("Unsupported operation");
+    }
+
+    /**
+     * Called when the provider is enabled
+     */
+    protected abstract void onEnabled();
+
+    /**
+     * Called when the provider is disabled
+     */
+    protected abstract void onDisable();
+
+    /**
+     * Reset internal states
+     */
+    protected abstract void onReset();
+
+    /**
+     * Dump internal states
+     */
+    public abstract void dump(PrintWriter pw);
+
+    /**
+     * Given current device setup, whether the charging control provider is supported.
+     *
+     * @return Whether this charging control provider is supported.
+     */
+    public abstract boolean isSupported();
+
+    /**
+     * Whether this provider requires always monitoring the battery level
+     */
+    public abstract boolean requiresBatteryLevelMonitoring();
+
+    /**
+     * Whether this provider supports the mode.
+     * Available modes:
+     *     - ${@link omnirom.health.HealthInterface#MODE_AUTO}
+     *     - ${@link omnirom.health.HealthInterface#MODE_MANUAL}
+     *     - ${@link omnirom.health.HealthInterface#MODE_LIMIT}
+     */
+    public abstract boolean isChargingControlModeSupported(int mode);
+
+    /**
+     * Whether the HAL supports the mode or modes
+     *
+     * @param mode One or more {@link vendor.lineage.health.ChargingControlSupportedMode}
+     * @return Whether the provider supports the modes
+     */
+    public final boolean isHALModeSupported(int mode) {
+        try {
+            Log.i(TAG, "isSupported mode called, param: " + mode + ", supported: "
+                    + mChargingControl.getSupportedMode());
+            return (mChargingControl.getSupportedMode() & mode) == mode;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Unable to get supported mode from HAL!", e);
+            return false;
+        }
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/health/ccprovider/Deadline.java b/src/org/omnirom/omnilib/internal/health/ccprovider/Deadline.java
new file mode 100644
index 0000000..71c5e90
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/ccprovider/Deadline.java
@@ -0,0 +1,98 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal.health.ccprovider;
+
+import static omnirom.health.HealthInterface.MODE_AUTO;
+import static omnirom.health.HealthInterface.MODE_MANUAL;
+
+import static org.omnirom.omnilib.internal.health.Util.msToString;
+
+import android.content.Context;
+import android.os.RemoteException;
+import android.util.Log;
+
+import vendor.lineage.health.ChargingControlSupportedMode;
+import vendor.lineage.health.IChargingControl;
+
+import java.io.PrintWriter;
+
+public class Deadline extends ChargingControlProvider {
+    private long mSavedTargetTime;
+
+    public Deadline(IChargingControl chargingControl, Context context) {
+        super(context, chargingControl);
+    }
+
+    @Override
+    protected boolean onBatteryChanged(float batteryPct, long startTime, long targetTime,
+            int configMode) {
+        if (targetTime == mSavedTargetTime) {
+            return true;
+        }
+
+        final long currentTime = System.currentTimeMillis();
+        final long deadline = (targetTime - currentTime) / 1000;
+
+        Log.i(TAG, "Setting charge deadline: Deadline (seconds): " + deadline);
+
+        try {
+            mChargingControl.setChargingDeadline(deadline);
+            mSavedTargetTime = targetTime;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to set charging deadline", e);
+            return false;
+        } catch (IllegalStateException e) {
+            // This is possible when the device is just plugged in and the sysfs node is not ready
+            // to be written to
+            Log.e(TAG, "Failed to set charging deadline, will retry on next battery change");
+            return false;
+        }
+
+        return true;
+    }
+
+    @Override
+    protected void onEnabled() {
+        onReset();
+    }
+
+    @Override
+    protected void onDisable() {
+        onReset();
+    }
+
+    @Override
+    protected void onReset() {
+        mSavedTargetTime = 0;
+
+        try {
+            mChargingControl.setChargingDeadline(-1);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to reset charging deadline", e);
+        }
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        pw.println("Provider: " + getClass().getName());
+        pw.println("  mSavedTargetTime: " + mSavedTargetTime);
+    }
+
+    @Override
+    public boolean isSupported() {
+        return isHALModeSupported(ChargingControlSupportedMode.DEADLINE);
+    }
+
+    @Override
+    public boolean isChargingControlModeSupported(int mode) {
+        return mode == MODE_AUTO || mode == MODE_MANUAL;
+    }
+
+    @Override
+    public boolean requiresBatteryLevelMonitoring() {
+        return false;
+    }
+}
diff --git a/src/org/omnirom/omnilib/internal/health/ccprovider/Toggle.java b/src/org/omnirom/omnilib/internal/health/ccprovider/Toggle.java
new file mode 100644
index 0000000..9a675f9
--- /dev/null
+++ b/src/org/omnirom/omnilib/internal/health/ccprovider/Toggle.java
@@ -0,0 +1,258 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.omnirom.omnilib.internal.health.ccprovider;
+
+import static omnirom.health.HealthInterface.MODE_AUTO;
+import static omnirom.health.HealthInterface.MODE_LIMIT;
+import static omnirom.health.HealthInterface.MODE_MANUAL;
+
+import static org.omnirom.omnilib.internal.health.Util.msToString;
+import static org.omnirom.omnilib.internal.health.Util.msToUTCString;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.BatteryManager;
+import android.os.BatteryStatsManager;
+import android.os.BatteryUsageStats;
+import android.os.RemoteException;
+import android.util.Log;
+
+import org.omnirom.omnilib.R;
+
+import vendor.lineage.health.ChargingControlSupportedMode;
+import vendor.lineage.health.IChargingControl;
+
+import java.io.PrintWriter;
+import java.util.Objects;
+
+public class Toggle extends ChargingControlProvider {
+    protected final int mChargingLimitMargin;
+    private final int mChargingTimeMargin;
+
+    private final boolean mToggleSetAlways = mContext.getResources().getBoolean(
+            R.bool.config_chargingControlToggleSetAlways);
+    private boolean mIsLimitSet;
+    private long mSavedTargetTime;
+    private long mEstimatedFullTime;
+    private chgCtrlStage mStage = chgCtrlStage.STAGE_NONE;
+
+    private enum chgCtrlStage {
+        /**
+         * It has no effect
+         */
+        STAGE_NONE,
+
+        /**
+         * The battery level is less than 80%
+         */
+        STAGE_INITIAL,
+
+        /**
+         * The battery level reached 80% and is now waiting
+         */
+        STAGE_WAITING,
+
+        /**
+         * The battery is now charging towards 100%
+         */
+        STAGE_CONTINUE,
+    }
+
+    // Only when the battery level is above this limit will the charging control be activated.
+    private final static int CHARGE_CTRL_MIN_LEVEL = 80;
+
+    public Toggle(IChargingControl chargingControl,
+            Context context) {
+        super(context, chargingControl);
+
+        boolean isBypassSupported = isHALModeSupported(
+                ChargingControlSupportedMode.BYPASS | ChargingControlSupportedMode.TOGGLE);
+        if (!isBypassSupported) {
+            mChargingLimitMargin = mContext.getResources().getInteger(
+                    R.integer.config_chargingControlBatteryRechargeMargin);
+        } else {
+            mChargingLimitMargin = 1;
+        }
+        Log.i(TAG, "isBypassSupported: " + isBypassSupported);
+
+        mChargingTimeMargin = mContext.getResources().getInteger(
+                R.integer.config_chargingControlTimeMargin) * 60 * 1000;
+    }
+
+    @Override
+    public boolean isSupported() {
+        return isHALModeSupported(ChargingControlSupportedMode.TOGGLE);
+    }
+
+    @Override
+    public boolean requiresBatteryLevelMonitoring() {
+        return !isHALModeSupported(ChargingControlSupportedMode.BYPASS);
+    }
+
+    @Override
+    protected boolean onBatteryChanged(float currentPct, int targetPct) {
+        mIsLimitSet = shouldStopCharging(currentPct, targetPct);
+        Log.i(TAG, "Current battery level: " + currentPct + ", target: " + targetPct +
+                ", limit set: " + mIsLimitSet);
+        return setChargingEnabled(!mIsLimitSet);
+    }
+
+    private boolean onStage(chgCtrlStage stage) {
+        switch (stage) {
+            case STAGE_NONE -> {
+                setChargingEnabled(true);
+                return false;
+            }
+            case STAGE_INITIAL, STAGE_CONTINUE -> {
+                return setChargingEnabled(true);
+            }
+            case STAGE_WAITING -> {
+                return setChargingEnabled(false);
+            }
+        }
+
+        return false;
+    }
+
+    private chgCtrlStage getNextStage(float batteryPct, long startTime, long targetTime) {
+        final long currentTime = System.currentTimeMillis();
+        chgCtrlStage stage = mStage;
+
+        IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+        Intent batteryStatus = mContext.registerReceiver(null, ifilter);
+        boolean plugged = batteryStatus.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) != 0;
+
+        if (startTime > currentTime && stage != chgCtrlStage.STAGE_CONTINUE) {
+            // Not yet entering user configured time frame
+            return chgCtrlStage.STAGE_NONE;
+        }
+
+        if (mSavedTargetTime != targetTime && (mSavedTargetTime == 0
+                || mSavedTargetTime >= currentTime)) {
+            Log.i(TAG, "User changed target time, reassign it");
+            mSavedTargetTime = targetTime;
+            stage = chgCtrlStage.STAGE_INITIAL;
+        }
+
+        final BatteryUsageStats batteryUsageStats = Objects.requireNonNull(
+                mContext.getSystemService(
+                        BatteryStatsManager.class)).getBatteryUsageStats();
+        long remaining = batteryUsageStats.getChargeTimeRemainingMs();
+        remaining += mChargingTimeMargin;
+        Log.i(TAG, "Current estimated time to full: " + msToUTCString(remaining));
+
+        long deltaTime = targetTime - currentTime;
+        Log.i(TAG, "Current time to target: " + msToUTCString(deltaTime));
+
+        switch (stage) {
+            case STAGE_NONE, STAGE_INITIAL -> {
+                if (!plugged || batteryPct < CHARGE_CTRL_MIN_LEVEL || remaining == -1) {
+                    // NONE/INITIAL -> INITIAL: If battery level < 80%
+                    return chgCtrlStage.STAGE_INITIAL;
+                } else if (deltaTime > remaining) {
+                    // NONE/INITIAL -> WAITING: battery level >= 80% && Have enough time waiting
+                    mEstimatedFullTime = remaining;
+                    return chgCtrlStage.STAGE_WAITING;
+                } else {
+                    // NONE/INITIAL -> CONTINUE: battery level >= 80% && Not enough time waiting
+                    return chgCtrlStage.STAGE_CONTINUE;
+                }
+            }
+            case STAGE_WAITING -> {
+                if (deltaTime <= mEstimatedFullTime) {
+                    return chgCtrlStage.STAGE_CONTINUE;
+                } else {
+                    return chgCtrlStage.STAGE_WAITING;
+                }
+            }
+            case STAGE_CONTINUE -> {
+                if (!plugged) {
+                    return chgCtrlStage.STAGE_INITIAL;
+                }
+                return chgCtrlStage.STAGE_CONTINUE;
+            }
+        }
+
+        Log.e(TAG, "Possible bug: code reaches out of switch case");
+        return chgCtrlStage.STAGE_NONE;
+    }
+
+    @Override
+    protected boolean onBatteryChanged(float batteryPct, long startTime, long targetTime,
+            int configMode) {
+        if (configMode != MODE_AUTO && configMode != MODE_MANUAL) {
+            Log.e(TAG,
+                    "Possible bug: onBatteryChanged called with unsupported mode: " + configMode);
+            return false;
+        }
+
+        chgCtrlStage prevStage = mStage;
+        mStage = getNextStage(batteryPct, startTime, targetTime);
+        Log.i(TAG, "State change: " + prevStage + " -> " + mStage);
+
+        return onStage(mStage);
+    }
+
+    private boolean setChargingEnabled(boolean enabled) {
+        try {
+            if (mToggleSetAlways) {
+                mChargingControl.setChargingEnabled(enabled);
+            } else if (mChargingControl.getChargingEnabled() != enabled) {
+                mChargingControl.setChargingEnabled(enabled);
+            }
+            return true;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to set charging enabled", e);
+            return false;
+        }
+    }
+
+    @Override
+    protected void onEnabled() {
+        onReset();
+    }
+
+    @Override
+    protected void onDisable() {
+        onReset();
+    }
+
+    @Override
+    protected void onReset() {
+        try {
+            mChargingControl.setChargingEnabled(true);
+            mIsLimitSet = false;
+            mSavedTargetTime = 0;
+            mEstimatedFullTime = 0;
+            mStage = chgCtrlStage.STAGE_NONE;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to set charging enabled", e);
+        }
+    }
+
+    @Override
+    public boolean isChargingControlModeSupported(int mode) {
+        return mode == MODE_AUTO || mode == MODE_MANUAL || mode == MODE_LIMIT;
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        pw.println("Provider: " + getClass().getName());
+        pw.println("  mIsLimitSet: " + mIsLimitSet);
+        pw.println("  mSavedTargetTime: " + msToString(mSavedTargetTime));
+        pw.println("  mEstimatedFullTime: " + msToUTCString(mEstimatedFullTime));
+        pw.println("  mStage: " + mStage);
+        pw.println("  mChargeLimitMargin: " + mChargingLimitMargin);
+    }
+
+    private boolean shouldStopCharging(float currentPct, int targetPct) {
+        if (mIsLimitSet) {
+            return currentPct >= targetPct - mChargingLimitMargin;
+        }
+        return currentPct >= targetPct;
+    }
+}