| /* |
| * Copyright (C) 2024 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.server.art; |
| |
| import static com.android.server.art.model.ArtFlags.ScheduleStatus; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.job.JobInfo; |
| import android.app.job.JobParameters; |
| import android.app.job.JobScheduler; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.os.Binder; |
| import android.os.Build; |
| import android.os.CancellationSignal; |
| import android.os.PersistableBundle; |
| import android.os.RemoteException; |
| import android.os.ServiceSpecificException; |
| import android.os.SystemProperties; |
| import android.provider.DeviceConfig; |
| |
| import androidx.annotation.RequiresApi; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.server.art.model.ArtFlags; |
| import com.android.server.art.model.ArtServiceJobInterface; |
| import com.android.server.art.prereboot.PreRebootDriver; |
| import com.android.server.art.prereboot.PreRebootStatsReporter; |
| |
| import java.time.Duration; |
| import java.util.Objects; |
| import java.util.UUID; |
| import java.util.concurrent.CompletableFuture; |
| |
| /** |
| * The Pre-reboot Dexopt job. |
| * |
| * During Pre-reboot Dexopt, the old version of this code is run. |
| * |
| * @hide |
| */ |
| @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) |
| public class PreRebootDexoptJob implements ArtServiceJobInterface { |
| /** |
| * "android" is the package name for a <service> declared in |
| * frameworks/base/core/res/AndroidManifest.xml |
| */ |
| private static final String JOB_PKG_NAME = Utils.PLATFORM_PACKAGE_NAME; |
| /** An arbitrary number. Must be unique among all jobs owned by the system uid. */ |
| public static final int JOB_ID = 27873781; |
| |
| @NonNull private final Injector mInjector; |
| |
| // Job state variables. The monitor of `this` is notified when `mRunningJob` is changed. |
| @GuardedBy("this") @Nullable private CompletableFuture<Void> mRunningJob = null; |
| @GuardedBy("this") @Nullable private CancellationSignal mCancellationSignal = null; |
| |
| /** Whether `mRunningJob` is running from the job scheduler's perspective. */ |
| @GuardedBy("this") private boolean mIsRunningJobKnownByJobScheduler = false; |
| |
| /** The slot that contains the OTA update, "_a" or "_b", or null for a Mainline update. */ |
| @GuardedBy("this") @Nullable private String mOtaSlot = null; |
| |
| // Mutations to the global state of Pre-reboot Dexopt, including mounts, staged files, and |
| // stats, should only be done when there is no job running and the `this` lock is held, or by |
| // the job itself. |
| |
| public PreRebootDexoptJob(@NonNull Context context) { |
| this(new Injector(context)); |
| } |
| |
| @VisibleForTesting |
| public PreRebootDexoptJob(@NonNull Injector injector) { |
| mInjector = injector; |
| } |
| |
| @Override |
| public synchronized boolean onStartJob( |
| @NonNull BackgroundDexoptJobService jobService, @NonNull JobParameters params) { |
| JobInfo pendingJob = mInjector.getJobScheduler().getPendingJob(JOB_ID); |
| if (pendingJob == null |
| || !params.getExtras().getString("ticket").equals( |
| pendingJob.getExtras().getString("ticket"))) { |
| // Job expired. We can only get here due to a race, and this should be very rare. |
| return false; |
| } |
| |
| mIsRunningJobKnownByJobScheduler = true; |
| // No need to handle exceptions thrown by the future because exceptions are handled inside |
| // the future itself. |
| startLocked(() -> { |
| if (mIsRunningJobKnownByJobScheduler) { |
| mIsRunningJobKnownByJobScheduler = false; |
| // If it failed, it means something went wrong, so we don't reschedule the job |
| // because it will likely fail again. If it's cancelled, the job will be rescheduled |
| // because the return value of `onStopJob` will be respected, and this call will be |
| // skipped. |
| jobService.jobFinished(params, false /* wantsReschedule */); |
| } |
| }); |
| // "true" means the job will continue running until `jobFinished` is called. |
| return true; |
| } |
| |
| @Override |
| public synchronized boolean onStopJob(@NonNull JobParameters params) { |
| if (mIsRunningJobKnownByJobScheduler) { |
| cancelGivenLocked(mRunningJob); |
| mIsRunningJobKnownByJobScheduler = false; |
| } |
| // "true" means to execute again with the default retry policy. |
| return true; |
| } |
| |
| /** |
| * Notifies this class that an update (OTA or Mainline) is ready. |
| * |
| * @param otaSlot The slot that contains the OTA update, "_a" or "_b", or null for a Mainline |
| * update. |
| */ |
| public synchronized @ScheduleStatus int onUpdateReady(@Nullable String otaSlot) { |
| cancelAnyLocked(); |
| resetLocked(); |
| updateOtaSlotLocked(otaSlot); |
| return scheduleLocked(); |
| } |
| |
| public synchronized void test() { |
| cancelAnyLocked(); |
| mInjector.getPreRebootDriver().test(); |
| } |
| |
| @VisibleForTesting |
| public synchronized void waitForRunningJob() { |
| while (mRunningJob != null) { |
| try { |
| this.wait(); |
| } catch (InterruptedException e) { |
| AsLog.wtf("Interrupted", e); |
| } |
| } |
| } |
| |
| @GuardedBy("this") |
| private @ScheduleStatus int scheduleLocked() { |
| if (this != BackgroundDexoptJobService.getJob(JOB_ID)) { |
| throw new IllegalStateException("This job cannot be scheduled"); |
| } |
| |
| if (!isEnabled()) { |
| return ArtFlags.SCHEDULE_DISABLED_BY_SYSPROP; |
| } |
| |
| // If `pm.dexopt.disable_bg_dexopt` is set, the user probably means to disable any dexopt |
| // jobs in the background. |
| if (SystemProperties.getBoolean("pm.dexopt.disable_bg_dexopt", false /* def */)) { |
| AsLog.i("Pre-reboot Dexopt Job is disabled by system property " |
| + "'pm.dexopt.disable_bg_dexopt'"); |
| return ArtFlags.SCHEDULE_DISABLED_BY_SYSPROP; |
| } |
| |
| String ticket = UUID.randomUUID().toString(); |
| PersistableBundle extras = new PersistableBundle(1 /* capacity */); |
| extras.putString("ticket", ticket); |
| JobInfo info = new JobInfo |
| .Builder(JOB_ID, |
| new ComponentName(JOB_PKG_NAME, |
| BackgroundDexoptJobService.class.getName())) |
| .setExtras(extras) |
| .setRequiresDeviceIdle(true) |
| .setRequiresCharging(true) |
| .setRequiresBatteryNotLow(true) |
| // The latency is to wait for update_engine to finish. |
| .setMinimumLatency(Duration.ofMinutes(10).toMillis()) |
| .build(); |
| |
| /* @JobScheduler.Result */ int result; |
| |
| // This operation requires the uid to be "system" (1000). |
| long identityToken = Binder.clearCallingIdentity(); |
| try { |
| result = mInjector.getJobScheduler().schedule(info); |
| } finally { |
| Binder.restoreCallingIdentity(identityToken); |
| } |
| |
| if (result == JobScheduler.RESULT_SUCCESS) { |
| AsLog.i("Pre-reboot Dexopt Job scheduled"); |
| mInjector.getStatsReporter().recordJobScheduled(); |
| return ArtFlags.SCHEDULE_SUCCESS; |
| } else { |
| AsLog.i("Failed to schedule Pre-reboot Dexopt Job"); |
| return ArtFlags.SCHEDULE_JOB_SCHEDULER_FAILURE; |
| } |
| } |
| |
| @GuardedBy("this") |
| private void unscheduleLocked() { |
| if (this != BackgroundDexoptJobService.getJob(JOB_ID)) { |
| throw new IllegalStateException("This job cannot be unscheduled"); |
| } |
| |
| // This operation requires the uid to be "system" (1000). |
| long identityToken = Binder.clearCallingIdentity(); |
| try { |
| mInjector.getJobScheduler().cancel(JOB_ID); |
| } finally { |
| Binder.restoreCallingIdentity(identityToken); |
| } |
| } |
| |
| /** |
| * The future returns true if the job is cancelled by the job scheduler. |
| * |
| * Can only be called when there is no running job. |
| */ |
| @GuardedBy("this") |
| @NonNull |
| private CompletableFuture<Void> startLocked(@Nullable Runnable onJobFinishedLocked) { |
| Utils.check(mRunningJob == null); |
| |
| String otaSlot = mOtaSlot; |
| var cancellationSignal = mCancellationSignal = new CancellationSignal(); |
| mRunningJob = new CompletableFuture().runAsync(() -> { |
| markHasStarted(true); |
| try { |
| mInjector.getPreRebootDriver().run(otaSlot, cancellationSignal); |
| } catch (RuntimeException e) { |
| AsLog.e("Fatal error", e); |
| } finally { |
| synchronized (this) { |
| if (onJobFinishedLocked != null) { |
| try { |
| onJobFinishedLocked.run(); |
| } catch (RuntimeException e) { |
| AsLog.wtf("Unexpected exception", e); |
| } |
| } |
| mRunningJob = null; |
| mCancellationSignal = null; |
| this.notifyAll(); |
| } |
| } |
| }); |
| this.notifyAll(); |
| return mRunningJob; |
| } |
| |
| /** |
| * Cancels the given job and waits for it to exit, if it's running. Temporarily releases the |
| * lock when waiting for the job to exit. |
| * |
| * When this method exits, it's guaranteed that the given job is not running, but another job |
| * might be running. |
| */ |
| @GuardedBy("this") |
| private void cancelGivenLocked(@NonNull CompletableFuture<Void> job) { |
| while (mRunningJob == job) { |
| if (!mCancellationSignal.isCanceled()) { |
| mCancellationSignal.cancel(); |
| AsLog.i("Job cancelled"); |
| } |
| try { |
| this.wait(); |
| } catch (InterruptedException e) { |
| AsLog.wtf("Interrupted", e); |
| } |
| } |
| } |
| |
| /** |
| * Cancels any running job, prevents the pending job (if any) from being started by the job |
| * scheduler, and waits for the running job to exit. Temporarily releases the lock when waiting |
| * for the job to exit. |
| * |
| * When this method exits, it's guaranteed that no job is running. |
| */ |
| @GuardedBy("this") |
| private void cancelAnyLocked() { |
| unscheduleLocked(); |
| while (mRunningJob != null) { |
| if (!mCancellationSignal.isCanceled()) { |
| mCancellationSignal.cancel(); |
| AsLog.i("Job cancelled"); |
| } |
| try { |
| this.wait(); |
| } catch (InterruptedException e) { |
| AsLog.wtf("Interrupted", e); |
| } |
| } |
| } |
| |
| @GuardedBy("this") |
| private void updateOtaSlotLocked(@Nullable String value) { |
| Utils.check(value == null || value.equals("_a") || value.equals("_b")); |
| // It's not possible that this method is called with two different slots. |
| Utils.check(mOtaSlot == null || value == null || Objects.equals(mOtaSlot, value)); |
| // An OTA update has a higher priority than a Mainline update. When there are both a pending |
| // OTA update and a pending Mainline update, the system discards the Mainline update on the |
| // reboot. |
| if (mOtaSlot == null && value != null) { |
| mOtaSlot = value; |
| } |
| } |
| |
| private boolean isEnabled() { |
| boolean syspropEnable = |
| SystemProperties.getBoolean("dalvik.vm.enable_pr_dexopt", false /* def */); |
| boolean deviceConfigEnable = mInjector.getDeviceConfigBoolean( |
| DeviceConfig.NAMESPACE_RUNTIME, "enable_pr_dexopt", false /* defaultValue */); |
| boolean deviceConfigForceDisable = |
| mInjector.getDeviceConfigBoolean(DeviceConfig.NAMESPACE_RUNTIME, |
| "force_disable_pr_dexopt", false /* defaultValue */); |
| if ((!syspropEnable && !deviceConfigEnable) || deviceConfigForceDisable) { |
| AsLog.i(String.format( |
| "Pre-reboot Dexopt Job is not enabled (sysprop:dalvik.vm.enable_pr_dexopt=%b, " |
| + "device_config:enable_pr_dexopt=%b, " |
| + "device_config:force_disable_pr_dexopt=%b)", |
| syspropEnable, deviceConfigEnable, deviceConfigForceDisable)); |
| return false; |
| } |
| return true; |
| } |
| |
| @GuardedBy("this") |
| private void resetLocked() { |
| mInjector.getStatsReporter().delete(); |
| if (hasStarted()) { |
| try { |
| mInjector.getArtd().cleanUpPreRebootStagedFiles(); |
| } catch (ServiceSpecificException | RemoteException e) { |
| AsLog.e("Failed to clean up obsolete Pre-reboot staged files", e); |
| } |
| markHasStarted(false); |
| } |
| } |
| |
| /** |
| * Whether the job has started at least once, meaning the device is expected to have staged |
| * files, no matter it succeed, failed, or cancelled. |
| * |
| * This flag is survives across system server restarts, but not device reboots. |
| */ |
| public boolean hasStarted() { |
| return SystemProperties.getBoolean("dalvik.vm.pre-reboot.has-started", false /* def */); |
| } |
| |
| private void markHasStarted(boolean value) { |
| ArtJni.setProperty("dalvik.vm.pre-reboot.has-started", String.valueOf(value)); |
| } |
| |
| /** |
| * Injector pattern for testing purpose. |
| * |
| * @hide |
| */ |
| @VisibleForTesting |
| public static class Injector { |
| @NonNull private final Context mContext; |
| |
| Injector(@NonNull Context context) { |
| mContext = context; |
| } |
| |
| @NonNull |
| public JobScheduler getJobScheduler() { |
| return Objects.requireNonNull(mContext.getSystemService(JobScheduler.class)); |
| } |
| |
| @NonNull |
| public PreRebootDriver getPreRebootDriver() { |
| return new PreRebootDriver(mContext); |
| } |
| |
| @NonNull |
| public PreRebootStatsReporter getStatsReporter() { |
| return new PreRebootStatsReporter(); |
| } |
| |
| @NonNull |
| public IArtd getArtd() { |
| return ArtdRefCache.getInstance().getArtd(); |
| } |
| |
| // Wrap `DeviceConfig` to avoid mocking it directly in tests. `DeviceConfig` backs |
| // read-write Trunk Stable flags used by the framework. |
| @NonNull |
| public boolean getDeviceConfigBoolean( |
| @NonNull String namespace, @NonNull String name, boolean defaultValue) { |
| return DeviceConfig.getBoolean(namespace, name, defaultValue); |
| } |
| } |
| } |