/*
 * 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 com.android.server.job;

import android.app.ActivityManager;
import android.app.job.JobParameters;
import android.app.job.IJobCallback;
import android.app.job.IJobService;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.PowerManager;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.UserHandle;
import android.os.WorkSource;
import android.util.Slog;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.app.IBatteryStats;
import com.android.server.job.controllers.JobStatus;

import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Handles client binding and lifecycle of a job. Jobs execute one at a time on an instance of this
 * class.
 *
 * There are two important interactions into this class from the
 * {@link com.android.server.job.JobSchedulerService}. To execute a job and to cancel a job.
 * - Execution of a new job is handled by the {@link #mAvailable}. This bit is flipped once when a
 * job lands, and again when it is complete.
 * - Cancelling is trickier, because there are also interactions from the client. It's possible
 * the {@link com.android.server.job.JobServiceContext.JobServiceHandler} tries to process a
 * {@link #MSG_CANCEL} after the client has already finished. This is handled by having
 * {@link com.android.server.job.JobServiceContext.JobServiceHandler#handleCancelH} check whether
 * the context is still valid.
 * To mitigate this, tearing down the context removes all messages from the handler, including any
 * tardy {@link #MSG_CANCEL}s. Additionally, we avoid sending duplicate onStopJob()
 * calls to the client after they've specified jobFinished().
 */
public class JobServiceContext extends IJobCallback.Stub implements ServiceConnection {
    private static final boolean DEBUG = JobSchedulerService.DEBUG;
    private static final String TAG = "JobServiceContext";
    /** Define the maximum # of jobs allowed to run on a service at once. */
    private static final int defaultMaxActiveJobsPerService =
            ActivityManager.isLowRamDeviceStatic() ? 1 : 3;
    /** Amount of time a job is allowed to execute for before being considered timed-out. */
    private static final long EXECUTING_TIMESLICE_MILLIS = 10 * 60 * 1000;  // 10mins.
    /** Amount of time the JobScheduler will wait for a response from an app for a message. */
    private static final long OP_TIMEOUT_MILLIS = 8 * 1000;

    private static final String[] VERB_STRINGS = {
            "VERB_BINDING", "VERB_STARTING", "VERB_EXECUTING", "VERB_STOPPING", "VERB_FINISHED"
    };

    // States that a job occupies while interacting with the client.
    static final int VERB_BINDING = 0;
    static final int VERB_STARTING = 1;
    static final int VERB_EXECUTING = 2;
    static final int VERB_STOPPING = 3;
    static final int VERB_FINISHED = 4;

    // Messages that result from interactions with the client service.
    /** System timed out waiting for a response. */
    private static final int MSG_TIMEOUT = 0;
    /** Received a callback from client. */
    private static final int MSG_CALLBACK = 1;
    /** Run through list and start any ready jobs.*/
    private static final int MSG_SERVICE_BOUND = 2;
    /** Cancel a job. */
    private static final int MSG_CANCEL = 3;
    /** Shutdown the job. Used when the client crashes and we can't die gracefully.*/
    private static final int MSG_SHUTDOWN_EXECUTION = 4;

    public static final int NO_PREFERRED_UID = -1;

    private final Handler mCallbackHandler;
    /** Make callbacks to {@link JobSchedulerService} to inform on job completion status. */
    private final JobCompletedListener mCompletedListener;
    /** Used for service binding, etc. */
    private final Context mContext;
    private final Object mLock;
    private final IBatteryStats mBatteryStats;
    private PowerManager.WakeLock mWakeLock;

    // Execution state.
    private JobParameters mParams;
    @VisibleForTesting
    int mVerb;
    private AtomicBoolean mCancelled = new AtomicBoolean();

    /**
     * All the information maintained about the job currently being executed.
     *
     * Any reads (dereferences) not done from the handler thread must be synchronized on
     * {@link #mLock}.
     * Writes can only be done from the handler thread, or {@link #executeRunnableJob(JobStatus)}.
     */
    private JobStatus mRunningJob;
    /** Used to store next job to run when current job is to be preempted. */
    private int mPreferredUid;
    IJobService service;

    /**
     * Whether this context is free. This is set to false at the start of execution, and reset to
     * true when execution is complete.
     */
    @GuardedBy("mLock")
    private boolean mAvailable;
    /** Track start time. */
    private long mExecutionStartTimeElapsed;
    /** Track when job will timeout. */
    private long mTimeoutElapsed;

    JobServiceContext(JobSchedulerService service, IBatteryStats batteryStats, Looper looper) {
        this(service.getContext(), service.getLock(), batteryStats, service, looper);
    }

    @VisibleForTesting
    JobServiceContext(Context context, Object lock, IBatteryStats batteryStats,
                      JobCompletedListener completedListener, Looper looper) {
        mContext = context;
        mLock = lock;
        mBatteryStats = batteryStats;
        mCallbackHandler = new JobServiceHandler(looper);
        mCompletedListener = completedListener;
        mAvailable = true;
        mVerb = VERB_FINISHED;
        mPreferredUid = NO_PREFERRED_UID;
    }

    /**
     * Give a job to this context for execution. Callers must first check {@link #getRunningJob()}
     * and ensure it is null to make sure this is a valid context.
     * @param job The status of the job that we are going to run.
     * @return True if the job is valid and is running. False if the job cannot be executed.
     */
    boolean executeRunnableJob(JobStatus job) {
        synchronized (mLock) {
            if (!mAvailable) {
                Slog.e(TAG, "Starting new runnable but context is unavailable > Error.");
                return false;
            }

            mPreferredUid = NO_PREFERRED_UID;

            mRunningJob = job;
            final boolean isDeadlineExpired =
                    job.hasDeadlineConstraint() &&
                            (job.getLatestRunTimeElapsed() < SystemClock.elapsedRealtime());
            Uri[] triggeredUris = null;
            if (job.changedUris != null) {
                triggeredUris = new Uri[job.changedUris.size()];
                job.changedUris.toArray(triggeredUris);
            }
            String[] triggeredAuthorities = null;
            if (job.changedAuthorities != null) {
                triggeredAuthorities = new String[job.changedAuthorities.size()];
                job.changedAuthorities.toArray(triggeredAuthorities);
            }
            mParams = new JobParameters(this, job.getJobId(), job.getExtras(), isDeadlineExpired,
                    triggeredUris, triggeredAuthorities);
            mExecutionStartTimeElapsed = SystemClock.elapsedRealtime();

            mVerb = VERB_BINDING;
            scheduleOpTimeOut();
            final Intent intent = new Intent().setComponent(job.getServiceComponent());
            boolean binding = mContext.bindServiceAsUser(intent, this,
                    Context.BIND_AUTO_CREATE | Context.BIND_NOT_FOREGROUND,
                    new UserHandle(job.getUserId()));
            if (!binding) {
                if (DEBUG) {
                    Slog.d(TAG, job.getServiceComponent().getShortClassName() + " unavailable.");
                }
                mRunningJob = null;
                mParams = null;
                mExecutionStartTimeElapsed = 0L;
                mVerb = VERB_FINISHED;
                removeOpTimeOut();
                return false;
            }
            try {
                mBatteryStats.noteJobStart(job.getBatteryName(), job.getSourceUid());
            } catch (RemoteException e) {
                // Whatever.
            }
            mAvailable = false;
            return true;
        }
    }

    /**
     * Used externally to query the running job. Will return null if there is no job running.
     * Be careful when using this function, at any moment it's possible that the job returned may
     * stop executing.
     */
    JobStatus getRunningJob() {
        synchronized (mLock) {
            return mRunningJob == null ?
                    null : new JobStatus(mRunningJob);
        }
    }

    /** Called externally when a job that was scheduled for execution should be cancelled. */
    void cancelExecutingJob(int reason) {
        mCallbackHandler.obtainMessage(MSG_CANCEL, reason, 0 /* unused */).sendToTarget();
    }

    void preemptExecutingJob() {
        Message m = mCallbackHandler.obtainMessage(MSG_CANCEL);
        m.arg1 = JobParameters.REASON_PREEMPT;
        m.sendToTarget();
    }

    int getPreferredUid() {
        return mPreferredUid;
    }

    void clearPreferredUid() {
        mPreferredUid = NO_PREFERRED_UID;
    }

    long getExecutionStartTimeElapsed() {
        return mExecutionStartTimeElapsed;
    }

    long getTimeoutElapsed() {
        return mTimeoutElapsed;
    }

    @Override
    public void jobFinished(int jobId, boolean reschedule) {
        if (!verifyCallingUid()) {
            return;
        }
        mCallbackHandler.obtainMessage(MSG_CALLBACK, jobId, reschedule ? 1 : 0)
                .sendToTarget();
    }

    @Override
    public void acknowledgeStopMessage(int jobId, boolean reschedule) {
        if (!verifyCallingUid()) {
            return;
        }
        mCallbackHandler.obtainMessage(MSG_CALLBACK, jobId, reschedule ? 1 : 0)
                .sendToTarget();
    }

    @Override
    public void acknowledgeStartMessage(int jobId, boolean ongoing) {
        if (!verifyCallingUid()) {
            return;
        }
        mCallbackHandler.obtainMessage(MSG_CALLBACK, jobId, ongoing ? 1 : 0).sendToTarget();
    }

    /**
     * We acquire/release a wakelock on onServiceConnected/unbindService. This mirrors the work
     * we intend to send to the client - we stop sending work when the service is unbound so until
     * then we keep the wakelock.
     * @param name The concrete component name of the service that has been connected.
     * @param service The IBinder of the Service's communication channel,
     */
    @Override
    public void onServiceConnected(ComponentName name, IBinder service) {
        JobStatus runningJob;
        synchronized (mLock) {
            // This isn't strictly necessary b/c the JobServiceHandler is running on the main
            // looper and at this point we can't get any binder callbacks from the client. Better
            // safe than sorry.
            runningJob = mRunningJob;
        }
        if (runningJob == null || !name.equals(runningJob.getServiceComponent())) {
            mCallbackHandler.obtainMessage(MSG_SHUTDOWN_EXECUTION).sendToTarget();
            return;
        }
        this.service = IJobService.Stub.asInterface(service);
        final PowerManager pm =
                (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
        mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, runningJob.getTag());
        mWakeLock.setWorkSource(new WorkSource(runningJob.getSourceUid()));
        mWakeLock.setReferenceCounted(false);
        mWakeLock.acquire();
        mCallbackHandler.obtainMessage(MSG_SERVICE_BOUND).sendToTarget();
    }

    /** If the client service crashes we reschedule this job and clean up. */
    @Override
    public void onServiceDisconnected(ComponentName name) {
        mCallbackHandler.obtainMessage(MSG_SHUTDOWN_EXECUTION).sendToTarget();
    }

    /**
     * This class is reused across different clients, and passes itself in as a callback. Check
     * whether the client exercising the callback is the client we expect.
     * @return True if the binder calling is coming from the client we expect.
     */
    private boolean verifyCallingUid() {
        synchronized (mLock) {
            if (mRunningJob == null || Binder.getCallingUid() != mRunningJob.getUid()) {
                if (DEBUG) {
                    Slog.d(TAG, "Stale callback received, ignoring.");
                }
                return false;
            }
            return true;
        }
    }

    /**
     * Handles the lifecycle of the JobService binding/callbacks, etc. The convention within this
     * class is to append 'H' to each function name that can only be called on this handler. This
     * isn't strictly necessary because all of these functions are private, but helps clarity.
     */
    private class JobServiceHandler extends Handler {
        JobServiceHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message message) {
            switch (message.what) {
                case MSG_SERVICE_BOUND:
                    removeOpTimeOut();
                    handleServiceBoundH();
                    break;
                case MSG_CALLBACK:
                    if (DEBUG) {
                        Slog.d(TAG, "MSG_CALLBACK of : " + mRunningJob
                                + " v:" + VERB_STRINGS[mVerb]);
                    }
                    removeOpTimeOut();

                    if (mVerb == VERB_STARTING) {
                        final boolean workOngoing = message.arg2 == 1;
                        handleStartedH(workOngoing);
                    } else if (mVerb == VERB_EXECUTING ||
                            mVerb == VERB_STOPPING) {
                        final boolean reschedule = message.arg2 == 1;
                        handleFinishedH(reschedule);
                    } else {
                        if (DEBUG) {
                            Slog.d(TAG, "Unrecognised callback: " + mRunningJob);
                        }
                    }
                    break;
                case MSG_CANCEL:
                    if (mVerb == VERB_FINISHED) {
                        if (DEBUG) {
                            Slog.d(TAG,
                                   "Trying to process cancel for torn-down context, ignoring.");
                        }
                        return;
                    }
                    mParams.setStopReason(message.arg1);
                    if (message.arg1 == JobParameters.REASON_PREEMPT) {
                        mPreferredUid = mRunningJob != null ? mRunningJob.getUid() :
                                NO_PREFERRED_UID;
                    }
                    handleCancelH();
                    break;
                case MSG_TIMEOUT:
                    handleOpTimeoutH();
                    break;
                case MSG_SHUTDOWN_EXECUTION:
                    closeAndCleanupJobH(true /* needsReschedule */);
                    break;
                default:
                    Slog.e(TAG, "Unrecognised message: " + message);
            }
        }

        /** Start the job on the service. */
        private void handleServiceBoundH() {
            if (DEBUG) {
                Slog.d(TAG, "MSG_SERVICE_BOUND for " + mRunningJob.toShortString());
            }
            if (mVerb != VERB_BINDING) {
                Slog.e(TAG, "Sending onStartJob for a job that isn't pending. "
                        + VERB_STRINGS[mVerb]);
                closeAndCleanupJobH(false /* reschedule */);
                return;
            }
            if (mCancelled.get()) {
                if (DEBUG) {
                    Slog.d(TAG, "Job cancelled while waiting for bind to complete. "
                            + mRunningJob);
                }
                closeAndCleanupJobH(true /* reschedule */);
                return;
            }
            try {
                mVerb = VERB_STARTING;
                scheduleOpTimeOut();
                service.startJob(mParams);
            } catch (RemoteException e) {
                Slog.e(TAG, "Error sending onStart message to '" +
                        mRunningJob.getServiceComponent().getShortClassName() + "' ", e);
            }
        }

        /**
         * State behaviours.
         * VERB_STARTING   -> Successful start, change job to VERB_EXECUTING and post timeout.
         *     _PENDING    -> Error
         *     _EXECUTING  -> Error
         *     _STOPPING   -> Error
         */
        private void handleStartedH(boolean workOngoing) {
            switch (mVerb) {
                case VERB_STARTING:
                    mVerb = VERB_EXECUTING;
                    if (!workOngoing) {
                        // Job is finished already so fast-forward to handleFinished.
                        handleFinishedH(false);
                        return;
                    }
                    if (mCancelled.get()) {
                        if (DEBUG) {
                            Slog.d(TAG, "Job cancelled while waiting for onStartJob to complete.");
                        }
                        // Cancelled *while* waiting for acknowledgeStartMessage from client.
                        handleCancelH();
                        return;
                    }
                    scheduleOpTimeOut();
                    break;
                default:
                    Slog.e(TAG, "Handling started job but job wasn't starting! Was "
                            + VERB_STRINGS[mVerb] + ".");
                    return;
            }
        }

        /**
         * VERB_EXECUTING  -> Client called jobFinished(), clean up and notify done.
         *     _STOPPING   -> Successful finish, clean up and notify done.
         *     _STARTING   -> Error
         *     _PENDING    -> Error
         */
        private void handleFinishedH(boolean reschedule) {
            switch (mVerb) {
                case VERB_EXECUTING:
                case VERB_STOPPING:
                    closeAndCleanupJobH(reschedule);
                    break;
                default:
                    Slog.e(TAG, "Got an execution complete message for a job that wasn't being" +
                            "executed. Was " + VERB_STRINGS[mVerb] + ".");
            }
        }

        /**
         * A job can be in various states when a cancel request comes in:
         * VERB_BINDING    -> Cancelled before bind completed. Mark as cancelled and wait for
         *                    {@link #onServiceConnected(android.content.ComponentName, android.os.IBinder)}
         *     _STARTING   -> Mark as cancelled and wait for
         *                    {@link JobServiceContext#acknowledgeStartMessage(int, boolean)}
         *     _EXECUTING  -> call {@link #sendStopMessageH}}, but only if there are no callbacks
         *                      in the message queue.
         *     _ENDING     -> No point in doing anything here, so we ignore.
         */
        private void handleCancelH() {
            if (JobSchedulerService.DEBUG) {
                Slog.d(TAG, "Handling cancel for: " + mRunningJob.getJobId() + " "
                        + VERB_STRINGS[mVerb]);
            }
            switch (mVerb) {
                case VERB_BINDING:
                case VERB_STARTING:
                    mCancelled.set(true);
                    break;
                case VERB_EXECUTING:
                    if (hasMessages(MSG_CALLBACK)) {
                        // If the client has called jobFinished, ignore this cancel.
                        return;
                    }
                    sendStopMessageH();
                    break;
                case VERB_STOPPING:
                    // Nada.
                    break;
                default:
                    Slog.e(TAG, "Cancelling a job without a valid verb: " + mVerb);
                    break;
            }
        }

        /** Process MSG_TIMEOUT here. */
        private void handleOpTimeoutH() {
            switch (mVerb) {
                case VERB_BINDING:
                    Slog.e(TAG, "Time-out while trying to bind " + mRunningJob.toShortString() +
                            ", dropping.");
                    closeAndCleanupJobH(false /* needsReschedule */);
                    break;
                case VERB_STARTING:
                    // Client unresponsive - wedged or failed to respond in time. We don't really
                    // know what happened so let's log it and notify the JobScheduler
                    // FINISHED/NO-RETRY.
                    Slog.e(TAG, "No response from client for onStartJob '" +
                            mRunningJob.toShortString());
                    closeAndCleanupJobH(false /* needsReschedule */);
                    break;
                case VERB_STOPPING:
                    // At least we got somewhere, so fail but ask the JobScheduler to reschedule.
                    Slog.e(TAG, "No response from client for onStopJob, '" +
                            mRunningJob.toShortString());
                    closeAndCleanupJobH(true /* needsReschedule */);
                    break;
                case VERB_EXECUTING:
                    // Not an error - client ran out of time.
                    Slog.i(TAG, "Client timed out while executing (no jobFinished received)." +
                            " sending onStop. "  + mRunningJob.toShortString());
                    mParams.setStopReason(JobParameters.REASON_TIMEOUT);
                    sendStopMessageH();
                    break;
                default:
                    Slog.e(TAG, "Handling timeout for an invalid job state: " +
                            mRunningJob.toShortString() + ", dropping.");
                    closeAndCleanupJobH(false /* needsReschedule */);
            }
        }

        /**
         * Already running, need to stop. Will switch {@link #mVerb} from VERB_EXECUTING ->
         * VERB_STOPPING.
         */
        private void sendStopMessageH() {
            removeOpTimeOut();
            if (mVerb != VERB_EXECUTING) {
                Slog.e(TAG, "Sending onStopJob for a job that isn't started. " + mRunningJob);
                closeAndCleanupJobH(false /* reschedule */);
                return;
            }
            try {
                mVerb = VERB_STOPPING;
                scheduleOpTimeOut();
                service.stopJob(mParams);
            } catch (RemoteException e) {
                Slog.e(TAG, "Error sending onStopJob to client.", e);
                closeAndCleanupJobH(false /* reschedule */);
            }
        }

        /**
         * The provided job has finished, either by calling
         * {@link android.app.job.JobService#jobFinished(android.app.job.JobParameters, boolean)}
         * or from acknowledging the stop message we sent. Either way, we're done tracking it and
         * we want to clean up internally.
         */
        private void closeAndCleanupJobH(boolean reschedule) {
            final JobStatus completedJob;
            synchronized (mLock) {
                if (mVerb == VERB_FINISHED) {
                    return;
                }
                completedJob = mRunningJob;
                try {
                    mBatteryStats.noteJobFinish(mRunningJob.getBatteryName(),
                            mRunningJob.getSourceUid());
                } catch (RemoteException e) {
                    // Whatever.
                }
                if (mWakeLock != null) {
                    mWakeLock.release();
                }
                mContext.unbindService(JobServiceContext.this);
                mWakeLock = null;
                mRunningJob = null;
                mParams = null;
                mVerb = VERB_FINISHED;
                mCancelled.set(false);
                service = null;
                mAvailable = true;
            }
            removeOpTimeOut();
            removeMessages(MSG_CALLBACK);
            removeMessages(MSG_SERVICE_BOUND);
            removeMessages(MSG_CANCEL);
            removeMessages(MSG_SHUTDOWN_EXECUTION);
            mCompletedListener.onJobCompleted(completedJob, reschedule);
        }
    }

    /**
     * Called when sending a message to the client, over whose execution we have no control. If
     * we haven't received a response in a certain amount of time, we want to give up and carry
     * on with life.
     */
    private void scheduleOpTimeOut() {
        removeOpTimeOut();

        final long timeoutMillis = (mVerb == VERB_EXECUTING) ?
                EXECUTING_TIMESLICE_MILLIS : OP_TIMEOUT_MILLIS;
        if (DEBUG) {
            Slog.d(TAG, "Scheduling time out for '" +
                    mRunningJob.getServiceComponent().getShortClassName() + "' jId: " +
                    mParams.getJobId() + ", in " + (timeoutMillis / 1000) + " s");
        }
        Message m = mCallbackHandler.obtainMessage(MSG_TIMEOUT);
        mCallbackHandler.sendMessageDelayed(m, timeoutMillis);
        mTimeoutElapsed = SystemClock.elapsedRealtime() + timeoutMillis;
    }


    private void removeOpTimeOut() {
        mCallbackHandler.removeMessages(MSG_TIMEOUT);
    }
}
