// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.background_task_scheduler.internal;
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.PersistableBundle;
import androidx.annotation.VisibleForTesting;
import androidx.core.os.BuildCompat;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.components.background_task_scheduler.TaskInfo;
import org.chromium.components.background_task_scheduler.TaskParameters;
import java.util.List;
/**
* An implementation of {@link BackgroundTaskSchedulerDelegate} that uses the system
* {@link JobScheduler} to schedule jobs.
*/
class BackgroundTaskSchedulerJobService implements BackgroundTaskSchedulerDelegate {
private static final String TAG = "BkgrdTaskSchedulerJS";
/** Delta time for expiration checks. Used to make checks after the end time. */
static final long DEADLINE_DELTA_MS = 1000;
/** Clock to use so we can mock time in tests. */
public interface Clock {
long currentTimeMillis();
}
private static Clock sClock = System::currentTimeMillis;
static void setClockForTesting(Clock clock) {
var oldValue = sClock;
sClock = clock;
ResettersForTesting.register(() -> sClock = oldValue);
}
/**
* Checks if a task expired, based on the current time of the service.
*
* @param jobParameters parameters sent to the service, which contain the scheduling information
* regarding expiration.
* @param currentTimeMs the current time of the service.
* @return true if the task expired and false otherwise.
*/
static boolean didTaskExpire(JobParameters jobParameters, long currentTimeMs) {
PersistableBundle extras = jobParameters.getExtras();
if (extras == null || !extras.containsKey(BACKGROUND_TASK_SCHEDULE_TIME_KEY)) {
return false;
}
long scheduleTimeMs = extras.getLong(BACKGROUND_TASK_SCHEDULE_TIME_KEY);
if (extras.containsKey(BACKGROUND_TASK_END_TIME_KEY)) {
long endTimeMs = extras.getLong(BACKGROUND_TASK_END_TIME_KEY);
return TaskInfo.OneOffInfo.getExpirationStatus(
scheduleTimeMs, endTimeMs, currentTimeMs);
} else {
long intervalTimeMs = extras.getLong(BACKGROUND_TASK_INTERVAL_TIME_KEY);
// Based on the JobInfo documentation, attempting to declare a smaller period than
// this when scheduling a job will result in a job that is still periodic, but will
// run with this effective period.
if (intervalTimeMs < JobInfo.getMinPeriodMillis()) {
intervalTimeMs = JobInfo.getMinPeriodMillis();
}
// Since Android N, there was a minimum of 5 min set for the flex value. This
// value is considerably lower from the previous one, since the minimum value
// allowed for the interval time is of 15 min:
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/oreo-release/core/java/android/app/job/JobInfo.java.
long flexTimeMs =
extras.getLong(
BACKGROUND_TASK_FLEX_TIME_KEY,
/* defaultValue= */ JobInfo.getMinFlexMillis());
return TaskInfo.PeriodicInfo.getExpirationStatus(
scheduleTimeMs, intervalTimeMs, flexTimeMs, currentTimeMs);
}
}
/**
* Retrieves the {@link TaskParameters} from the {@link JobParameters}, which are passed as
* one of the keys. Only values valid for {@link android.os.BaseBundle} are supported, and other
* values are stripped at the time when the task is scheduled.
*
* @param jobParameters the {@link JobParameters} to extract the {@link TaskParameters} from.
* @return the {@link TaskParameters} for the current job.
*/
static TaskParameters getTaskParametersFromJobParameters(JobParameters jobParameters) {
TaskParameters.Builder builder = TaskParameters.create(jobParameters.getJobId());
PersistableBundle jobExtras = jobParameters.getExtras();
PersistableBundle persistableTaskExtras =
jobExtras.getPersistableBundle(BACKGROUND_TASK_EXTRAS_KEY);
PersistableBundle taskExtras = new PersistableBundle();
taskExtras.putAll(persistableTaskExtras);
builder.addExtras(taskExtras);
return builder.build();
}
@VisibleForTesting
static JobInfo createJobInfoFromTaskInfo(Context context, TaskInfo taskInfo) {
PersistableBundle jobExtras = new PersistableBundle();
PersistableBundle persistableBundle = taskInfo.getExtras();
jobExtras.putPersistableBundle(BACKGROUND_TASK_EXTRAS_KEY, persistableBundle);
JobInfo.Builder builder =
new JobInfo.Builder(
taskInfo.getTaskId(),
new ComponentName(context, BackgroundTaskJobService.class))
.setPersisted(taskInfo.isPersisted())
.setRequiresCharging(taskInfo.requiresCharging())
.setRequiredNetworkType(
getJobInfoNetworkTypeFromTaskNetworkType(
taskInfo.getRequiredNetworkType()));
if (BuildCompat.isAtLeastU()) {
builder.setUserInitiated(taskInfo.isUserInitiated());
}
JobInfoBuilderVisitor jobInfoBuilderVisitor = new JobInfoBuilderVisitor(builder, jobExtras);
taskInfo.getTimingInfo().accept(jobInfoBuilderVisitor);
builder = jobInfoBuilderVisitor.getBuilder();
return builder.build();
}
private static class JobInfoBuilderVisitor implements TaskInfo.TimingInfoVisitor {
private final JobInfo.Builder mBuilder;
private final PersistableBundle mJobExtras;
JobInfoBuilderVisitor(JobInfo.Builder builder, PersistableBundle jobExtras) {
mBuilder = builder;
mJobExtras = jobExtras;
}
// Only valid after a TimingInfo object was visited.
JobInfo.Builder getBuilder() {
return mBuilder;
}
@Override
public void visit(TaskInfo.OneOffInfo oneOffInfo) {
if (oneOffInfo.expiresAfterWindowEndTime()) {
mJobExtras.putLong(
BackgroundTaskSchedulerDelegate.BACKGROUND_TASK_SCHEDULE_TIME_KEY,
sClock.currentTimeMillis());
mJobExtras.putLong(
BackgroundTaskSchedulerDelegate.BACKGROUND_TASK_END_TIME_KEY,
oneOffInfo.getWindowEndTimeMs());
}
mBuilder.setExtras(mJobExtras);
if (oneOffInfo.hasWindowStartTimeConstraint()) {
long latency = oneOffInfo.getWindowStartTimeMs();
if (latency < 0) {
latency = 0;
}
mBuilder.setMinimumLatency(latency);
}
if (oneOffInfo.hasWindowEndTimeConstraint()) {
long windowEndTimeMs = oneOffInfo.getWindowEndTimeMs();
if (oneOffInfo.expiresAfterWindowEndTime()) {
windowEndTimeMs += DEADLINE_DELTA_MS;
}
mBuilder.setOverrideDeadline(windowEndTimeMs);
}
}
@Override
public void visit(TaskInfo.PeriodicInfo periodicInfo) {
if (periodicInfo.expiresAfterWindowEndTime()) {
mJobExtras.putLong(BACKGROUND_TASK_SCHEDULE_TIME_KEY, sClock.currentTimeMillis());
mJobExtras.putLong(BACKGROUND_TASK_INTERVAL_TIME_KEY, periodicInfo.getIntervalMs());
if (periodicInfo.hasFlex()) {
mJobExtras.putLong(BACKGROUND_TASK_FLEX_TIME_KEY, periodicInfo.getFlexMs());
}
}
mBuilder.setExtras(mJobExtras);
if (periodicInfo.hasFlex()) {
mBuilder.setPeriodic(periodicInfo.getIntervalMs(), periodicInfo.getFlexMs());
return;
}
mBuilder.setPeriodic(periodicInfo.getIntervalMs());
}
}
private static int getJobInfoNetworkTypeFromTaskNetworkType(
@TaskInfo.NetworkType int networkType) {
// The values are hard coded to represent the same as the network type from JobService.
return networkType;
}
@Override
public boolean schedule(Context context, TaskInfo taskInfo) {
ThreadUtils.assertOnUiThread();
JobInfo jobInfo = createJobInfoFromTaskInfo(context, taskInfo);
JobScheduler jobScheduler =
(JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
if (!taskInfo.shouldUpdateCurrent() && hasPendingJob(jobScheduler, taskInfo.getTaskId())) {
return true;
}
// This can fail on heavily modified android builds. Catch so we don't crash.
try {
return jobScheduler.schedule(jobInfo) == JobScheduler.RESULT_SUCCESS;
} catch (Exception e) {
// Typically we don't catch RuntimeException, but this time we do want to catch it
// because we are worried about android as modified by device manufacturers.
Log.e(TAG, "Unable to schedule with Android.", e);
return false;
}
}
@Override
public void cancel(Context context, int taskId) {
ThreadUtils.assertOnUiThread();
JobScheduler jobScheduler =
(JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
try {
jobScheduler.cancel(taskId);
} catch (NullPointerException exception) {
Log.e(TAG, "Failed to cancel task: " + taskId);
}
}
private boolean hasPendingJob(JobScheduler jobScheduler, int jobId) {
List<JobInfo> pendingJobs = jobScheduler.getAllPendingJobs();
for (JobInfo pendingJob : pendingJobs) {
if (pendingJob.getId() == jobId) return true;
}
return false;
}
}