SystemJobScheduler.java

/*
 * Copyright 2017 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 androidx.work.impl.background.systemjob;

import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.Context;
import android.os.Build;
import android.os.PersistableBundle;

import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.work.Logger;
import androidx.work.WorkInfo;
import androidx.work.impl.Scheduler;
import androidx.work.impl.WorkDatabase;
import androidx.work.impl.WorkManagerImpl;
import androidx.work.impl.model.SystemIdInfo;
import androidx.work.impl.model.WorkSpec;
import androidx.work.impl.utils.IdGenerator;

import java.util.List;
import java.util.Locale;

/**
 * A class that schedules work using {@link android.app.job.JobScheduler}.
 *
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@RequiresApi(WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL)
public class SystemJobScheduler implements Scheduler {

    private static final String TAG = Logger.tagWithPrefix("SystemJobScheduler");

    private final JobScheduler mJobScheduler;
    private final WorkManagerImpl mWorkManager;
    private final IdGenerator mIdGenerator;
    private final SystemJobInfoConverter mSystemJobInfoConverter;

    public SystemJobScheduler(@NonNull Context context, @NonNull WorkManagerImpl workManager) {
        this(context,
                workManager,
                (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE),
                new SystemJobInfoConverter(context));
    }

    @VisibleForTesting
    public SystemJobScheduler(
            Context context,
            WorkManagerImpl workManager,
            JobScheduler jobScheduler,
            SystemJobInfoConverter systemJobInfoConverter) {
        mWorkManager = workManager;
        mJobScheduler = jobScheduler;
        mIdGenerator = new IdGenerator(context);
        mSystemJobInfoConverter = systemJobInfoConverter;
    }

    @Override
    public void schedule(WorkSpec... workSpecs) {
        WorkDatabase workDatabase = mWorkManager.getWorkDatabase();

        for (WorkSpec workSpec : workSpecs) {
            workDatabase.beginTransaction();
            try {
                // It is possible that this WorkSpec got cancelled/pruned since this isn't part of
                // the same database transaction as marking it enqueued (for example, if we using
                // any of the synchronous operations).  For now, handle this gracefully by exiting
                // the loop.  When we plumb ListenableFutures all the way through, we can remove the
                // *sync methods and return ListenableFutures, which will block on an operation on
                // the background task thread so all database operations happen on the same thread.
                // See b/114705286.
                WorkSpec currentDbWorkSpec = workDatabase.workSpecDao().getWorkSpec(workSpec.id);
                if (currentDbWorkSpec == null) {
                    Logger.get().warning(
                            TAG,
                            "Skipping scheduling " + workSpec.id
                                    + " because it's no longer in the DB");
                    continue;
                } else if (currentDbWorkSpec.state != WorkInfo.State.ENQUEUED) {
                    Logger.get().warning(
                            TAG,
                            "Skipping scheduling " + workSpec.id
                                    + " because it is no longer enqueued");
                    continue;
                }

                SystemIdInfo info = workDatabase.systemIdInfoDao()
                        .getSystemIdInfo(workSpec.id);

                if (info != null) {
                    JobInfo jobInfo = getPendingJobInfo(mJobScheduler, workSpec.id);
                    if (jobInfo != null) {
                        Logger.get().debug(TAG, String.format(
                                "Skipping scheduling %s because JobScheduler is aware of it "
                                        + "already.",
                                workSpec.id));
                        continue;
                    }
                }

                int jobId = info != null ? info.systemId : mIdGenerator.nextJobSchedulerIdWithRange(
                        mWorkManager.getConfiguration().getMinJobSchedulerId(),
                        mWorkManager.getConfiguration().getMaxJobSchedulerId());

                if (info == null) {
                    SystemIdInfo newSystemIdInfo = new SystemIdInfo(workSpec.id, jobId);
                    mWorkManager.getWorkDatabase()
                            .systemIdInfoDao()
                            .insertSystemIdInfo(newSystemIdInfo);
                }

                scheduleInternal(workSpec, jobId);

                // API 23 JobScheduler only kicked off jobs if there were at least two jobs in the
                // queue, even if the job constraints were met.  This behavior was considered
                // undesirable and later changed in Marshmallow MR1.  To match the new behavior,
                // we will double-schedule jobs on API 23 and de-dupe them
                // in SystemJobService as needed.
                if (Build.VERSION.SDK_INT == 23) {
                    int nextJobId = mIdGenerator.nextJobSchedulerIdWithRange(
                            mWorkManager.getConfiguration().getMinJobSchedulerId(),
                            mWorkManager.getConfiguration().getMaxJobSchedulerId());

                    scheduleInternal(workSpec, nextJobId);
                }

                workDatabase.setTransactionSuccessful();
            } finally {
                workDatabase.endTransaction();
            }
        }
    }

    /**
     * Schedules one job with JobScheduler.
     *
     * @param workSpec The {@link WorkSpec} to schedule with JobScheduler.
     */
    @VisibleForTesting
    public void scheduleInternal(WorkSpec workSpec, int jobId) {
        JobInfo jobInfo = mSystemJobInfoConverter.convert(workSpec, jobId);
        Logger.get().debug(
                TAG,
                String.format("Scheduling work ID %s Job ID %s", workSpec.id, jobId));
        try {
            mJobScheduler.schedule(jobInfo);
        } catch (IllegalStateException e) {
            // This only gets thrown if we exceed 100 jobs.  Let's figure out if WorkManager is
            // responsible for all these jobs.
            int numWorkManagerJobs = 0;
            List<JobInfo> allJobInfos = mJobScheduler.getAllPendingJobs();
            if (allJobInfos != null) {  // Apparently this CAN be null on API 23?
                for (JobInfo currentJobInfo : allJobInfos) {
                    if (currentJobInfo.getExtras().getString(
                            SystemJobInfoConverter.EXTRA_WORK_SPEC_ID) != null) {
                        ++numWorkManagerJobs;
                    }
                }
            }

            String message = String.format(Locale.getDefault(),
                    "JobScheduler 100 job limit exceeded.  We count %d WorkManager "
                            + "jobs in JobScheduler; we have %d tracked jobs in our DB; "
                            + "our Configuration limit is %d.",
                    numWorkManagerJobs,
                    mWorkManager.getWorkDatabase().workSpecDao().getScheduledWork().size(),
                    mWorkManager.getConfiguration().getMaxSchedulerLimit());

            Logger.get().error(TAG, message);

            // Rethrow a more verbose exception.
            throw new IllegalStateException(message, e);
        }
    }

    @Override
    public void cancel(@NonNull String workSpecId) {
        // Note: despite what the word "pending" and the associated Javadoc might imply, this is
        // actually a list of all unfinished jobs that JobScheduler knows about for the current
        // process.
        List<JobInfo> allJobInfos = mJobScheduler.getAllPendingJobs();
        if (allJobInfos != null) {  // Apparently this CAN be null on API 23?
            for (JobInfo jobInfo : allJobInfos) {
                if (workSpecId.equals(
                        jobInfo.getExtras().getString(SystemJobInfoConverter.EXTRA_WORK_SPEC_ID))) {

                    // Its safe to call this method twice.
                    mWorkManager.getWorkDatabase()
                            .systemIdInfoDao()
                            .removeSystemIdInfo(workSpecId);

                    mJobScheduler.cancel(jobInfo.getId());

                    // See comment in #schedule.
                    if (Build.VERSION.SDK_INT != 23) {
                        return;
                    }
                }
            }
        }
    }

    /**
     * Cancels all the jobs owned by {@link androidx.work.WorkManager} in {@link JobScheduler}.
     */
    public static void jobSchedulerCancelAll(@NonNull Context context) {
        JobScheduler jobScheduler = (JobScheduler)
                context.getSystemService(Context.JOB_SCHEDULER_SERVICE);

        if (jobScheduler != null) {
            List<JobInfo> jobInfos = jobScheduler.getAllPendingJobs();
            // Apparently this can be null on API 23?
            if (jobInfos != null) {
                for (JobInfo jobInfo : jobInfos) {
                    PersistableBundle extras = jobInfo.getExtras();
                    // This is a job scheduled by WorkManager.
                    if (extras.containsKey(SystemJobInfoConverter.EXTRA_WORK_SPEC_ID)) {
                        jobScheduler.cancel(jobInfo.getId());
                    }
                }
            }
        }
    }

    private static JobInfo getPendingJobInfo(
            @NonNull JobScheduler jobScheduler,
            @NonNull String workSpecId) {

        List<JobInfo> jobInfos = jobScheduler.getAllPendingJobs();
        // Apparently this CAN be null on API 23?
        if (jobInfos != null) {
            for (JobInfo jobInfo : jobInfos) {
                PersistableBundle extras = jobInfo.getExtras();
                if (extras != null
                        && extras.containsKey(SystemJobInfoConverter.EXTRA_WORK_SPEC_ID)) {
                    if (workSpecId.equals(
                            extras.getString(SystemJobInfoConverter.EXTRA_WORK_SPEC_ID))) {
                        return jobInfo;
                    }
                }
            }
        }
        return null;
    }
}