Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8a7af5a9 authored by Luofan Chen's avatar Luofan Chen
Browse files

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
parent 8709a3a5
Loading
Loading
Loading
Loading
+135 −554

File changed.

Preview size limit exceeded, changes collapsed.

+213 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
 * SPDX-License-Identifier: Apache-2.0
 */

package org.lineageos.platform.internal.health;

import static org.lineageos.platform.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.lineageos.platform.internal.R;

public class ChargingControlNotification {
    private final NotificationManager mNotificationManager;
    private final Context mContext;

    private static final String INTENT_PARTS =
            "org.lineageos.lineageparts.CHARGING_CONTROL_SETTINGS";

    private static final int CHARGING_CONTROL_NOTIFICATION_ID = 1000;
    private static final String ACTION_CHARGING_CONTROL_CANCEL_ONCE =
            "lineageos.platform.intent.action.CHARGING_CONTROL_CANCEL_ONCE";
    private static final String CHARGING_CONTROL_CHANNEL_ID = "LineageHealthChargingControl";

    private 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 LineageHealthNotificationBroadcastReceiver(),
                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 LineageHealthNotificationBroadcastReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            handleNotificationIntent(intent);
        }
    }
}
+68 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
 * SPDX-License-Identifier: Apache-2.0
 */

package org.lineageos.platform.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();
    }
}
+167 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
 * SPDX-License-Identifier: Apache-2.0
 */

package org.lineageos.platform.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 = "LineageHealth";

    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 lineageos.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 lineageos.health.HealthInterface#MODE_AUTO} or
     * {@link lineageos.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 lineageos.health.HealthInterface#MODE_AUTO} or
     *                   {@link lineageos.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 lineageos.health.HealthInterface#MODE_AUTO}
     *     - ${@link lineageos.health.HealthInterface#MODE_MANUAL}
     *     - ${@link lineageos.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;
        }
    }
}
+98 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2024-2025 The LineageOS Project
 * SPDX-License-Identifier: Apache-2.0
 */

package org.lineageos.platform.internal.health.ccprovider;

import static lineageos.health.HealthInterface.MODE_AUTO;
import static lineageos.health.HealthInterface.MODE_MANUAL;

import static org.lineageos.platform.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;
    }
}
Loading