Job.java

package com.birbit.android.jobqueue;

import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.birbit.android.jobqueue.log.JqLog;
import com.birbit.android.jobqueue.network.NetworkUtil;
import com.birbit.android.jobqueue.timer.Timer;

import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * Base class for all of your jobs.
 */
@SuppressWarnings("deprecation")
abstract public class Job implements Serializable {
    private static final long serialVersionUID = 3L;
    @SuppressWarnings("WeakerAccess")
    public static final int DEFAULT_RETRY_LIMIT = 20;
    static final String SINGLE_ID_TAG_PREFIX = "job-single-id:";
    // set either in constructor or by the JobHolder
    /**package**/ private transient String id;
    // values set from params
    @NetworkUtil.NetworkStatus
    transient int requiredNetworkType;
    // values set after job is covered by a JobHolder
    private transient String groupId;
    private transient boolean persistent;
    private transient Set<String> readonlyTags;

    private transient int currentRunCount;
    /**package**/ transient int priority;
    private transient long delayInMs;
    private transient long deadlineInMs;
    private transient boolean cancelOnDeadline;
    /*package*/ transient volatile boolean cancelled;

    // set when job is loaded
    private transient Context applicationContext;

    private transient volatile boolean sealed;

    // set when job is loaded
    private transient volatile boolean isDeadlineReached;


    protected Job(Params params) {
        this.id = UUID.randomUUID().toString();
        this.requiredNetworkType = params.requiredNetworkType;
        this.persistent = params.isPersistent();
        this.groupId = params.getGroupId();
        this.priority = params.getPriority();
        this.delayInMs = Math.max(0, params.getDelayMs());
        this.deadlineInMs = Math.max(0, params.getDeadlineMs());
        this.cancelOnDeadline = params.shouldCancelOnDeadline();
        final String singleId = params.getSingleId();
        if (params.getTags() != null || singleId != null) {
            final Set<String> tags = params.getTags() != null ? params.getTags() : new HashSet<String>();
            if (singleId != null) {
                final String tagForSingleId = createTagForSingleId(singleId);
                tags.add(tagForSingleId);
                if (this.groupId == null) {
                    this.groupId = tagForSingleId;
                }
            }
            this.readonlyTags = Collections.unmodifiableSet(tags);
        }
        if (deadlineInMs > 0 && deadlineInMs < delayInMs) {
            throw new IllegalArgumentException("deadline cannot be less than the delay. It" +
                    " does not make sense. deadline:" + deadlineInMs + "," +
                    "delay:" + delayInMs);
        }

    }

    public final String getId() {
        return id;
    }

    /**
     * used by {@link JobManager} to assign proper priority at the time job is added.
     * @return priority (higher = better)
     */
    public final int getPriority() {
        return priority;
    }

    /**
     * used by {@link JobManager} to assign proper delay at the time job is added.
     * This field is not persisted!
     * @return delay in ms
     */
    public final long getDelayInMs() {
        return delayInMs;
    }

    /**
     * Returns a readonly set of tags attached to this Job.
     *
     * @return Set of Tags. If tags do not exists, returns null.
     */
    @Nullable
    public final Set<String> getTags() {
        return readonlyTags;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        if (!sealed) {
            throw new IllegalStateException("A job cannot be serialized w/o first being added into"
                    + " a job manager.");
        }
    }

    /**
     * Job class keeps some data within itself which is later moved to the JobHolder.
     * <p>
     * When a Job is saved to disk, this information might be lost. This method is provided as a
     * convenience to the custom queues to put this data back into the Job when it is re-created.
     *
     * @param holder The JobHolder which holds this job.
     */
    final void updateFromJobHolder(JobHolder holder) {
        if (sealed) {
            throw new IllegalStateException("Cannot set a Job from JobHolder after it is sealed.");
        }
        id = holder.id;
        groupId = holder.groupId;
        priority = holder.getPriority();
        this.persistent = holder.persistent;
        readonlyTags = holder.tags;
        requiredNetworkType = holder.requiredNetworkType;
        sealed = true; //  deserialized jobs are sealed
    }

    /**
     * Whether we should add this job to disk or non-persistent queue
     *
     * @return True if this job should be persistent between app restarts
     */
    public final boolean isPersistent() {
        return persistent;
    }

    /**
     * Called when the job is added to disk and committed.
     * This means job will eventually run. This is a good time to update local database and dispatch events.
     * <p>
     * Changes to this class will not be preserved if your job is persistent !!!
     * <p>
     * Also, if your app crashes right after adding the job, {@code onRun} might be called without an {@code onAdded} call
     * <p>
     * Note that this method is called on JobManager's thread and will block any other action so
     * it should be fast and not make any web requests (File IO is OK).
     */
    abstract public void onAdded();

    /**
     * The actual method that should to the work.
     * It should finish w/o any exception. If it throws any exception,
     * {@link #shouldReRunOnThrowable(Throwable, int, int)} will be called to
     * decide either to dismiss the job or re-run it.
     *
     * @throws Throwable Can throw and exception which will mark job run as failed
     */
    abstract public void onRun() throws Throwable;

    /**
     * Called when a job is cancelled.
     * @param cancelReason It is one of:
     *                   <ul>
     *                   <li>{@link CancelReason#REACHED_RETRY_LIMIT}</li>
     *                   <li>{@link CancelReason#CANCELLED_VIA_SHOULD_RE_RUN}</li>
     *                   <li>{@link CancelReason#CANCELLED_WHILE_RUNNING}</li>
     *                   <li>{@link CancelReason#SINGLE_INSTANCE_WHILE_RUNNING}</li>
     *                   <li>{@link CancelReason#SINGLE_INSTANCE_ID_QUEUED}</li>
     *                   </ul>
     * @param throwable The exception that was thrown from the last execution of {@link #onRun()}
     */
    abstract protected void onCancel(@CancelReason int cancelReason, @Nullable Throwable throwable);

    /**
     * If {@code onRun} method throws an exception, this method is called.
     * <p>
     * If you simply want to return retry or cancel, you can use {@link RetryConstraint#RETRY} or
     * {@link RetryConstraint#CANCEL}.
     * <p>
     * You can also use a custom {@link RetryConstraint} where you can change the Job's priority or
     * add a delay until the next run (e.g. exponential back off).
     * <p>
     * Note that changing the Job's priority or adding a delay may alter the original run order of
     * the job. So if the job was added to the queue with other jobs and their execution order is
     * important (e.g. they use the same groupId), you should not change job's priority or add a
     * delay unless you really want to change their execution order.
     *
     * @param throwable The exception that was thrown from {@link #onRun()}
     * @param runCount The number of times this job run. Starts from 1.
     * @param maxRunCount The max number of times this job can run. Decided by {@link #getRetryLimit()}
     *
     * @return A {@link RetryConstraint} to decide whether this Job should be tried again or not and
     * if yes, whether we should add a delay or alter its priority. Returning null from this method
     * is equal to returning {@link RetryConstraint#RETRY}.
     */
    abstract protected RetryConstraint shouldReRunOnThrowable(@NonNull Throwable throwable, int runCount, int maxRunCount);

    /**
     * Runs the job and catches any exception
     *
     * @param currentRunCount The current run count of the job
     *
     * @return one of the RUN_RESULT ints
     */
    final int safeRun(JobHolder holder, int currentRunCount, Timer timer) {
        this.currentRunCount = currentRunCount;
        if (JqLog.isDebugEnabled()) {
            JqLog.d("running job %s", this.getClass().getSimpleName());
        }
        boolean reRun = false;
        boolean failed = false;
        Throwable throwable = null;
        boolean cancelForDeadline = false;
        try {
            onRun();
            if (JqLog.isDebugEnabled()) {
                JqLog.d("finished job %s", this);
            }
        } catch (Throwable t) {
            failed = true;
            throwable = t;
            JqLog.e(t, "error while executing job %s", this);
            cancelForDeadline = holder.shouldCancelOnDeadline()
                    && holder.getDeadlineNs() <= timer.nanoTime();
            reRun = currentRunCount < getRetryLimit() && !cancelForDeadline;
            if(reRun && !cancelled) {
                try {
                    RetryConstraint retryConstraint = shouldReRunOnThrowable(t, currentRunCount,
                            getRetryLimit());
                    if (retryConstraint == null) {
                        retryConstraint = RetryConstraint.RETRY;
                    }
                    holder.retryConstraint = retryConstraint;
                    reRun = retryConstraint.shouldRetry();
                } catch (Throwable t2) {
                    JqLog.e(t2, "shouldReRunOnThrowable did throw an exception");
                }
            }
        }
        JqLog.d("safeRunResult for %s : %s. re run:%s. cancelled: %s", this, !failed, reRun, cancelled);
        if (!failed) {
            return JobHolder.RUN_RESULT_SUCCESS;
        }
        if (holder.isCancelledSingleId()) {
            return JobHolder.RUN_RESULT_FAIL_SINGLE_ID;
        }
        if (holder.isCancelled()) {
            return JobHolder.RUN_RESULT_FAIL_FOR_CANCEL;
        }
        if (reRun) {
            return JobHolder.RUN_RESULT_TRY_AGAIN;
        }
        if (cancelForDeadline) {
            return JobHolder.RUN_RESULT_HIT_DEADLINE;
        }
        if (currentRunCount < getRetryLimit()) {
            return JobHolder.RUN_RESULT_FAIL_SHOULD_RE_RUN;
        } else {
            // only set the Throwable if we are sure the Job is not gonna run again
            holder.setThrowable(throwable);
            return JobHolder.RUN_RESULT_FAIL_RUN_LIMIT;
        }
    }

    /**
     * Before each run, JobManager sets this number.
     * <p>
     * Might be useful for the {@link com.birbit.android.jobqueue.Job#onRun()} method
     *
     * @return The current retry count of the Job
     */
    public final int getCurrentRunCount() {
        return currentRunCount;
    }

    /**
     * Some jobs may require being run synchronously. For instance, if it is a job like sending a comment, we should
     * never run them in parallel (unless they are being sent to different conversations).
     * By assigning same groupId to jobs, you can ensure that that type of jobs will be run in the order they were given
     * (if their priority is the same).
     *
     * @return The groupId of the job or null if it is not grouped
     */
    public final String getRunGroupId() {
        return groupId;
    }

    /**
     * Some jobs only need a single instance to be queued to run. For instance, if a user has made several changes
     * to a resource while offline, you can save every change locally during {@link #onAdded()}, but
     * only update the resource remotely once with the latest changes.
     *
     * @return The single instance id of the job or null if it is not a single instance job
     */
    public final String getSingleInstanceId() {
        if (readonlyTags != null) {
            for (String tag : readonlyTags) {
                if (tag.startsWith(SINGLE_ID_TAG_PREFIX)) {
                    return tag;
                }
            }
        }
        return null;
    }

    private String createTagForSingleId(String singleId) {
        return SINGLE_ID_TAG_PREFIX + singleId;
    }

    /**
     * By default, jobs will be retried {@code DEFAULT_RETRY_LIMIT}  times.
     * If job fails this many times, onCancel will be called w/o calling {@link #shouldReRunOnThrowable(Throwable, int, int)}
     *
     * @return The number of times the job should be re-tried before being cancelled automatically
     */
    protected int getRetryLimit() {
        return DEFAULT_RETRY_LIMIT;
    }

    /**
     * Returns true if job is cancelled. Note that if the job is already running when it is cancelled,
     * this flag is still set to true but job is NOT STOPPED (e.g. JobManager does not interrupt
     * the thread).
     * If you have a long job that may be cancelled, you can check this field and handle it manually.
     * <p>
     * Note that, if your job returns successfully from {@link #onRun()} method, it will be considered
     * as successfully completed, thus will be added to {@link CancelResult#getFailedToCancel()}
     * list. If you want this job to be considered as cancelled, you should throw an exception.
     * You can also use {@link #assertNotCancelled()} method to do it.
     * <p>
     * Calling this method outside {@link #onRun()} method has no meaning since {@link #onRun()} will not
     * be called if the job is cancelled before it is called.
     *
     * @return true if the Job is cancelled
     */
    public final boolean isCancelled() {
        return cancelled;
    }

    /**
     * Convenience method that checks if job is cancelled and throws a RuntimeException if it is
     * cancelled.
     */
    @SuppressWarnings({"WeakerAccess", "unused"})
    public void assertNotCancelled() {
        if (cancelled) {
            throw new RuntimeException("job is cancelled");
        }
    }

    /*package*/ void setApplicationContext(Context context) {
        this.applicationContext = context;
    }

    /*package*/ void setDeadlineReached(boolean deadlineReached) {
        isDeadlineReached = deadlineReached;
    }

    /**
     * Convenience method to get the application context in a Job.
     * <p>
     * This context is set when job is added to a JobManager.
     *
     * @return The application context
     */
    @SuppressWarnings("WeakerAccess")
    public Context getApplicationContext() {
        return applicationContext;
    }

    /**
     * Returns true if the job's deadline is reached.
     * <p>
     * Note that this method is safe to access only when the job is running. Value is undefined
     * if it is called outside the {@link #onRun()} method.
     *
     * @return true if job reached its deadline, false otherwise
     */
    public boolean isDeadlineReached() {
        return isDeadlineReached;
    }

    /**
     * Returns whether job requires a network connection to be run or not.
     *
     * @return True if job requires a network to be run, false otherwise.
     */
    @SuppressWarnings("unused")
    public final boolean requiresNetwork() {
        return requiredNetworkType >= NetworkUtil.METERED;
    }

    /**
     * Returns whether job requires a unmetered network connection to be run or not.
     *
     * @return True if job requires a unmetered network to be run, false otherwise.
     */
    @SuppressWarnings("unused")
    public final boolean requiresUnmeteredNetwork() {
        return requiredNetworkType >= NetworkUtil.UNMETERED;
    }

    /**package**/ long getDeadlineInMs() {
        return deadlineInMs;
    }

    /**package**/ boolean shouldCancelOnDeadline() {
        return cancelOnDeadline;
    }
}