/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.devicelockcontroller; import static com.android.devicelockcontroller.policy.ProvisionStateController.ProvisionState.UNPROVISIONED; import android.annotation.IntDef; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.database.sqlite.SQLiteException; import android.os.Bundle; import android.os.SystemClock; import androidx.annotation.VisibleForTesting; import com.android.devicelockcontroller.policy.DevicePolicyController; import com.android.devicelockcontroller.policy.DeviceStateController; import com.android.devicelockcontroller.policy.PolicyObjectsProvider; import com.android.devicelockcontroller.policy.ProvisionStateController; import com.android.devicelockcontroller.policy.ProvisionStateController.ProvisionState; import com.android.devicelockcontroller.schedule.DeviceLockControllerScheduler; import com.android.devicelockcontroller.schedule.DeviceLockControllerSchedulerProvider; import com.android.devicelockcontroller.storage.GlobalParametersClient; import com.android.devicelockcontroller.util.LogUtil; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.time.Duration; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * Attempt to recover from failed check ins due to disk full. */ public final class WorkManagerExceptionHandler implements Thread.UncaughtExceptionHandler { private static final String TAG = "WorkManagerExceptionHandler"; private static final long RETRY_ALARM_MILLISECONDS = Duration.ofHours(1).toMillis(); @VisibleForTesting public static final String ALARM_REASON = "ALARM_REASON"; private final Executor mWorkManagerTaskExecutor; private final Runnable mTerminateRunnable; private static volatile WorkManagerExceptionHandler sWorkManagerExceptionHandler; /** Alarm reason definitions. */ @Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.SOURCE) @IntDef({ AlarmReason.INITIAL_CHECK_IN, AlarmReason.RETRY_CHECK_IN, AlarmReason.RESCHEDULE_CHECK_IN, AlarmReason.INITIALIZATION, }) public @interface AlarmReason { int INITIAL_CHECK_IN = 0; int RETRY_CHECK_IN = 1; int RESCHEDULE_CHECK_IN = 2; int INITIALIZATION = 3; } /** * Receiver to handle alarms scheduled upon failure to enqueue check-in work due to * SQLite exceptions, or WorkManager initialization failures. * This receiver tries to recover the check-in process, if still needed. */ public static final class WorkFailureAlarmReceiver extends BroadcastReceiver { private final Executor mExecutor = Executors.newSingleThreadExecutor(); @Override public void onReceive(Context context, Intent intent) { if (!WorkFailureAlarmReceiver.class.getName().equals(intent.getComponent() .getClassName())) { throw new IllegalArgumentException("Can not handle implicit intent!"); } final Bundle bundle = intent.getExtras(); if (bundle == null) { throw new IllegalArgumentException("Intent has no bundle"); } final @AlarmReason int alarmReason = bundle.getInt(ALARM_REASON, -1 /* undefined */); if (alarmReason < 0) { throw new IllegalArgumentException("Missing alarm reason"); } LogUtil.i(TAG, "Received alarm to recover from WorkManager exception with reason: " + alarmReason); final PendingResult pendingResult = goAsync(); final ListenableFuture checkInIfNotYetProvisioned = Futures.transformAsync(GlobalParametersClient.getInstance().isProvisionReady(), isProvisionReady -> { if (isProvisionReady) { // Already provisioned, no need to check in return Futures.immediateVoidFuture(); } else { return getCheckInFuture(context, alarmReason); } }, mExecutor); Futures.addCallback(checkInIfNotYetProvisioned, new FutureCallback<>() { @Override public void onSuccess(Void result) { LogUtil.i(TAG, "Successfully scheduled check in after WorkManager exception"); pendingResult.finish(); } @Override public void onFailure(Throwable t) { LogUtil.e(TAG, "Failed to schedule check in after WorkManager exception", t); pendingResult.finish(); } }, mExecutor); } private ListenableFuture getCheckInFuture(Context context, @AlarmReason int alarmReason) { final DeviceLockControllerSchedulerProvider schedulerProvider = (DeviceLockControllerSchedulerProvider) context.getApplicationContext(); final DeviceLockControllerScheduler scheduler = schedulerProvider.getDeviceLockControllerScheduler(); ListenableFuture checkInOperation; switch (alarmReason) { case AlarmReason.INITIAL_CHECK_IN: case AlarmReason.INITIALIZATION: checkInOperation = scheduler.maybeScheduleInitialCheckIn(); break; case AlarmReason.RETRY_CHECK_IN: // Use zero as delay since this is a corner case. We will eventually get the // proper value from the server. checkInOperation = scheduler.scheduleRetryCheckInWork(Duration.ZERO); break; case AlarmReason.RESCHEDULE_CHECK_IN: checkInOperation = scheduler.notifyNeedRescheduleCheckIn(); break; default: throw new IllegalArgumentException("Invalid alarm reason"); } return checkInOperation; } } private Executor createWorkManagerTaskExecutor(Context context) { final ThreadFactory threadFactory = new ThreadFactory() { private final AtomicInteger mThreadCount = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread thread = new DlcWmThread(context, r, "DLC.WorkManager.task-" + mThreadCount.incrementAndGet()); thread.setUncaughtExceptionHandler(WorkManagerExceptionHandler.this); return thread; } }; // Same as the one used by WorkManager internally. return Executors.newFixedThreadPool(Math.max(2, Math.min(Runtime.getRuntime().availableProcessors() - 1, 4)), threadFactory); } private static final class DlcWmThread extends Thread { private final Thread.UncaughtExceptionHandler mOriginalUncaughtExceptionHandler; private final Context mContext; DlcWmThread(Context context, Runnable target, String name) { super(target, name); mContext = context; mOriginalUncaughtExceptionHandler = getUncaughtExceptionHandler(); } UncaughtExceptionHandler getOriginalUncaughtExceptionHandler() { return mOriginalUncaughtExceptionHandler; } Context getContext() { return mContext; } } /** * Schedule an alarm to restart the check in process in case of critical failures. * This is called if we failed to enqueue the check in work. */ public static void scheduleAlarm(Context context, @AlarmReason int alarmReason) { final AlarmManager alarmManager = context.getSystemService(AlarmManager.class); final Intent intent = new Intent(context, WorkFailureAlarmReceiver.class); final Bundle bundle = new Bundle(); bundle.putInt(ALARM_REASON, alarmReason); intent.putExtras(bundle); final PendingIntent alarmIntent = PendingIntent.getBroadcast(context, /* requestCode = */ 0, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); alarmManager.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + RETRY_ALARM_MILLISECONDS, alarmIntent); LogUtil.i(TAG, "Alarm scheduled, reason: " + alarmReason); } /** * Schedule an alarm to restart the app in case of critical failures. * This is called if we failed to initialize WorkManager. */ public void scheduleAlarmAndTerminate(Context context, @AlarmReason int alarmReason) { scheduleAlarm(context, alarmReason); // Terminate the process without calling the original uncaught exception handler, // otherwise the alarm may be canceled if there are several crashes in a short period // of time (similar to what happens in the force stopped case). LogUtil.i(TAG, "Terminating Device Lock Controller because of a critical failure."); mTerminateRunnable.run(); } @VisibleForTesting WorkManagerExceptionHandler(Context context, Runnable terminateRunnable) { mWorkManagerTaskExecutor = createWorkManagerTaskExecutor(context); mTerminateRunnable = terminateRunnable; } /** * Get the only instance of WorkManagerExceptionHandler. */ public static WorkManagerExceptionHandler getInstance(Context context) { if (sWorkManagerExceptionHandler == null) { synchronized (WorkManagerExceptionHandler.class) { if (sWorkManagerExceptionHandler == null) { sWorkManagerExceptionHandler = new WorkManagerExceptionHandler(context, () -> System.exit(0)); } } } return sWorkManagerExceptionHandler; } Executor getWorkManagerTaskExecutor() { return mWorkManagerTaskExecutor; } // Called when one of the internal task threads of WorkManager throws an exception. // We're interested in some exceptions subclass of SQLiteException (like SQLiteFullException) // since it's not handled in initializationExceptionHandler. @Override public void uncaughtException(Thread t, Throwable e) { LogUtil.e(TAG, "Uncaught exception in WorkManager task", e); if (!(t instanceof DlcWmThread)) { throw new RuntimeException("Thread is not a DlcWmThread", e); } if (e instanceof SQLiteException) { handleWorkManagerException(((DlcWmThread) t).getContext(), e); } else { final Thread.UncaughtExceptionHandler originalExceptionHandler = ((DlcWmThread) t).getOriginalUncaughtExceptionHandler(); originalExceptionHandler.uncaughtException(t, e); } } private void handleWorkManagerException(Context context, Throwable t) { Futures.addCallback(handleException(context, t), new FutureCallback<>() { @Override public void onSuccess(Void result) { // No-op } @Override public void onFailure(Throwable e) { LogUtil.e(TAG, "Error handling WorkManager exception", e); } }, mWorkManagerTaskExecutor); } // This is setup in WM configuration and is called when initialization fails. It does not // include the SQLiteFullException case. void initializationExceptionHandler(Context context, Throwable t) { LogUtil.e(TAG, "WorkManager initialization error", t); handleWorkManagerException(context, t); } @VisibleForTesting ListenableFuture handleException(Context context, Throwable t) { final Context applicationContext = context.getApplicationContext(); final PolicyObjectsProvider policyObjectsProvider = (PolicyObjectsProvider) applicationContext; final ProvisionStateController provisionStateController = policyObjectsProvider.getProvisionStateController(); final DeviceStateController deviceStateController = policyObjectsProvider.getDeviceStateController(); final ListenableFuture<@ProvisionState Integer> provisionStateFuture = provisionStateController.getState(); final ListenableFuture isClearedFuture = deviceStateController.isCleared(); return Futures.whenAllSucceed(provisionStateFuture, isClearedFuture).call(() -> { final @ProvisionState Integer provisionState = Futures.getDone(provisionStateFuture); if (provisionState == UNPROVISIONED) { scheduleAlarmAndTerminate(context, AlarmReason.INITIALIZATION); } else if (!Futures.getDone(isClearedFuture)) { LogUtil.e(TAG, "Resetting device, current provisioning state: " + provisionState, t); final DevicePolicyController devicePolicyController = policyObjectsProvider.getPolicyController(); devicePolicyController.wipeDevice(); } else { LogUtil.w(TAG, "Device won't be reset (restrictions cleared)"); } return null; }, mWorkManagerTaskExecutor); } }