package me.tatarka.support.internal.receivers;

import android.annotation.TargetApi;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.PowerManager;
import android.os.SystemClock;
import android.support.v4.content.WakefulBroadcastReceiver;

import me.tatarka.support.internal.job.JobServiceCompat;
import me.tatarka.support.internal.job.JobStore;
import me.tatarka.support.internal.util.ArraySet;

/**
 * Created by evantatarka on 11/8/14.
 */
public class IdleReceiver extends WakefulBroadcastReceiver {
    // Policy: we decide that we're "idle" if the device has been unused /
    // screen off or dreaming for at least this long
    private static final long INACTIVITY_IDLE_THRESHOLD = 71 * 60 * 1000; // millis; 71 min
    private static final long IDLE_WINDOW_SLOP = 5 * 60 * 1000; // 5 minute window, to be nice
    private static final long INACTIVITY_ANYWAY_THRESHOLD = 97 * 60 * 1000; // millis; 97 min

    private static final String ACTION_TRIGGER_IDLE =
            "me.tatarka.support.server.job.controllers.IdleReceiver.ACTION_TRIGGER_IDLE";

    private static IdleReceiver sReceiver;

    @Override
    public void onReceive(Context context, Intent intent) {
        final String action = intent.getAction();

        ControllerPrefs prefs = ControllerPrefs.getInstance(context);
        boolean isDaydream = prefs.isInDaydreamMode();
        if (action.equals(Intent.ACTION_DREAMING_STARTED)) {
            prefs.edit().setInDaydreamMode(true).apply();
            isDaydream = true;
        } else if (action.equals(Intent.ACTION_DREAMING_STOPPED)) {
            prefs.edit().setInDaydreamMode(false).apply();
            isDaydream = false;
        }

        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        boolean isInteractive = pm.isScreenOn();

        boolean isIdle = !isInteractive || isDaydream;

        if (action.equals(Intent.ACTION_SCREEN_ON)
                || action.equals(Intent.ACTION_DREAMING_STOPPED)) {
            // possible transition to not-idle

            if (!isIdle) {
                AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
                Intent i = new Intent(context, IdleReceiver.class);
                i.setAction(ACTION_TRIGGER_IDLE);
                PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, i, PendingIntent.FLAG_NO_CREATE);
                if (pendingIntent != null) {
                    am.cancel(pendingIntent);
                }
                reportNewIdleState(context, false);
            }
        } else if (action.equals(Intent.ACTION_SCREEN_OFF)
                || action.equals(Intent.ACTION_DREAMING_STARTED)) {
            // when the screen goes off or dreaming starts, we schedule the
            // alarm that will tell us when we have decided the device is
            // truly idle.
            final long nowElapsed = SystemClock.elapsedRealtime();
            final long when = nowElapsed + INACTIVITY_IDLE_THRESHOLD;
            AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
            Intent i = new Intent(context, IdleReceiver.class);
            i.setAction(ACTION_TRIGGER_IDLE);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, i, 0);
            compatSetWindow(am, AlarmManager.ELAPSED_REALTIME_WAKEUP, when, IDLE_WINDOW_SLOP, pendingIntent);
        } else if (action.equals(ACTION_TRIGGER_IDLE)) {
            // idle time starts now
            if (isIdle) {
                reportNewIdleState(context, true);
            } else {
                setAnywayTimer(context);
                enableReceiver(context);
            }
        }
    }

    public static void setIdleForJob(Context context, JobStatus job) {
        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        boolean isInteractive = pm.isScreenOn();
        boolean isDreamMode = ControllerPrefs.getInstance(context).isInDaydreamMode();
        job.idleConstraintSatisfied.set(!isInteractive || isDreamMode);

        setAnywayTimer(context);
        enableReceiver(context);
    }

    /** We can't get idle broadcasts while the app is not running. Since our app will probably be
     killed before the device becomes idle, let's set an alarm sometime in the future that will
     force an idle check. */
    private static void setAnywayTimer(Context context) {
        AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        Intent intent = new Intent(context, IdleReceiver.class);
        intent.setAction(ACTION_TRIGGER_IDLE);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0);
        final long nowElapsed = SystemClock.elapsedRealtime();
        final long when = nowElapsed + INACTIVITY_ANYWAY_THRESHOLD;
        compatSetWindow(am, AlarmManager.ELAPSED_REALTIME_WAKEUP, when, IDLE_WINDOW_SLOP, pendingIntent);
    }

    public static void unsetIdle(Context context) {
        AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        Intent intent = new Intent(context, IdleReceiver.class);
        intent.setAction(ACTION_TRIGGER_IDLE);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_NO_CREATE);
        if (pendingIntent != null) {
            am.cancel(pendingIntent);
        }
        disableReceiver(context);
    }

    /**
     * Call this as soon as we get a chance, it will be unregistered whenever our app is killed.
     */
    public static void enableReceiver(Context context) {
        IntentFilter filter = getFilter();
        sReceiver = new IdleReceiver();
        context.getApplicationContext().registerReceiver(sReceiver, filter);
    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    private static IntentFilter getFilter()  {
        IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_ON);
        filter.addAction(Intent.ACTION_SCREEN_OFF);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            filter.addAction(Intent.ACTION_DREAMING_STARTED);
            filter.addAction(Intent.ACTION_DREAMING_STOPPED);
        }
        return filter;
    }

    private static void disableReceiver(Context context) {
        if (sReceiver != null) {
            context.getApplicationContext().unregisterReceiver(sReceiver);
        }
    }

    /**
     * Interaction with the task manager service
     */
    void reportNewIdleState(Context context, boolean isIdle) {
        JobStore jobStore = JobStore.initAndGet(context);
        synchronized (jobStore) {
            ArraySet<JobStatus> jobs = jobStore.getJobs();
            for (int i = 0; i < jobs.size(); i++) {
                JobStatus ts = jobs.valueAt(i);
                ts.idleConstraintSatisfied.set(isIdle);
            }
        }
        startWakefulService(context, JobServiceCompat.maybeRunJobs(context));
    }

    @TargetApi(Build.VERSION_CODES.KITKAT)
    private static void compatSetWindow(AlarmManager am, int type, long windowStartMillis, long windowLengthMillis, PendingIntent pendingIntent) {
        // Samsung devices have a bug where setWindow() may run before the start time.
        // https://code.google.com/p/android/issues/detail?id=69525
        boolean isShittySamsungDevice = Build.MANUFACTURER.equalsIgnoreCase("samsung");

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && !isShittySamsungDevice) {
            am.setWindow(type, windowStartMillis, windowLengthMillis, pendingIntent);
        } else {
            am.set(type, windowStartMillis, pendingIntent);
        }
    }
}


/*
 * Copyright (C) 2014 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 me.tatarka.support.internal.job;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.SystemClock;
import android.support.v4.util.AtomicFile;
import android.util.Log;
import android.util.Pair;
import android.util.Xml;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlSerializer;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import me.tatarka.support.internal.util.ArraySet;
import me.tatarka.support.internal.util.FastXmlSerializer;
import me.tatarka.support.job.JobInfo;
import me.tatarka.support.internal.receivers.JobStatus;
import me.tatarka.support.os.PersistableBundle;
import me.tatarka.support.internal.IoThread;

/**
 * Maintains the master list of jobs that the job scheduler is tracking. These jobs are compared by
 * reference, so none of the functions in this class should make a copy.
 * Also handles read/write of persisted jobs.
 *

 * Note on locking:
 * All callers to this class must lock on the class object they are calling.
 * This is important b/c {@link WriteJobsMapToDiskRunnable}
 * and {@link ReadJobMapFromDiskRunnable} lock on that
 * object.
 *
 * @hide
 */
public class JobStore {
    private static final String TAG = "JobStore";

    /**
     * Threshold to adjust how often we want to write to the db.
     */
    private static final int MAX_OPS_BEFORE_WRITE = 1;
    final ArraySet<JobStatus> mJobSet;
    final Context mContext;

    private int mDirtyOperations;

    private static final Object sSingletonLock = new Object();
    private final AtomicFile mJobsFile;
    /**
     * Handler backed by IoThread for writing to disk.
     */
    private final Handler mIoHandler = IoThread.getHandler();
    private static JobStore sSingleton;

    public static JobStore initAndGet(Context context) {
        synchronized (sSingletonLock) {
            if (sSingleton == null) {
                sSingleton = new JobStore(context, context.getFilesDir());
            }
            return sSingleton;
        }
    }

    /**
     * Construct the instance of the job store. This results in a blocking read from disk.
     */
    private JobStore(Context context, File dataDir) {
        mContext = context;
        mDirtyOperations = 0;

        File systemDir = new File(dataDir, "system");
        File jobDir = new File(systemDir, "job");
        jobDir.mkdirs();
        mJobsFile = new AtomicFile(new File(jobDir, "jobs.xml"));

        mJobSet = new ArraySet<JobStatus>();

        readJobMapFromDisk(mJobSet);
    }

    /**
     * Add a job to the master list, persisting it if necessary. If the JobStatus already exists,
     * it will be replaced.
     *
     * @param jobStatus Job to add.
     * @return Whether or not an equivalent JobStatus was replaced by this operation.
     */
    public boolean add(JobStatus jobStatus) {
        boolean replaced = mJobSet.remove(jobStatus);
        mJobSet.add(jobStatus);
        if (!jobStatus.isPersisted()) {
            markForBootSession(jobStatus);
        }
        maybeWriteStatusToDiskAsync();
        return replaced;
    }

    /**
     * Whether this jobStatus object already exists in the JobStore.
     */
    public boolean containsJobId(int jobId) {
        for (int i = mJobSet.size() - 1; i >= 0; i--) {
            JobStatus ts = mJobSet.valueAt(i);
            if (ts.matches(jobId)) {
                return true;
            }
        }
        return false;
    }

    boolean containsJob(JobStatus jobStatus) {
        return mJobSet.contains(jobStatus);
    }

    public int size() {
        return mJobSet.size();
    }

    /**
     * Remove the provided job. Will also delete the job if it was persisted.
     *
     * @return Whether or not the job existed to be removed.
     */
    public boolean remove(JobStatus jobStatus) {
        boolean removed = mJobSet.remove(jobStatus);
        if (!removed) {
            return false;
        }
        if (!jobStatus.isPersisted()) {
            unmarkForBootSession(jobStatus);
        }
        maybeWriteStatusToDiskAsync();
        return removed;
    }

    public void clear() {
        for (int i = 0; i < mJobSet.size(); i++) {
            JobStatus jobStatus = mJobSet.valueAt(i);
            if (!jobStatus.isPersisted()) {
                unmarkForBootSession(jobStatus);
            }
        }
        mJobSet.clear();
        maybeWriteStatusToDiskAsync();
    }

    /**
     * @param jobId Job id, specified at schedule-time.
     * @return the JobStatus that matches the provided uId and jobId, or null if none found.
     */
    public JobStatus getJobByJobId(int jobId) {
        Iterator<JobStatus> it = mJobSet.iterator();
        while (it.hasNext()) {
            JobStatus ts = it.next();
            if (ts.matches(jobId)) {
                return ts;
            }
        }
        return null;
    }

    /**
     * @return The live array of JobStatus objects.
     */
    public ArraySet<JobStatus> getJobs() {
        return mJobSet;
    }

    /**
     * Version of the db schema.
     */
    private static final int JOBS_FILE_VERSION = 0;
    /**
     * Tag corresponds to constraints this job needs.
     */
    private static final String XML_TAG_PARAMS_CONSTRAINTS = "constraints";
    /**
     * Tag corresponds to execution parameters.
     */
    private static final String XML_TAG_PERIODIC = "periodic";
    private static final String XML_TAG_ONEOFF = "one-off";
    private static final String XML_TAG_EXTRAS = "extras";

    /**
     * Every time the state changes we write all the jobs in one swath, instead of trying to
     * track incremental changes.
     *
     * @return Whether the operation was successful. This will only fail for e.g. if the system is
     * low on storage. If this happens, we continue as normal
     */
    private void maybeWriteStatusToDiskAsync() {
        mDirtyOperations++;
        if (mDirtyOperations >= MAX_OPS_BEFORE_WRITE) {
            mIoHandler.post(new WriteJobsMapToDiskRunnable());
        }
    }

    private void readJobMapFromDisk(ArraySet<JobStatus> jobSet) {
        new ReadJobMapFromDiskRunnable(jobSet).run();
    }

    /**
     * Runnable that writes {@link #mJobSet} out to xml.
     * NOTE: This Runnable locks on JobStore.this
     */
    private class WriteJobsMapToDiskRunnable implements Runnable {
        @Override
        public void run() {
            final long startElapsed = SystemClock.elapsedRealtime();
            List<JobStatus> mStoreCopy = new ArrayList<JobStatus>();
            synchronized (JobStore.this) {
                // Copy over the jobs so we can release the lock before writing.
                for (int i = 0; i < mJobSet.size(); i++) {
                    JobStatus jobStatus = mJobSet.valueAt(i);
                    JobStatus copy = new JobStatus(jobStatus.getJob(),
                            jobStatus.getEarliestRunTime(), jobStatus.getLatestRunTimeElapsed());
                    mStoreCopy.add(copy);
                }
            }
            writeJobsMapImpl(mStoreCopy);
        }

        private void writeJobsMapImpl(List<JobStatus> jobList) {
            try {
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                XmlSerializer out = new FastXmlSerializer();
                out.setOutput(baos, "utf-8");
                out.startDocument(null, true);
                out.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);

                out.startTag(null, "job-info");
                out.attribute(null, "version", Integer.toString(JOBS_FILE_VERSION));
                for (int i = 0; i < jobList.size(); i++) {
                    JobStatus jobStatus = jobList.get(i);
                    out.startTag(null, "job");
                    addIdentifierAttributesToJobTag(out, jobStatus);
                    writeConstraintsToXml(out, jobStatus);
                    writeExecutionCriteriaToXml(out, jobStatus);
                    writeBundleToXml(jobStatus.getExtras(), out);
                    out.endTag(null, "job");
                }
                out.endTag(null, "job-info");
                out.endDocument();

                // Write out to disk in one fell sweep.
                FileOutputStream fos = mJobsFile.startWrite();
                fos.write(baos.toByteArray());
                mJobsFile.finishWrite(fos);
                mDirtyOperations = 0;
            } catch (IOException e) {
                Log.e(TAG, e.getMessage(), e);
            } catch (XmlPullParserException e) {
                Log.e(TAG, e.getMessage(), e);
            }
        }

        /**
         * Write out a tag with data comprising the required fields of this job and its client.
         */
        private void addIdentifierAttributesToJobTag(XmlSerializer out, JobStatus jobStatus)
                throws IOException {
            out.attribute(null, "jobid", Integer.toString(jobStatus.getJobId()));
            out.attribute(null, "package", jobStatus.getServiceComponent().getPackageName());
            out.attribute(null, "class", jobStatus.getServiceComponent().getClassName());
        }

        private void writeBundleToXml(PersistableBundle extras, XmlSerializer out)
                throws IOException, XmlPullParserException {
            out.startTag(null, XML_TAG_EXTRAS);
            extras.saveToXml(out);
            out.endTag(null, XML_TAG_EXTRAS);
        }

        /**
         * Write out a tag with data identifying this job's constraints. If the constraint isn't here
         * it doesn't apply.
         */
        private void writeConstraintsToXml(XmlSerializer out, JobStatus jobStatus) throws IOException {
            out.startTag(null, XML_TAG_PARAMS_CONSTRAINTS);
            if (jobStatus.hasUnmeteredConstraint()) {
                out.attribute(null, "unmetered", Boolean.toString(true));
            }
            if (jobStatus.hasConnectivityConstraint()) {
                out.attribute(null, "connectivity", Boolean.toString(true));
            }
            if (jobStatus.hasIdleConstraint()) {
                out.attribute(null, "idle", Boolean.toString(true));
            }
            if (jobStatus.hasChargingConstraint()) {
                out.attribute(null, "charging", Boolean.toString(true));
            }
            out.endTag(null, XML_TAG_PARAMS_CONSTRAINTS);
        }

        private void writeExecutionCriteriaToXml(XmlSerializer out, JobStatus jobStatus)
                throws IOException {
            final JobInfo job = jobStatus.getJob();
            if (jobStatus.getJob().isPeriodic()) {
                out.startTag(null, XML_TAG_PERIODIC);
                out.attribute(null, "period", Long.toString(job.getIntervalMillis()));
            } else {
                out.startTag(null, XML_TAG_ONEOFF);
            }

            if (jobStatus.isPersisted()) {
                out.attribute(null, "persisted", Boolean.toString(true));
            }

            if (jobStatus.hasDeadlineConstraint()) {
                // Wall clock deadline.
                final long deadlineWallclock = System.currentTimeMillis() +
                        (jobStatus.getLatestRunTimeElapsed() - SystemClock.elapsedRealtime());
                out.attribute(null, "deadline", Long.toString(deadlineWallclock));
            }
            if (jobStatus.hasTimingDelayConstraint()) {
                final long delayWallclock = System.currentTimeMillis() +
                        (jobStatus.getEarliestRunTime() - SystemClock.elapsedRealtime());
                out.attribute(null, "delay", Long.toString(delayWallclock));
            }

            // Only write out back-off policy if it differs from the default.
            // This also helps the case where the job is idle -> these aren't allowed to specify
            // back-off.
            if (jobStatus.getJob().getInitialBackoffMillis() != JobInfo.DEFAULT_INITIAL_BACKOFF_MILLIS
                    || jobStatus.getJob().getBackoffPolicy() != JobInfo.DEFAULT_BACKOFF_POLICY) {
                out.attribute(null, "backoff-policy", Integer.toString(job.getBackoffPolicy()));
                out.attribute(null, "initial-backoff", Long.toString(job.getInitialBackoffMillis()));
            }
            if (job.isPeriodic()) {
                out.endTag(null, XML_TAG_PERIODIC);
            } else {
                out.endTag(null, XML_TAG_ONEOFF);
            }
        }
    }

    /**
     * Runnable that reads list of persisted job from xml. This is run once at start up, so doesn't
     * need to go through {@link JobStore#add(JobStatus)}.
     */
    private class ReadJobMapFromDiskRunnable implements Runnable {
        private final ArraySet<JobStatus> jobSet;

        /**
         * @param jobSet Reference to the (empty) set of JobStatus objects that back the JobStore,
         *               so that after disk read we can populate it directly.
         */
        ReadJobMapFromDiskRunnable(ArraySet<JobStatus> jobSet) {
            this.jobSet = jobSet;
        }

        @Override
        public void run() {
            try {
                List<JobStatus> jobs;
                FileInputStream fis = mJobsFile.openRead();
                synchronized (JobStore.this) {
                    jobs = readJobMapImpl(fis);
                    if (jobs != null) {
                        for (int i = 0; i < jobs.size(); i++) {
                            JobStatus jobStatus = jobs.get(i);
                            // Skip any jobs that are not persisted if we have rebooted.
                            if (!jobStatus.isPersisted() && !isMarkedForBootSession(jobStatus)) {
                                continue;
                            }
                            this.jobSet.add(jobStatus);
                        }
                    }
                }
                fis.close();
            } catch (FileNotFoundException e) {
                // Ignore
            } catch (XmlPullParserException e) {
                // Ignore
            } catch (IOException e) {
                // Ignore
            }
        }

        private List<JobStatus> readJobMapImpl(FileInputStream fis)
                throws XmlPullParserException, IOException {
            XmlPullParser parser = Xml.newPullParser();
            parser.setInput(fis, null);

            int eventType = parser.getEventType();
            while (eventType != XmlPullParser.START_TAG &&
                    eventType != XmlPullParser.END_DOCUMENT) {
                eventType = parser.next();
            }
            if (eventType == XmlPullParser.END_DOCUMENT) {
                return null;
            }

            String tagName = parser.getName();
            if ("job-info".equals(tagName)) {
                final List<JobStatus> jobs = new ArrayList<JobStatus>();
                // Read in version info.
                try {
                    int version = Integer.valueOf(parser.getAttributeValue(null, "version"));
                    if (version != JOBS_FILE_VERSION) {
                        return null;
                    }
                } catch (NumberFormatException e) {
                    return null;
                }
                eventType = parser.next();
                do {
                    // Read each <job/>
                    if (eventType == XmlPullParser.START_TAG) {
                        tagName = parser.getName();
                        // Start reading job.
                        if ("job".equals(tagName)) {
                            JobStatus persistedJob = restoreJobFromXml(parser);
                            if (persistedJob != null) {
                                jobs.add(persistedJob);
                            }
                        }
                    }
                    eventType = parser.next();
                } while (eventType != XmlPullParser.END_DOCUMENT);
                return jobs;
            }
            return null;
        }

        /**
         * @param parser Xml parser at the beginning of a "<job/>" tag. The next "parser.next()" call
         *               will take the parser into the body of the job tag.
         * @return Newly instantiated job holding all the information we just read out of the xml tag.
         */
        private JobStatus restoreJobFromXml(XmlPullParser parser) throws XmlPullParserException,
                IOException {
            JobInfo.Builder jobBuilder;

            // Read out job identifier attributes.
            try {
                jobBuilder = buildBuilderFromXml(parser);
            } catch (NumberFormatException e) {
                return null;
            }

            int eventType;
            // Read out constraints tag.
            do {
                eventType = parser.next();
            } while (eventType == XmlPullParser.TEXT);  // Push through to next START_TAG.

            if (!(eventType == XmlPullParser.START_TAG &&
                    XML_TAG_PARAMS_CONSTRAINTS.equals(parser.getName()))) {
                // Expecting a <constraints> start tag.
                return null;
            }
            try {
                buildConstraintsFromXml(jobBuilder, parser);
            } catch (NumberFormatException e) {
                return null;
            }
            parser.next(); // Consume </constraints>

            // Read out execution parameters tag.
            do {
                eventType = parser.next();
            } while (eventType == XmlPullParser.TEXT);
            if (eventType != XmlPullParser.START_TAG) {
                return null;
            }

            Pair<Long, Long> runtimes;
            try {
                runtimes = buildExecutionTimesFromXml(parser);
            } catch (NumberFormatException e) {
                return null;
            }

            if (XML_TAG_PERIODIC.equals(parser.getName())) {
                try {
                    String val = parser.getAttributeValue(null, "period");
                    jobBuilder.setPeriodic(Long.valueOf(val));
                } catch (NumberFormatException e) {
                    return null;
                }
            } else if (XML_TAG_ONEOFF.equals(parser.getName())) {
                try {
                    if (runtimes.first != JobStatus.NO_EARLIEST_RUNTIME) {
                        jobBuilder.setMinimumLatency(runtimes.first - SystemClock.elapsedRealtime());
                    }
                    if (runtimes.second != JobStatus.NO_LATEST_RUNTIME) {
                        jobBuilder.setOverrideDeadline(
                                runtimes.second - SystemClock.elapsedRealtime());
                    }
                } catch (NumberFormatException e) {
                    return null;
                }
            } else {
                // Expecting a parameters start tag.
                return null;
            }

            String persisted = parser.getAttributeValue(null, "persisted");
            if (persisted != null) {
                jobBuilder.setPersisted(Boolean.valueOf(persisted));
            }

            maybeBuildBackoffPolicyFromXml(jobBuilder, parser);

            parser.nextTag(); // Consume parameters end tag.

            // Read out extras Bundle.
            do {
                eventType = parser.next();
            } while (eventType == XmlPullParser.TEXT);
            if (!(eventType == XmlPullParser.START_TAG && XML_TAG_EXTRAS.equals(parser.getName()))) {
                return null;
            }

            PersistableBundle extras = PersistableBundle.restoreFromXml(parser);
            jobBuilder.setExtras(extras);
            parser.nextTag(); // Consume </extras>

            return new JobStatus(jobBuilder.build(), runtimes.first, runtimes.second);
        }

        private JobInfo.Builder buildBuilderFromXml(XmlPullParser parser) throws NumberFormatException {
            // Pull out required fields from <job> attributes.
            int jobId = Integer.valueOf(parser.getAttributeValue(null, "jobid"));
            String packageName = parser.getAttributeValue(null, "package");
            String className = parser.getAttributeValue(null, "class");
            ComponentName cname = new ComponentName(packageName, className);

            return new JobInfo.Builder(jobId, cname);
        }

        private void buildConstraintsFromXml(JobInfo.Builder jobBuilder, XmlPullParser parser) {
            String val = parser.getAttributeValue(null, "unmetered");
            if (val != null) {
                jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
            }
            val = parser.getAttributeValue(null, "connectivity");
            if (val != null) {
                jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
            }
            val = parser.getAttributeValue(null, "idle");
            if (val != null) {
                jobBuilder.setRequiresDeviceIdle(true);
            }
            val = parser.getAttributeValue(null, "charging");
            if (val != null) {
                jobBuilder.setRequiresCharging(true);
            }
        }

        /**
         * Builds the back-off policy out of the params tag. These attributes may not exist, depending
         * on whether the back-off was set when the job was first scheduled.
         */
        private void maybeBuildBackoffPolicyFromXml(JobInfo.Builder jobBuilder, XmlPullParser parser) {
            String val = parser.getAttributeValue(null, "initial-backoff");
            if (val != null) {
                long initialBackoff = Long.valueOf(val);
                val = parser.getAttributeValue(null, "backoff-policy");
                int backoffPolicy = Integer.valueOf(val);  // Will throw NFE which we catch higher up.
                jobBuilder.setBackoffCriteria(initialBackoff, backoffPolicy);
            }
        }

        /**
         * Convenience function to read out and convert deadline and delay from xml into elapsed real
         * time.
         *
         * @return A {@link android.util.Pair}, where the first value is the earliest elapsed runtime
         * and the second is the latest elapsed runtime.
         */
        private Pair<Long, Long> buildExecutionTimesFromXml(XmlPullParser parser)
                throws NumberFormatException {
            // Pull out execution time data.
            final long nowWallclock = System.currentTimeMillis();
            final long nowElapsed = SystemClock.elapsedRealtime();

            long earliestRunTimeElapsed = JobStatus.NO_EARLIEST_RUNTIME;
            long latestRunTimeElapsed = JobStatus.NO_LATEST_RUNTIME;
            String val = parser.getAttributeValue(null, "deadline");
            if (val != null) {
                long latestRuntimeWallclock = Long.valueOf(val);
                long maxDelayElapsed =
                        Math.max(latestRuntimeWallclock - nowWallclock, 0);
                latestRunTimeElapsed = nowElapsed + maxDelayElapsed;
            }
            val = parser.getAttributeValue(null, "delay");
            if (val != null) {
                long earliestRuntimeWallclock = Long.valueOf(val);
                long minDelayElapsed =
                        Math.max(earliestRuntimeWallclock - nowWallclock, 0);
                earliestRunTimeElapsed = nowElapsed + minDelayElapsed;

            }
            return Pair.create(earliestRunTimeElapsed, latestRunTimeElapsed);
        }
    }

    private void markForBootSession(JobStatus jobStatus) {
        // Pending intents are cleared on reboot. Therefore, we can use one to mark that we haven't
        // rebooted yet.
        Intent intent = new Intent(mContext, JobSchedulerService.class)
                .setAction(jobStatus.toShortString());
        PendingIntent pendingIntent = PendingIntent.getService(mContext, jobStatus.getJobId(), intent, PendingIntent.FLAG_CANCEL_CURRENT);
        // Have the alarm manager hold on to our pending intent so it will still be there even if our app is killed.
        AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
        am.set(AlarmManager.ELAPSED_REALTIME, Long.MAX_VALUE, pendingIntent);
    }

    private void unmarkForBootSession(JobStatus jobStatus) {
        Intent intent = new Intent(mContext, JobSchedulerService.class)
                .setAction(jobStatus.toShortString());
        PendingIntent pendingIntent = PendingIntent.getService(mContext, jobStatus.getJobId(), intent, PendingIntent.FLAG_NO_CREATE);
        if (pendingIntent != null) {
            AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
            am.cancel(pendingIntent);
            pendingIntent.cancel();
        }
    }

    private boolean isMarkedForBootSession(JobStatus jobStatus) {
        Intent intent = new Intent(mContext, JobSchedulerService.class)
                .setAction(jobStatus.toShortString());
        PendingIntent pendingIntent = PendingIntent.getService(mContext, jobStatus.getJobId(), intent, PendingIntent.FLAG_NO_CREATE);
        return pendingIntent != null;
    }
}


package me.tatarka.support.internal.job;

import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.os.SystemClock;
import android.util.Log;
import android.util.SparseArray;

import me.tatarka.support.internal.receivers.IdleReceiver;
import me.tatarka.support.job.IJobCallback;
import me.tatarka.support.job.IJobService;
import me.tatarka.support.job.JobInfo;
import me.tatarka.support.job.JobParameters;
import me.tatarka.support.internal.receivers.JobStatus;
import me.tatarka.support.internal.receivers.TimeReceiver;

/**
 * @hide
 */
public class JobSchedulerService extends Service {
    static final String TAG = "JobServiceSchedulerService";

    private static final String EXTRA_MSG = "EXTRA_MSG";
    private static final String EXTRA_JOB_ID = "EXTRA_JOB_ID";

    private static final int MSG_START_JOB = 0;
    private static final int MSG_STOP_JOB = 1;
    private static final int MSG_STOP_ALL = 2;
    private static final int MSG_RECHECK_CONSTRAINTS = 3;

    private SparseArray<JobServiceConnection> runningJobs = new SparseArray<JobServiceConnection>();

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        int what = intent.getIntExtra(EXTRA_MSG, -1);
        Message message = Message.obtain(handler, what, intent);
        handler.handleMessage(message);
        return START_NOT_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    private void handleStartJob(int jobId) {
        if (runningJobs.get(jobId) != null) {
            // Job already running!
            return;
        }

        JobStore jobStore = JobStore.initAndGet(this);
        JobStatus job;
        synchronized (jobStore) {
            job = jobStore.getJobByJobId(jobId);
        }

        if (job == null) {
            // Attempting to start a non-existent job, it may have already been canceled.
            return;
        }

        Intent intent = new Intent();
        intent.setComponent(job.getJob().getService());

        int flags = BIND_AUTO_CREATE;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            flags |= BIND_WAIVE_PRIORITY;
        }

        JobServiceConnection connection = new JobServiceConnection(job);
        runningJobs.put(job.getJobId(), connection);
        bindService(intent, connection, flags);
    }

    private void handleStopJob(int jobId) {
        JobServiceConnection connection = runningJobs.get(jobId);
        if (connection != null) {
            connection.stop(false);
        }
    }

    private void handleStopAll() {
        int size = runningJobs.size();
        for (int i = 0; i < size; i++) {
            int jobId = runningJobs.keyAt(i);
            JobServiceConnection connection = runningJobs.get(jobId);
            connection.stop(false);
        }
    }

    private void handleRecheckConstraints() {
        int size = runningJobs.size();
        for (int i = 0; i < size; i++) {
            int jobId = runningJobs.keyAt(i);
            JobServiceConnection connection = runningJobs.get(jobId);
            if (!connection.job.isConstraintsSatisfied()) {
                connection.stop(true);
            }
        }
    }

    private class JobServiceConnection implements ServiceConnection {
        JobStatus job;
        JobParameters jobParams;
        IJobService jobService;
        boolean allowReschedule = true;

        JobServiceConnection(final JobStatus job) {
            this.job = job;
            this.jobParams = new JobParameters(new IJobCallback.Stub() {
                @Override
                public void jobFinished(int jobId, boolean needsReschedule) throws RemoteException {
                    finishJob(jobId, JobServiceConnection.this);

                    if (allowReschedule) {
                        if (needsReschedule || job.getJob().isPeriodic()) {
                            rescheduleJob(job, needsReschedule);
                        }
                    }
                    stopIfFinished();
                }

                @Override
                public void acknowledgeStartMessage(int jobId, boolean workOngoing) throws RemoteException {
                    if (!workOngoing) {
                        finishJob(jobId, JobServiceConnection.this);
                        stopIfFinished();
                    }
                }

                @Override
                public void acknowledgeStopMessage(int jobId, boolean reschedule) throws RemoteException {
                    finishJob(jobId, JobServiceConnection.this);

                    if (allowReschedule) {
                        if (reschedule || job.getJob().isPeriodic()) {
                            rescheduleJob(job, reschedule);
                        }
                    }
                    stopIfFinished();
                }
            }, job.getJobId(), job.getExtras(), !job.isConstraintsSatisfied());
        }

        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            jobService = (IJobService) service;

            try {
                jobService.startJob(jobParams);
            } catch (Exception e) {
                Log.e(TAG, "Error while starting job: " + job.getJob());
                throw new RuntimeException(e);
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            runningJobs.remove(job.getJobId());
            jobService = null;
            jobParams = null;
            stopIfFinished();
        }

        void stop(boolean allowReschedule) {
            if (jobService != null) {
                this.allowReschedule = allowReschedule;
                try {
                    jobService.stopJob(jobParams);
                } catch (Exception e) {
                    Log.e(TAG, "Error while stopping job: " + job.getJobId());
                    throw new RuntimeException(e);
                }
            }
        }
    }

    static void startJob(Context context, int jobId) {
        context.startService(new Intent(context, JobSchedulerService.class)
                .putExtra(EXTRA_MSG, MSG_START_JOB)
                .putExtra(EXTRA_JOB_ID, jobId));
    }

    static void stopJob(Context context, int jobId) {
        context.startService(new Intent(context, JobSchedulerService.class)
                .putExtra(EXTRA_MSG, MSG_STOP_JOB)
                .putExtra(EXTRA_JOB_ID, jobId));
    }

    static void stopAll(Context context) {
        context.startService(new Intent(context, JobSchedulerService.class)
                .putExtra(EXTRA_MSG, MSG_STOP_ALL));
    }

    static void recheckConstraints(Context context) {
        context.startService(new Intent(context, JobSchedulerService.class)
                .putExtra(EXTRA_MSG, MSG_RECHECK_CONSTRAINTS));
    }

    private void finishJob(int jobId, JobServiceConnection connection) {
        if (runningJobs.get(jobId) != null) {
            unbindService(connection);
        }
        runningJobs.remove(jobId);
        JobStore jobStore = JobStore.initAndGet(this);
        synchronized (jobStore) {
            jobStore.remove(connection.job);
        }
    }

    private void rescheduleJob(JobStatus job, boolean wasFailure) {
        JobStatus newJob;
        if (wasFailure) {
            newJob = rescheduleFailedJob(job);
        } else {
            newJob = reschedulePeriodicJob(job);
        }

        JobStore jobStore = JobStore.initAndGet(this);
        synchronized (jobStore) {
            jobStore.remove(job);
            jobStore.add(newJob);
        }

        TimeReceiver.setAlarmsForJob(this, newJob);
        if (job.hasIdleConstraint()) {
            IdleReceiver.setIdleForJob(this, newJob);
        }
    }

    /**
     * A job is rescheduled with exponential back-off if the client requests this from their
     * execution logic.
     * A caveat is for idle-mode jobs, for which the idle-mode constraint will usurp the
     * timeliness of the reschedule. For an idle-mode job, no deadline is given.
     * @return A newly instantiated JobStatus with the same constraints as the last job except
     * with adjusted timing constraints.
     */
    private JobStatus rescheduleFailedJob(JobStatus job) {
        if (job.hasIdleConstraint()) {
            // Don't need to modify time on idle job, it will run whenever the next idle period is.
            return job;
        }

        final long elapsedNowMillis = SystemClock.elapsedRealtime();
        final JobInfo jobInfo = job.getJob();

        final long initialBackoffMillis = jobInfo.getInitialBackoffMillis();
        final int backoffAttemps = job.getNumFailures() + 1;

        long delayMillis;
        switch (job.getJob().getBackoffPolicy()) {
            case JobInfo.BACKOFF_POLICY_LINEAR:
                delayMillis = initialBackoffMillis * backoffAttemps;
                break;
            default:
            case JobInfo.BACKOFF_POLICY_EXPONENTIAL:
                delayMillis = (long) Math.scalb(initialBackoffMillis, backoffAttemps - 1);
                break;
        }
        delayMillis = Math.min(delayMillis, JobInfo.MAX_BACKOFF_DELAY_MILLIS);
        return new JobStatus(job, elapsedNowMillis + delayMillis, JobStatus.NO_LATEST_RUNTIME, backoffAttemps);
    }

    /**
     * Called after a periodic has executed so we can to re-add it. We take the last execution time
     * of the job to be the time of completion (i.e. the time at which this function is called).
     * This could be inaccurate b/c the job can run for as long as
     * {@link com.android.server.job.JobServiceContext#EXECUTING_TIMESLICE_MILLIS}, but will lead
     * to underscheduling at least, rather than if we had taken the last execution time to be the
     * start of the execution.
     * @return A new job representing the execution criteria for this instantiation of the
     * recurring job.
     */
    private JobStatus reschedulePeriodicJob(JobStatus job) {
        final long elapsedNow = SystemClock.elapsedRealtime();
        // Compute how much of the period is remaining.
        long runEarly = Math.max(job.getLatestRunTimeElapsed() - elapsedNow, 0);
        long newEarliestRunTimeElapsed = elapsedNow + runEarly;
        long period = job.getJob().getIntervalMillis();
        long newLatestRuntimeElapsed = newEarliestRunTimeElapsed + period;
        return new JobStatus(job, newEarliestRunTimeElapsed,
                newLatestRuntimeElapsed, 0 /* backoffAttempt */);
    }

    private void stopIfFinished() {
        if (runningJobs.size() == 0) {
            JobServiceCompat.jobsFinished(this);
            stopSelf();
        }
    }

    private final Handler handler = new Handler(Looper.getMainLooper()) {
        @Override
        public void handleMessage(Message msg) {
            Intent intent = (Intent) msg.obj;
            switch (msg.what) {
                case MSG_START_JOB: {
                    int jobId = intent.getIntExtra(EXTRA_JOB_ID, 0);
                    handleStartJob(jobId);
                    break;
                }
                case MSG_STOP_JOB: {
                    int jobId = intent.getIntExtra(EXTRA_JOB_ID, 0);
                    handleStopJob(jobId);
                    break;
                }
                case MSG_STOP_ALL: {
                    handleStopAll();
                    break;
                }
                case MSG_RECHECK_CONSTRAINTS: {
                    handleRecheckConstraints();
                    break;
                }
            }
        }
    };
}


/*
* Copyright (C) 2013 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 me.tatarka.support.internal.util;

import android.util.Log;

import java.lang.reflect.Array;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

/**
 * ArraySet is a generic set data structure that is designed to be more memory efficient than a
 * traditional {@link java.util.HashSet}. The design is very similar to
 * {@link ArrayMap}, with all of the caveats described there. This implementation is
 * separate from ArrayMap, however, so the Object array contains only one item for each
 * entry in the set (instead of a pair for a mapping).
 *

 *
Note that this implementation is not intended to be appropriate for data structures
 * that may contain large numbers of items. It is generally slower than a traditional
 * HashSet, since lookups require a binary search and adds and removes require inserting
 * and deleting entries in the array. For containers holding up to hundreds of items,
 * the performance difference is not significant, less than 50%.

 *

 *
Because this container is intended to better balance memory use, unlike most other
 * standard Java containers it will shrink its array as items are removed from it. Currently
 * you have no control over this shrinking -- if you set a capacity and then remove an
 * item, it may reduce the capacity to better match the current size. In the future an
 * explicit call to set the capacity should turn off this aggressive shrinking behavior.

 *
 * @hide
 */
public final class ArraySet<E> implements Collection<E>, Set<E> {
    private static final boolean DEBUG = false;
    private static final String TAG = "ArraySet";
    /**
     * The minimum amount by which the capacity of a ArraySet will increase.
     * This is tuned to be relatively space-efficient.
     */
    private static final int BASE_SIZE = 4;
    /**
     * Maximum number of entries to have in array caches.
     */
    private static final int CACHE_SIZE = 10;
    /**
     * Caches of small array objects to avoid spamming garbage. The cache
     * Object[] variable is a pointer to a linked list of array objects.
     * The first entry in the array is a pointer to the next array in the
     * list; the second entry is a pointer to the int[] hash code array for it.
     */
    static Object[] mBaseCache;
    static int mBaseCacheSize;
    static Object[] mTwiceBaseCache;
    static int mTwiceBaseCacheSize;
    int[] mHashes;
    Object[] mArray;
    int mSize;
    MapCollections<E, E> mCollections;

    private int indexOf(Object key, int hash) {
        final int N = mSize;
        // Important fast case: if nothing is in here, nothing to look for.
        if (N == 0) {
            return ~0;
        }
        int index = ContainerHelpers.binarySearch(mHashes, N, hash);
        // If the hash code wasn't found, then we have no entry for this key.
        if (index < 0) {
            return index;
        }
        // If the key at the returned index matches, that's what we want.
        if (key.equals(mArray[index])) {
            return index;
        }
        // Search for a matching key after the index.
        int end;
        for (end = index + 1; end < N && mHashes[end] == hash; end++) {
            if (key.equals(mArray[end])) return end;
        }
        // Search for a matching key before the index.
        for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
            if (key.equals(mArray[i])) return i;
        }
        // Key not found -- return negative value indicating where a
        // new entry for this key should go. We use the end of the
        // hash chain to reduce the number of array entries that will
        // need to be copied when inserting.
        return ~end;
    }

    private int indexOfNull() {
        final int N = mSize;
        // Important fast case: if nothing is in here, nothing to look for.
        if (N == 0) {
            return ~0;
        }
        int index = ContainerHelpers.binarySearch(mHashes, N, 0);
        // If the hash code wasn't found, then we have no entry for this key.
        if (index < 0) {
            return index;
        }
        // If the key at the returned index matches, that's what we want.
        if (null == mArray[index]) {
            return index;
        }
        // Search for a matching key after the index.
        int end;
        for (end = index + 1; end < N && mHashes[end] == 0; end++) {
            if (null == mArray[end]) return end;
        }
        // Search for a matching key before the index.
        for (int i = index - 1; i >= 0 && mHashes[i] == 0; i--) {
            if (null == mArray[i]) return i;
        }
        // Key not found -- return negative value indicating where a
        // new entry for this key should go. We use the end of the
        // hash chain to reduce the number of array entries that will
        // need to be copied when inserting.
        return ~end;
    }

    private void allocArrays(final int size) {
        if (size == (BASE_SIZE * 2)) {
            synchronized (ArraySet.class) {
                if (mTwiceBaseCache != null) {
                    final Object[] array = mTwiceBaseCache;
                    mArray = array;
                    mTwiceBaseCache = (Object[]) array[0];
                    mHashes = (int[]) array[1];
                    array[0] = array[1] = null;
                    mTwiceBaseCacheSize--;
                    if (DEBUG) Log.d(TAG, "Retrieving 2x cache " + mHashes
                            + " now have " + mTwiceBaseCacheSize + " entries");
                    return;
                }
            }
        } else if (size == BASE_SIZE) {
            synchronized (ArraySet.class) {
                if (mBaseCache != null) {
                    final Object[] array = mBaseCache;
                    mArray = array;
                    mBaseCache = (Object[]) array[0];
                    mHashes = (int[]) array[1];
                    array[0] = array[1] = null;
                    mBaseCacheSize--;
                    if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
                            + " now have " + mBaseCacheSize + " entries");
                    return;
                }
            }
        }
        mHashes = new int[size];
        mArray = new Object[size];
    }

    private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
        if (hashes.length == (BASE_SIZE * 2)) {
            synchronized (ArraySet.class) {
                if (mTwiceBaseCacheSize < CACHE_SIZE) {
                    array[0] = mTwiceBaseCache;
                    array[1] = hashes;
                    for (int i = size - 1; i >= 2; i--) {
                        array[i] = null;
                    }
                    mTwiceBaseCache = array;
                    mTwiceBaseCacheSize++;
                    if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
                            + " now have " + mTwiceBaseCacheSize + " entries");
                }
            }
        } else if (hashes.length == BASE_SIZE) {
            synchronized (ArraySet.class) {
                if (mBaseCacheSize < CACHE_SIZE) {
                    array[0] = mBaseCache;
                    array[1] = hashes;
                    for (int i = size - 1; i >= 2; i--) {
                        array[i] = null;
                    }
                    mBaseCache = array;
                    mBaseCacheSize++;
                    if (DEBUG) Log.d(TAG, "Storing 1x cache " + array
                            + " now have " + mBaseCacheSize + " entries");
                }
            }
        }
    }

    /**
     * Create a new empty ArraySet. The default capacity of an array map is 0, and
     * will grow once items are added to it.
     */
    public ArraySet() {
        mHashes = EmptyArray.INT;
        mArray = EmptyArray.OBJECT;
        mSize = 0;
    }

    /**
     * Create a new ArraySet with a given initial capacity.
     */
    public ArraySet(int capacity) {
        if (capacity == 0) {
            mHashes = EmptyArray.INT;
            mArray = EmptyArray.OBJECT;
        } else {
            allocArrays(capacity);
        }
        mSize = 0;
    }

    /**
     * Create a new ArraySet with the mappings from the given ArraySet.
     */
    public ArraySet(ArraySet set) {
        this();
        if (set != null) {
            addAll(set);
        }
    }

    /**
     * Make the array map empty. All storage is released.
     */
    @Override
    public void clear() {
        if (mSize != 0) {
            freeArrays(mHashes, mArray, mSize);
            mHashes = EmptyArray.INT;
            mArray = EmptyArray.OBJECT;
            mSize = 0;
        }
    }

    /**
     * Ensure the array map can hold at least <var>minimumCapacity</var>
     * items.
     */
    public void ensureCapacity(int minimumCapacity) {
        if (mHashes.length < minimumCapacity) {
            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            allocArrays(minimumCapacity);
            if (mSize > 0) {
                System.arraycopy(ohashes, 0, mHashes, 0, mSize);
                System.arraycopy(oarray, 0, mArray, 0, mSize);
            }
            freeArrays(ohashes, oarray, mSize);
        }
    }

    /**
     * Check whether a value exists in the set.
     *
     * @param key The value to search for.
     * @return Returns true if the value exists, else false.
     */
    @Override
    public boolean contains(Object key) {
        return indexOf(key) >= 0;
    }

    /**
     * Returns the index of a value in the set.
     *
     * @param key The value to search for.
     * @return Returns the index of the value if it exists, else a negative integer.
     */
    public int indexOf(Object key) {
        return key == null ? indexOfNull() : indexOf(key, key.hashCode());
    }

    /**
     * Return the value at the given index in the array.
     *
     * @param index The desired index, must be between 0 and {@link #size()}-1.
     * @return Returns the value stored at the given index.
     */
    public E valueAt(int index) {
        return (E) mArray[index];
    }

    /**
     * Return true if the array map contains no items.
     */
    @Override
    public boolean isEmpty() {
        return mSize <= 0;
    }

    /**
     * Adds the specified object to this set. The set is not modified if it
     * already contains the object.
     *
     * @param value the object to add.
     * @return {@code true} if this set is modified, {@code false} otherwise.
     * @throws ClassCastException when the class of the object is inappropriate for this set.
     */
    @Override
    public boolean add(E value) {
        final int hash;
        int index;
        if (value == null) {
            hash = 0;
            index = indexOfNull();
        } else {
            hash = value.hashCode();
            index = indexOf(value, hash);
        }
        if (index >= 0) {
            return false;
        }
        index = ~index;
        if (mSize >= mHashes.length) {
            final int n = mSize >= (BASE_SIZE * 2) ? (mSize + (mSize >> 1))
                    : (mSize >= BASE_SIZE ? (BASE_SIZE * 2) : BASE_SIZE);
            if (DEBUG) Log.d(TAG, "add: grow from " + mHashes.length + " to " + n);
            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            allocArrays(n);
            if (mHashes.length > 0) {
                if (DEBUG) Log.d(TAG, "add: copy 0-" + mSize + " to 0");
                System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
                System.arraycopy(oarray, 0, mArray, 0, oarray.length);
            }
            freeArrays(ohashes, oarray, mSize);
        }
        if (index < mSize) {
            if (DEBUG) Log.d(TAG, "add: move " + index + "-" + (mSize - index)
                    + " to " + (index + 1));
            System.arraycopy(mHashes, index, mHashes, index + 1, mSize - index);
            System.arraycopy(mArray, index, mArray, index + 1, mSize - index);
        }
        mHashes[index] = hash;
        mArray[index] = value;
        mSize++;
        return true;
    }

    /**
     * Perform a {@link #add(Object)} of all values in <var>array</var>
     *
     * @param array The array whose contents are to be retrieved.
     */
    public void addAll(ArraySet<? extends E> array) {
        final int N = array.mSize;
        ensureCapacity(mSize + N);
        if (mSize == 0) {
            if (N > 0) {
                System.arraycopy(array.mHashes, 0, mHashes, 0, N);
                System.arraycopy(array.mArray, 0, mArray, 0, N);
                mSize = N;
            }
        } else {
            for (int i = 0; i < N; i++) {
                add(array.valueAt(i));
            }
        }
    }

    /**
     * Removes the specified object from this set.
     *
     * @param object the object to remove.
     * @return {@code true} if this set was modified, {@code false} otherwise.
     */
    @Override
    public boolean remove(Object object) {
        final int index = indexOf(object);
        if (index >= 0) {
            removeAt(index);
            return true;
        }
        return false;
    }

    /**
     * Remove the key/value mapping at the given index.
     *
     * @param index The desired index, must be between 0 and {@link #size()}-1.
     * @return Returns the value that was stored at this index.
     */
    public E removeAt(int index) {
        final Object old = mArray[index];
        if (mSize <= 1) {
            // Now empty.
            if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to 0");
            freeArrays(mHashes, mArray, mSize);
            mHashes = EmptyArray.INT;
            mArray = EmptyArray.OBJECT;
            mSize = 0;
        } else {
            if (mHashes.length > (BASE_SIZE * 2) && mSize < mHashes.length / 3) {
                // Shrunk enough to reduce size of arrays. We don't allow it to
                // shrink smaller than (BASE_SIZE*2) to avoid flapping between
                // that and BASE_SIZE.
                final int n = mSize > (BASE_SIZE * 2) ? (mSize + (mSize >> 1)) : (BASE_SIZE * 2);
                if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to " + n);
                final int[] ohashes = mHashes;
                final Object[] oarray = mArray;
                allocArrays(n);
                mSize--;
                if (index > 0) {
                    if (DEBUG) Log.d(TAG, "remove: copy from 0-" + index + " to 0");
                    System.arraycopy(ohashes, 0, mHashes, 0, index);
                    System.arraycopy(oarray, 0, mArray, 0, index);
                }
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: copy from " + (index + 1) + "-" + mSize
                            + " to " + index);
                    System.arraycopy(ohashes, index + 1, mHashes, index, mSize - index);
                    System.arraycopy(oarray, index + 1, mArray, index, mSize - index);
                }
            } else {
                mSize--;
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: move " + (index + 1) + "-" + mSize
                            + " to " + index);
                    System.arraycopy(mHashes, index + 1, mHashes, index, mSize - index);
                    System.arraycopy(mArray, index + 1, mArray, index, mSize - index);
                }
                mArray[mSize] = null;
            }
        }
        return (E) old;
    }

    /**
     * Return the number of items in this array map.
     */
    @Override
    public int size() {
        return mSize;
    }

    @Override
    public Object[] toArray() {
        Object[] result = new Object[mSize];
        System.arraycopy(mArray, 0, result, 0, mSize);
        return result;
    }

    @Override
    public <T> T[] toArray(T[] array) {
        if (array.length < mSize) {
            @SuppressWarnings("unchecked") T[] newArray
                    = (T[]) Array.newInstance(array.getClass().getComponentType(), mSize);
            array = newArray;
        }
        System.arraycopy(mArray, 0, array, 0, mSize);
        if (array.length > mSize) {
            array[mSize] = null;
        }
        return array;
    }

    /**
     * {@inheritDoc}
     *

     *
This implementation returns false if the object is not a set, or
     * if the sets have different sizes. Otherwise, for each value in this
     * set, it checks to make sure the value also exists in the other set.
     * If any value doesn't exist, the method returns false; otherwise, it
     * returns true.
     */
    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }
        if (object instanceof Set) {
            Set<?> set = (Set<?>) object;
            if (size() != set.size()) {
                return false;
            }
            try {
                for (int i = 0; i < mSize; i++) {
                    E mine = valueAt(i);
                    if (!set.contains(mine)) {
                        return false;
                    }
                }
            } catch (NullPointerException ignored) {
                return false;
            } catch (ClassCastException ignored) {
                return false;
            }
            return true;
        }
        return false;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int hashCode() {
        final int[] hashes = mHashes;
        int result = 0;
        for (int i = 0, s = mSize; i < s; i++) {
            result += hashes[i];
        }
        return result;
    }

    /**
     * {@inheritDoc}
     *

     *
This implementation composes a string by iterating over its values. If
     * this set contains itself as a value, the string "(this Set)"
     * will appear in its place.
     */
    @Override
    public String toString() {
        if (isEmpty()) {
            return "{}";
        }
        StringBuilder buffer = new StringBuilder(mSize * 14);
        buffer.append('{');
        for (int i = 0; i < mSize; i++) {
            if (i > 0) {
                buffer.append(", ");
            }
            Object value = valueAt(i);
            if (value != this) {
                buffer.append(value);
            } else {
                buffer.append("(this Set)");
            }
        }
        buffer.append('}');
        return buffer.toString();
    }

    // ------------------------------------------------------------------------
    // Interop with traditional Java containers. Not as efficient as using
    // specialized collection APIs.
    // ------------------------------------------------------------------------
    private MapCollections<E, E> getCollection() {
        if (mCollections == null) {
            mCollections = new MapCollections<E, E>() {
                @Override
                protected int colGetSize() {
                    return mSize;
                }

                @Override
                protected Object colGetEntry(int index, int offset) {
                    return mArray[index];
                }

                @Override
                protected int colIndexOfKey(Object key) {
                    return indexOf(key);
                }

                @Override
                protected int colIndexOfValue(Object value) {
                    return indexOf(value);
                }

                @Override
                protected Map<E, E> colGetMap() {
                    throw new UnsupportedOperationException("not a map");
                }

                @Override
                protected void colPut(E key, E value) {
                    add(key);
                }

                @Override
                protected E colSetValue(int index, E value) {
                    throw new UnsupportedOperationException("not a map");
                }

                @Override
                protected void colRemoveAt(int index) {
                    removeAt(index);
                }

                @Override
                protected void colClear() {
                    clear();
                }
            };
        }
        return mCollections;
    }

    @Override
    public Iterator<E> iterator() {
        return getCollection().getKeySet().iterator();
    }

    @Override
    public boolean containsAll(Collection<?> collection) {
        Iterator<?> it = collection.iterator();
        while (it.hasNext()) {
            if (!contains(it.next())) {
                return false;
            }
        }
        return true;
    }

    @Override
    public boolean addAll(Collection<? extends E> collection) {
        ensureCapacity(mSize + collection.size());
        boolean added = false;
        for (E value : collection) {
            added |= add(value);
        }
        return added;
    }

    @Override
    public boolean removeAll(Collection<?> collection) {
        boolean removed = false;
        for (Object value : collection) {
            removed |= remove(value);
        }
        return removed;
    }

    @Override
    public boolean retainAll(Collection<?> collection) {
        boolean removed = false;
        for (int i = mSize - 1; i >= 0; i--) {
            if (!collection.contains(mArray[i])) {
                removeAt(i);
                removed = true;
            }
        }
        return removed;
    }
}


package me.tatarka.support.internal;

import android.annotation.TargetApi;
import android.os.Build;
import android.os.IBinder;
import android.os.RemoteException;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import me.tatarka.support.job.IJobCallback;
import me.tatarka.support.job.IJobService;
import me.tatarka.support.job.JobParameters;
import me.tatarka.support.os.PersistableBundle;

/** @hide */
public abstract class IJobServiceCompat {
    private IBinder mBinder;

    public IJobServiceCompat() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            mBinder = new android.app.job.IJobService.Stub() {
                @Override
                public void startJob(android.app.job.JobParameters jobParams) throws RemoteException {
                    IJobServiceCompat.this.startJob(convertFromJobParameters(jobParams));
                }

                @Override
                public void stopJob(android.app.job.JobParameters jobParams) throws RemoteException {
                    IJobServiceCompat.this.stopJob(convertFromJobParameters(jobParams));
                }
            }.asBinder();
        } else {
            mBinder = new IJobService.Stub() {
                @Override
                public void startJob(JobParameters jobParams) throws RemoteException {
                    IJobServiceCompat.this.startJob(jobParams);
                }

                @Override
                public void stopJob(JobParameters jobParams) throws RemoteException {
                    IJobServiceCompat.this.stopJob(jobParams);
                }
            }.asBinder();
        }
    }

    public abstract void startJob(JobParameters jobParams);
    public abstract void stopJob(JobParameters jobParams);

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private static me.tatarka.support.job.JobParameters convertFromJobParameters(final android.app.job.JobParameters params) {
        return new me.tatarka.support.job.JobParameters(new IJobCallback.Stub() {
            @Override
            public void jobFinished(int jobId, boolean needsReschedule) throws RemoteException {
                android.app.job.IJobCallback callback = getCallback(params);
                if (callback != null) {
                    callback.jobFinished(jobId, needsReschedule);
                }
            }

            @Override
            public void acknowledgeStartMessage(int jobId, boolean workOngoing) throws RemoteException {
                android.app.job.IJobCallback callback = getCallback(params);
                if (callback != null) {
                    callback.acknowledgeStartMessage(jobId, workOngoing);
                }
            }

            @Override
            public void acknowledgeStopMessage(int jobId, boolean reschedule) throws RemoteException {
                android.app.job.IJobCallback callback = getCallback(params);
                if (callback != null) {
                    callback.acknowledgeStopMessage(jobId, reschedule);
                }
            }
        }, params.getJobId(), new PersistableBundle(params.getExtras()), params.isOverrideDeadlineExpired());
    }

    public IBinder asBinder() {
        return mBinder;
    }

    // getCallback() is a hidden method on JobParameters, so use reflection to get at it.
    private static android.app.job.IJobCallback getCallback(android.app.job.JobParameters params) {
        Method method;
        try {
            method = params.getClass().getDeclaredMethod("getCallback");
            method.setAccessible(true);
            return (android.app.job.IJobCallback) method.invoke(params);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}


/*
 * Copyright 2013 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 me.tatarka.support.job.sample;

import android.content.ComponentName;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.Messenger;
import android.support.v7.app.ActionBarActivity;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.RadioButton;
import android.widget.TextView;
import android.widget.Toast;

import java.util.List;

import me.tatarka.support.job.JobInfo;
import me.tatarka.support.job.JobParameters;
import me.tatarka.support.job.JobScheduler;
import me.tatarka.support.job.sample.service.TestJobService;

public class MainActivity extends ActionBarActivity {

    private static final String TAG = "MainActivity";

    public static final int MSG_UNCOLOUR_START = 0;
    public static final int MSG_UNCOLOUR_STOP = 1;
    public static final int MSG_SERVICE_OBJ = 2;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.sample_main);
        Resources res = getResources();
        defaultColor = res.getColor(R.color.none_received);
        startJobColor = res.getColor(R.color.start_received);
        stopJobColor = res.getColor(R.color.stop_received);

        // Set up UI.
        mShowStartView = (TextView) findViewById(R.id.onstart_textview);
        mShowStopView = (TextView) findViewById(R.id.onstop_textview);
        mParamsTextView = (TextView) findViewById(R.id.task_params);
        mDelayEditText = (EditText) findViewById(R.id.delay_time);
        mDeadlineEditText = (EditText) findViewById(R.id.deadline_time);
        mPeriodEditText = (EditText) findViewById(R.id.period_time);
        mNoConnectivityRadioButton = (RadioButton) findViewById(R.id.checkbox_none);
        mWiFiConnectivityRadioButton = (RadioButton) findViewById(R.id.checkbox_unmetered);
        mAnyConnectivityRadioButton = (RadioButton) findViewById(R.id.checkbox_any);
        mRequiresChargingCheckBox = (CheckBox) findViewById(R.id.checkbox_charging);
        mRequiresIdleCheckbox = (CheckBox) findViewById(R.id.checkbox_idle);
        mPersistedCheckbox = (CheckBox) findViewById(R.id.checkbox_persisted);
        mBackoffDelayEditText = (EditText) findViewById(R.id.backoff_delay_time);
        mBackoffLinearRadioButton = (RadioButton) findViewById(R.id.checkbox_linear);
        mBackoffExponentialRadioButton = (RadioButton) findViewById(R.id.checkbox_exponential);
        mServiceComponent = new ComponentName(this, TestJobService.class);
        // Start service and provide it a way to communicate with us.
        Intent startServiceIntent = new Intent(this, TestJobService.class);
        startServiceIntent.putExtra("messenger", new Messenger(mHandler));
        startService(startServiceIntent);
    }

    // UI fields.
    int defaultColor;
    int startJobColor;
    int stopJobColor;

    private TextView mShowStartView;
    private TextView mShowStopView;
    private TextView mParamsTextView;
    private EditText mDelayEditText;
    private EditText mDeadlineEditText;
    private EditText mPeriodEditText;
    private EditText mBackoffDelayEditText;
    private RadioButton mNoConnectivityRadioButton;
    private RadioButton mWiFiConnectivityRadioButton;
    private RadioButton mAnyConnectivityRadioButton;
    private CheckBox mRequiresChargingCheckBox;
    private CheckBox mRequiresIdleCheckbox;
    private CheckBox mPersistedCheckbox;
    private RadioButton mBackoffLinearRadioButton;
    private RadioButton mBackoffExponentialRadioButton;

    ComponentName mServiceComponent;
    /**
     * Service object to interact scheduled jobs.
     */
    TestJobService mTestService;

    private static int kJobId = 0;

    Handler mHandler = new Handler(/* default looper */) {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_UNCOLOUR_START:
                    mShowStartView.setBackgroundColor(defaultColor);
                    break;
                case MSG_UNCOLOUR_STOP:
                    mShowStopView.setBackgroundColor(defaultColor);
                    break;
                case MSG_SERVICE_OBJ:
                    mTestService = (TestJobService) msg.obj;
                    mTestService.setUiCallback(MainActivity.this);
            }
        }
    };

    private boolean ensureTestService() {
        if (mTestService == null) {
            Toast.makeText(MainActivity.this, "Service null, never got callback?",
                    Toast.LENGTH_SHORT).show();
            return false;
        }
        return true;
    }

    /**
     * UI onclick listener to schedule a job. What this job is is defined in
     * TestJobService#scheduleJob().
     */
    public void scheduleJob(View v) {
        if (!ensureTestService()) {
            return;
        }

        JobInfo.Builder builder = new JobInfo.Builder(kJobId++, mServiceComponent);

        String delay = mDelayEditText.getText().toString();
        if (delay != null && !TextUtils.isEmpty(delay)) {
            builder.setMinimumLatency(Long.valueOf(delay) * 1000);
        }
        String deadline = mDeadlineEditText.getText().toString();
        if (deadline != null && !TextUtils.isEmpty(deadline)) {
            builder.setOverrideDeadline(Long.valueOf(deadline) * 1000);
        }
        String period = mPeriodEditText.getText().toString();
        if (period != null && !TextUtils.isEmpty(period)) {
            builder.setPeriodic(Long.valueOf(period) * 1000);
        }
        boolean requiresUnmetered = mWiFiConnectivityRadioButton.isChecked();
        boolean requiresAnyConnectivity = mAnyConnectivityRadioButton.isChecked();
        if (requiresUnmetered) {
            builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
        } else if (requiresAnyConnectivity) {
            builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
        }
        builder.setRequiresDeviceIdle(mRequiresIdleCheckbox.isChecked());
        builder.setRequiresCharging(mRequiresChargingCheckBox.isChecked());
        builder.setPersisted(mPersistedCheckbox.isChecked());

        String backoffTime = mBackoffDelayEditText.getText().toString();
        if (backoffTime != null && !TextUtils.isEmpty(backoffTime)) {
            int backoffPolicy = mBackoffLinearRadioButton.isChecked() ? JobInfo.BACKOFF_POLICY_LINEAR : JobInfo.BACKOFF_POLICY_EXPONENTIAL;
            builder.setBackoffCriteria(Long.valueOf(backoffTime) * 1000, backoffPolicy);
        }

        try {
            mTestService.scheduleJob(builder.build());
        } catch (IllegalArgumentException e) {
            Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
        } catch (IllegalStateException e) {
            Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
        }
    }

    public void cancelAllJobs(View v) {
        JobScheduler tm = JobScheduler.getInstance(this);
        List<JobInfo> pendingJobs = tm.getAllPendingJobs();
        for (JobInfo job : pendingJobs) {
            Log.d(TAG, "canceled job: " + job.getId());
        }
        tm.cancelAll();
    }

    /**
     * UI onclick listener to call jobFinished() in our service.
     */
    public void finishJob(View v) {
        if (!ensureTestService()) {
            return;
        }
        mTestService.callJobFinished();
        mParamsTextView.setText("");
    }

    public void failJob(View v) {
        if (!ensureTestService()) {
            return;
        }
        mTestService.callJobFailed();
        mParamsTextView.setText("");
    }

    /**
     * Receives callback from the service when a job has landed on the app. Colours the UI and post
     * a message to uncolour it after a second.
     */
    public void onReceivedStartJob(JobParameters params) {
        mShowStartView.setBackgroundColor(startJobColor);
        Message m = Message.obtain(mHandler, MSG_UNCOLOUR_START);
        mHandler.sendMessageDelayed(m, 1000L); // uncolour in 1 second.
        mParamsTextView.setText("Executing: " + params.getJobId() + " " + params.getExtras());
    }

    /**
     * Receives callback from the service when a job that previously landed on the app must stop
     * executing. Colours the UI and post a message to uncolour it after a second.
     */
    public void onReceivedStopJob() {
        mShowStopView.setBackgroundColor(stopJobColor);
        Message m = Message.obtain(mHandler, MSG_UNCOLOUR_STOP);
        mHandler.sendMessageDelayed(m, 2000L); // uncolour in 1 second.
        mParamsTextView.setText("");
    }
}


package me.tatarka.support.internal.receivers;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import android.support.v4.content.WakefulBroadcastReceiver;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;

import me.tatarka.support.internal.job.JobServiceCompat;
import me.tatarka.support.internal.job.JobStore;
import me.tatarka.support.internal.util.ArraySet;

/**
 * @hide
 */
public class TimeReceiver extends WakefulBroadcastReceiver {
    private static final String ACTION_JOB_EXPIRED =
            "me.tatarka.support.jobscheduler.JOB_DEADLINE_EXPIRED";
    private static final String ACTION_JOB_DELAY_EXPIRED =
            "me.tatarka.support.jobscheduler.JOB_DELAY_EXPIRED";

    @Override
    public void onReceive(Context context, Intent intent) {
        List<JobStatus> jobs = getJobsSortedByLatestRuntime(context);
        if (ACTION_JOB_EXPIRED.equals(intent.getAction())) {
            checkExpiredDeadlinesAndResetAlarm(context, jobs);
        } else if (ACTION_JOB_DELAY_EXPIRED.equals(intent.getAction())) {
            checkExpiredDelaysAndResetAlarm(context, jobs);
        }
    }

    public static void setAlarmsForJob(Context context, JobStatus job) {
        if (job.hasTimingDelayConstraint() || job.hasDeadlineConstraint()) {
            List<JobStatus> jobs = getJobsSortedByLatestRuntime(context);
            maybeStopTrackingJob(context, job, jobs);
            maybeUpdateAlarms(context,
                    job.hasTimingDelayConstraint() ? job.getEarliestRunTime() : Long.MAX_VALUE,
                    job.hasDeadlineConstraint() ? job.getLatestRunTimeElapsed() : Long.MAX_VALUE);
        }
    }

    public static void unsetAlarmsForJob(Context context, int jobId) {
        List<JobStatus> jobs = getJobsSortedByLatestRuntime(context);
        JobStatus unsetJob = null;
        for (JobStatus job : jobs) {
            if (job.matches(jobId)) {
                unsetJob = job;
                break;
            }
        }

        if (unsetJob != null) {
            maybeStopTrackingJob(context, unsetJob, jobs);
        }
    }

    private static void maybeStopTrackingJob(Context context, JobStatus job, List<JobStatus> jobs) {
        if (jobs.contains(job)) {
            long nextDelayExpiredElapsedMillis = checkExpiredDelaysAndResetAlarm(context, jobs);
            long nextJobExpiredElapsedMillis = checkExpiredDeadlinesAndResetAlarm(context, jobs);
            ControllerPrefs.getInstance(context).edit()
                    .setNextDelayExipredElapsedMillis(nextDelayExpiredElapsedMillis)
                    .setNextJobExpiredElapsedMillis(nextJobExpiredElapsedMillis)
                    .apply();
        }
    }

    private static long checkExpiredDeadlinesAndResetAlarm(Context context, List<JobStatus> jobs) {
        long nextExpiryTime = Long.MAX_VALUE;
        final long nowElapsedMillis = SystemClock.elapsedRealtime();
        Iterator<JobStatus> it = jobs.iterator();
        boolean jobNeedsRun = false;
        while (it.hasNext()) {
            JobStatus job = it.next();
            if (!job.hasDeadlineConstraint()) {
                continue;
            }
            final long jobDeadline = job.getLatestRunTimeElapsed();
            if (jobDeadline <= nowElapsedMillis) {
                job.deadlineConstraintSatisfied.set(true);
                jobNeedsRun = true;
                it.remove();
            } else { // Sorted by expiry time, so take the next one and stop.
                nextExpiryTime = jobDeadline;
                break;
            }
        }

        if (jobNeedsRun) {
            startWakefulService(context, JobServiceCompat.maybeRunJobs(context));
        }

        return setDeadlineExpiredAlarm(context, nextExpiryTime);
    }

    private static long checkExpiredDelaysAndResetAlarm(Context context, List<JobStatus> jobs) {
        final long nowElapsedMillis = SystemClock.elapsedRealtime();
        long nextDelayTime = Long.MAX_VALUE;
        boolean ready = false;
        Iterator<JobStatus> it = jobs.iterator();
        while (it.hasNext()) {
            final JobStatus job = it.next();
            if (!job.hasTimingDelayConstraint()) {
                continue;
            }
            final long jobDelayTime = job.getEarliestRunTime();
            if (jobDelayTime <= nowElapsedMillis) {
                job.timeDelayConstraintSatisfied.set(true);
                if (canStopTrackingJob(job)) {
                    it.remove();
                }
                if (job.isReady()) {
                    ready = true;
                }
            } else { // Keep going through list to get next delay time.
                if (nextDelayTime > jobDelayTime) {
                    nextDelayTime = jobDelayTime;
                }
            }
        }
        if (ready) {
            startWakefulService(context, JobServiceCompat.maybeRunJobs(context));
        }
        return setDelayExpiredAlarm(context, nextDelayTime);
    }

    private static long setDelayExpiredAlarm(Context context, long alarmTimeElapsedMillis) {
        alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
        long nextDelayExpiredElapsedMillis = alarmTimeElapsedMillis;
        Intent intent = new Intent(context, TimeReceiver.class);
        intent.setAction(ACTION_JOB_DELAY_EXPIRED);
        PendingIntent nextDelayExpiredAlarmIntent =
                PendingIntent.getBroadcast(context, 0 /* ignored */, intent, 0);
        updateAlarmWithPendingIntent(context, nextDelayExpiredAlarmIntent, nextDelayExpiredElapsedMillis);
        return nextDelayExpiredElapsedMillis;
    }

    private static long setDeadlineExpiredAlarm(Context context, long alarmTimeElapsedMillis) {
        alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
        long nextJobExpiredElapsedMillis = alarmTimeElapsedMillis;
        Intent intent = new Intent(context, TimeReceiver.class);
        intent.setAction(ACTION_JOB_EXPIRED);
        PendingIntent deadlineExpiredAlarmIntent =
                PendingIntent.getBroadcast(context, 0 /* ignored */, intent, 0);
        updateAlarmWithPendingIntent(context, deadlineExpiredAlarmIntent, nextJobExpiredElapsedMillis);
        return nextJobExpiredElapsedMillis;
    }

    private static long maybeAdjustAlarmTime(long proposedAlarmTimeElapsedMillis) {
        final long earliestWakeupTimeElapsed = SystemClock.elapsedRealtime();
        if (proposedAlarmTimeElapsedMillis < earliestWakeupTimeElapsed) {
            return earliestWakeupTimeElapsed;
        }
        return proposedAlarmTimeElapsedMillis;
    }

    private static void updateAlarmWithPendingIntent(Context context, PendingIntent pi, long alarmTimeElapsed) {
        AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        if (alarmTimeElapsed == Long.MAX_VALUE) {
            am.cancel(pi);
        } else {
            am.set(AlarmManager.ELAPSED_REALTIME, alarmTimeElapsed, pi);
        }
    }

    private static void maybeUpdateAlarms(Context context, long delayExpiredElapsed, long deadlineExpiredElapsed) {
        ControllerPrefs prefs = ControllerPrefs.getInstance(context);
        long nextDelayExpiredElapsedMillis = prefs.getNextDelayExpiredElapsedMillis();
        long nextJobExpiredElapsedMillis = prefs.getNextJobExpiredElapsedMillis();

        if (delayExpiredElapsed < nextDelayExpiredElapsedMillis) {
            nextDelayExpiredElapsedMillis = setDelayExpiredAlarm(context, delayExpiredElapsed);
        }

        if (deadlineExpiredElapsed < nextJobExpiredElapsedMillis) {
            nextJobExpiredElapsedMillis = setDeadlineExpiredAlarm(context, deadlineExpiredElapsed);
        }

        prefs.edit()
                .setNextDelayExipredElapsedMillis(nextDelayExpiredElapsedMillis)
                .setNextJobExpiredElapsedMillis(nextJobExpiredElapsedMillis)
                .apply();
    }

    /**
     * Determines whether this controller can stop tracking the given job.
     * The controller is no longer interested in a job once its time constraint is satisfied, and
     * the job's deadline is fulfilled - unlike other controllers a time constraint can't toggle
     * back and forth.
     */
    private static boolean canStopTrackingJob(JobStatus job) {
        return (!job.hasTimingDelayConstraint() ||
                job.timeDelayConstraintSatisfied.get()) &&
                (!job.hasDeadlineConstraint() ||
                        job.deadlineConstraintSatisfied.get());
    }

    private static List<JobStatus> getJobsSortedByLatestRuntime(Context context) {
        List<JobStatus> result = new ArrayList<JobStatus>();
        JobStore jobStore = JobStore.initAndGet(context);
        synchronized (jobStore) {
            ArraySet<JobStatus> jobs = jobStore.getJobs();
            for (int i = 0; i < jobs.size(); i++) {
                JobStatus job = jobs.valueAt(i);
                if (job.hasTimingDelayConstraint() || job.hasDeadlineConstraint()) {
                    result.add(job);
                }
            }
        }
        Collections.sort(result, new Comparator<JobStatus>() {
            @Override
            public int compare(JobStatus lhs, JobStatus rhs) {
                return Long.valueOf(lhs.getLatestRunTimeElapsed()).compareTo(rhs.getLatestRunTimeElapsed());
            }
        });
        return result;
    }
}