Merge "Implement VVM Task Scheduling" into nyc-mr1-dev
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9778d2c..39c5385 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -716,14 +716,15 @@
                     android:mimeType="vnd.android.cursor.dir/voicemails"/>
             </intent-filter>
         </receiver>
-        <service
-            android:name="com.android.phone.vvm.omtp.sync.OmtpVvmSyncService"
-            android:exported="false" />
 
         <service
             android:name="com.android.phone.vvm.omtp.sms.OmtpProvisioningService"
             android:exported="false" />
 
+        <service
+          android:name="com.android.phone.vvm.omtp.scheduling.TaskSchedulerService"
+          android:exported="false" />
+
         <receiver android:name="com.android.phone.vvm.omtp.VvmPackageInstallReceiver"
             androidprv:systemUserOnly="true">
             <intent-filter>
diff --git a/src/com/android/phone/Assert.java b/src/com/android/phone/Assert.java
index d4233b2..143e66f 100644
--- a/src/com/android/phone/Assert.java
+++ b/src/com/android/phone/Assert.java
@@ -24,17 +24,39 @@
  */
 public class Assert {
 
+    private static Boolean sIsMainThreadForTest;
+
     public static void isTrue(boolean condition) {
         if (!condition) {
             throw new AssertionError("Expected condition to be true");
         }
     }
 
+    public static void isMainThread() {
+        if (sIsMainThreadForTest != null) {
+            isTrue(sIsMainThreadForTest);
+            return;
+        }
+        isTrue(Looper.getMainLooper().equals(Looper.myLooper()));
+    }
+
     public static void isNotMainThread() {
+        if (sIsMainThreadForTest != null) {
+            isTrue(!sIsMainThreadForTest);
+            return;
+        }
         isTrue(!Looper.getMainLooper().equals(Looper.myLooper()));
     }
 
     public static void fail() {
         throw new AssertionError("Fail");
     }
+
+    /**
+     * Override the main thread status for tests. Set to null to revert to normal behavior
+     */
+    @NeededForTesting
+    public static void setIsMainThreadForTesting(Boolean isMainThread) {
+        sIsMainThreadForTest = isMainThread;
+    }
 }
diff --git a/src/com/android/phone/vvm/omtp/VvmPhoneStateListener.java b/src/com/android/phone/vvm/omtp/VvmPhoneStateListener.java
index 64b37c6..1cb23d4 100644
--- a/src/com/android/phone/vvm/omtp/VvmPhoneStateListener.java
+++ b/src/com/android/phone/vvm/omtp/VvmPhoneStateListener.java
@@ -16,7 +16,6 @@
 package com.android.phone.vvm.omtp;
 
 import android.content.Context;
-import android.content.Intent;
 import android.telecom.PhoneAccountHandle;
 import android.telephony.PhoneStateListener;
 import android.telephony.ServiceState;
@@ -25,6 +24,7 @@
 import com.android.phone.PhoneUtils;
 import com.android.phone.vvm.omtp.sync.OmtpVvmSourceManager;
 import com.android.phone.vvm.omtp.sync.OmtpVvmSyncService;
+import com.android.phone.vvm.omtp.sync.SyncTask;
 import com.android.phone.vvm.omtp.sync.VoicemailStatusQueryHelper;
 import com.android.phone.vvm.omtp.utils.PhoneAccountHandleConverter;
 
@@ -76,10 +76,7 @@
                         .v(TAG, "Signal returned: requesting resync for " + subId);
                 // If the source is already registered, run a full sync in case something was missed
                 // while signal was down.
-                Intent serviceIntent = OmtpVvmSyncService.getSyncIntent(
-                        mContext, OmtpVvmSyncService.SYNC_FULL_SYNC, mPhoneAccount,
-                        true /* firstAttempt */);
-                mContext.startService(serviceIntent);
+                SyncTask.start(mContext, mPhoneAccount, OmtpVvmSyncService.SYNC_FULL_SYNC);
             } else {
                 VvmLog.v(TAG,
                         "Signal returned: reattempting activation for " + subId);
@@ -90,9 +87,6 @@
             }
         } else {
             VvmLog.v(TAG, "Notifications channel is inactive for " + subId);
-            mContext.stopService(OmtpVvmSyncService.getSyncIntent(
-                    mContext, OmtpVvmSyncService.SYNC_FULL_SYNC, mPhoneAccount,
-                    true /* firstAttempt */));
 
             if (!OmtpVvmSourceManager.getInstance(mContext).isVvmSourceRegistered(mPhoneAccount)) {
                 return;
diff --git a/src/com/android/phone/vvm/omtp/imap/ImapHelper.java b/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
index 4db02d0..f700dda 100644
--- a/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
+++ b/src/com/android/phone/vvm/omtp/imap/ImapHelper.java
@@ -86,13 +86,19 @@
     private final OmtpVvmCarrierConfigHelper mConfig;
 
     public ImapHelper(Context context, PhoneAccountHandle phoneAccount, Network network) {
+        this(context, new OmtpVvmCarrierConfigHelper(context,
+                PhoneUtils.getSubIdForPhoneAccountHandle(phoneAccount)), phoneAccount, network);
+    }
+
+    public ImapHelper(Context context, OmtpVvmCarrierConfigHelper config,
+            PhoneAccountHandle phoneAccount, Network network) {
         mContext = context;
         mPhoneAccount = phoneAccount;
         mNetwork = network;
-        mConfig = new OmtpVvmCarrierConfigHelper(context,
-                PhoneUtils.getSubIdForPhoneAccountHandle(phoneAccount));
+        mConfig = config;
         mPrefs = new VisualVoicemailPreferences(context,
                 phoneAccount);
+
         try {
             TempDirectory.setTempDirectory(context);
 
diff --git a/src/com/android/phone/vvm/omtp/protocol/Vvm3EventHandler.java b/src/com/android/phone/vvm/omtp/protocol/Vvm3EventHandler.java
index 3645407..3b62424 100644
--- a/src/com/android/phone/vvm/omtp/protocol/Vvm3EventHandler.java
+++ b/src/com/android/phone/vvm/omtp/protocol/Vvm3EventHandler.java
@@ -116,7 +116,7 @@
             case CONFIG_REQUEST_STATUS_SUCCESS:
                 PhoneAccountHandle handle = PhoneAccountHandleConverter
                         .fromSubId(config.getSubId());
-                if (VoicemailChangePinActivity.isDefaultOldPinSet(context, handle)) {
+                if (!VoicemailChangePinActivity.isDefaultOldPinSet(context, handle)) {
                     return false;
                 } else {
                     postError(context, config, PIN_NOT_SET);
diff --git a/src/com/android/phone/vvm/omtp/scheduling/BaseTask.java b/src/com/android/phone/vvm/omtp/scheduling/BaseTask.java
new file mode 100644
index 0000000..92327b5
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/BaseTask.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.annotation.CallSuper;
+import android.annotation.MainThread;
+import android.annotation.WorkerThread;
+import android.content.Context;
+import android.content.Intent;
+import android.os.SystemClock;
+import android.support.annotation.NonNull;
+
+import com.android.phone.Assert;
+import com.android.phone.NeededForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides common utilities for task implementations, such as execution time and managing {@link
+ * Policy}
+ */
+public abstract class BaseTask implements Task {
+
+    private static final String EXTRA_SUB_ID = "extra_sub_id";
+
+    private Context mContext;
+
+    private int mId;
+    private int mSubId = TaskId.SUB_ID_ANY;
+
+    private boolean mHasStarted;
+    private volatile boolean mHasFailed;
+
+    @NonNull
+    private final List<Policy> mPolicies = new ArrayList<>();
+
+    private long mExecutionTime;
+
+    private static Clock sClock = new Clock();
+
+    protected BaseTask(int id) {
+        mId = id;
+        mExecutionTime = getTimeMillis();
+    }
+
+    /**
+     * Modify the task ID to prevent arbitrary task from executing. Can only be called before {@link
+     * #onCreate(Context, Intent, int, int)} returns.
+     */
+    @MainThread
+    public void setId(int id) {
+        Assert.isMainThread();
+        mId = id;
+    }
+
+    @MainThread
+    public boolean hasStarted() {
+        Assert.isMainThread();
+        return mHasStarted;
+    }
+
+    @MainThread
+    public boolean hasFailed() {
+        Assert.isMainThread();
+        return mHasFailed;
+    }
+
+    public Context getContext() {
+        return mContext;
+    }
+
+    /**
+     * Should be call in the constructor or {@link Policy#onCreate(BaseTask, Intent, int, int)} will
+     * be missed.
+     */
+    @MainThread
+    public BaseTask addPolicy(Policy policy) {
+        Assert.isMainThread();
+        mPolicies.add(policy);
+        return this;
+    }
+
+    /**
+     * Indicate the task has failed. {@link Policy#onFail()} will be triggered once the execution
+     * ends. This mechanism is used by policies for actions such as determining whether to schedule
+     * a retry. Must be call inside {@link #onExecuteInBackgroundThread()}
+     */
+    @WorkerThread
+    public void fail() {
+        Assert.isNotMainThread();
+        mHasFailed = true;
+    }
+
+    @MainThread
+    public void setExecutionTime(long timeMillis) {
+        Assert.isMainThread();
+        mExecutionTime = timeMillis;
+    }
+
+    public long getTimeMillis() {
+        return sClock.getTimeMillis();
+    }
+
+    /**
+     * Creates an intent that can be used to restart the current task. Derived class should build
+     * their intent upon this.
+     */
+    public Intent createRestartIntent() {
+        return createIntent(getContext(), this.getClass(), mSubId);
+    }
+
+    /**
+     * Creates an intent that can be used to start the {@link TaskSchedulerService}. Derived class
+     * should build their intent upon this.
+     */
+    public static Intent createIntent(Context context, Class<? extends BaseTask> task, int subId) {
+        Intent intent = TaskSchedulerService.createIntent(context, task);
+        intent.putExtra(EXTRA_SUB_ID, subId);
+        return intent;
+    }
+
+    @Override
+    public TaskId getId() {
+        return new TaskId(mId, mSubId);
+    }
+
+    @Override
+    @CallSuper
+    public void onCreate(Context context, Intent intent, int flags, int startId) {
+        mContext = context;
+        mSubId = intent.getIntExtra(EXTRA_SUB_ID, TaskId.SUB_ID_ANY);
+        for (Policy policy : mPolicies) {
+            policy.onCreate(this, intent, flags, startId);
+        }
+    }
+
+    @Override
+    public long getReadyInMilliSeconds() {
+        return mExecutionTime - getTimeMillis();
+    }
+
+    @Override
+    @CallSuper
+    public void onBeforeExecute() {
+        for (Policy policy : mPolicies) {
+            policy.onBeforeExecute();
+        }
+        mHasStarted = true;
+    }
+
+    @Override
+    public void onCompleted() {
+        if (mHasFailed) {
+            for (Policy policy : mPolicies) {
+                policy.onFail();
+            }
+        }
+
+        for (Policy policy : mPolicies) {
+            policy.onCompleted();
+        }
+    }
+
+    @Override
+    public void onDuplicatedTaskAdded(Task task) {
+        for (Policy policy : mPolicies) {
+            policy.onDuplicatedTaskAdded();
+        }
+    }
+
+    @NeededForTesting
+    static class Clock {
+
+        public long getTimeMillis() {
+            return SystemClock.elapsedRealtime();
+        }
+    }
+
+    /**
+     * Used to replace the clock with an deterministic clock
+     */
+    @NeededForTesting
+    static void setClockForTesting(Clock clock) {
+        sClock = clock;
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/BlockerTask.java b/src/com/android/phone/vvm/omtp/scheduling/BlockerTask.java
new file mode 100644
index 0000000..9d91828
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/BlockerTask.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.phone.vvm.omtp.VvmLog;
+
+/**
+ * Task to block another task of the same ID from being queued for a certain amount of time.
+ */
+public class BlockerTask extends BaseTask {
+
+    private static final String TAG = "BlockerTask";
+
+    public static final String EXTRA_TASK_ID = "extra_task_id";
+    public static final String EXTRA_BLOCK_FOR_MILLIS = "extra_block_for_millis";
+
+    public BlockerTask() {
+        super(TASK_INVALID);
+    }
+
+    @Override
+    public void onCreate(Context context, Intent intent, int flags, int startId) {
+        super.onCreate(context, intent, flags, startId);
+        setId(intent.getIntExtra(EXTRA_TASK_ID, TASK_INVALID));
+        setExecutionTime(getTimeMillis() + intent.getIntExtra(EXTRA_BLOCK_FOR_MILLIS, 0));
+    }
+
+    @Override
+    public void onExecuteInBackgroundThread() {
+        // Do nothing.
+    }
+
+    @Override
+    public void onDuplicatedTaskAdded(Task task) {
+        VvmLog
+            .v(TAG, task.toString() + "blocked, " + getReadyInMilliSeconds() + "millis remaining");
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/MinimalIntervalPolicy.java b/src/com/android/phone/vvm/omtp/scheduling/MinimalIntervalPolicy.java
new file mode 100644
index 0000000..8bb22ca
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/MinimalIntervalPolicy.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.content.Intent;
+
+import com.android.phone.vvm.omtp.scheduling.Task.TaskId;
+
+/**
+ * If a task with this policy succeeds, a {@link BlockerTask} with the same {@link TaskId} of the
+ * task will be queued immediately, preventing the same task from running for a certain amount of
+ * time.
+ */
+public class MinimalIntervalPolicy implements Policy {
+
+    BaseTask mTask;
+    TaskId mId;
+    int mBlockForMillis;
+
+    public MinimalIntervalPolicy(int blockForMillis) {
+        mBlockForMillis = blockForMillis;
+    }
+
+    @Override
+    public void onCreate(BaseTask task, Intent intent, int flags, int startId) {
+        mTask = task;
+        mId = mTask.getId();
+    }
+
+    @Override
+    public void onBeforeExecute() {
+
+    }
+
+    @Override
+    public void onCompleted() {
+        if (!mTask.hasFailed()) {
+            Intent intent = mTask
+                    .createIntent(mTask.getContext(), BlockerTask.class, mId.subId);
+            intent.putExtra(BlockerTask.EXTRA_TASK_ID, mId.id);
+            intent.putExtra(BlockerTask.EXTRA_BLOCK_FOR_MILLIS, mBlockForMillis);
+            mTask.getContext().startService(intent);
+        }
+    }
+
+    @Override
+    public void onFail() {
+
+    }
+
+    @Override
+    public void onDuplicatedTaskAdded() {
+
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/Policy.java b/src/com/android/phone/vvm/omtp/scheduling/Policy.java
new file mode 100644
index 0000000..fcb01b8
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/Policy.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.content.Intent;
+
+/**
+ * A set of listeners managed by {@link BaseTask} for common behaviors such as retrying. Call {@link
+ * BaseTask#addPolicy(Policy)} to add a policy.
+ */
+public interface Policy {
+
+    void onCreate(BaseTask task, Intent intent, int flags, int startId);
+
+    void onBeforeExecute();
+
+    void onCompleted();
+
+    void onFail();
+
+    void onDuplicatedTaskAdded();
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/PostponePolicy.java b/src/com/android/phone/vvm/omtp/scheduling/PostponePolicy.java
new file mode 100644
index 0000000..f23d7f7
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/PostponePolicy.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.content.Intent;
+
+import com.android.phone.vvm.omtp.VvmLog;
+
+/**
+ * A task with Postpone policy will not be executed immediately. It will wait for a while and if a
+ * duplicated task is queued during the duration, the task will be postponed further. The task will
+ * only be executed if no new task was added in postponeMillis. Useful to batch small tasks in quick
+ * succession together.
+ */
+public class PostponePolicy implements Policy {
+
+    private static final String TAG = "PostponePolicy";
+
+    private final int mPostponeMillis;
+    private BaseTask mTask;
+
+    public PostponePolicy(int postponeMillis) {
+        mPostponeMillis = postponeMillis;
+    }
+
+    @Override
+    public void onCreate(BaseTask task, Intent intent, int flags, int startId) {
+        mTask = task;
+        mTask.setExecutionTime(mTask.getTimeMillis() + mPostponeMillis);
+    }
+
+    @Override
+    public void onBeforeExecute() {
+        // Do nothing
+    }
+
+    @Override
+    public void onCompleted() {
+        // Do nothing
+    }
+
+    @Override
+    public void onFail() {
+        // Do nothing
+    }
+
+    @Override
+    public void onDuplicatedTaskAdded() {
+        if (mTask.hasStarted()) {
+            return;
+        }
+        VvmLog.d(TAG, "postponing " + mTask);
+        mTask.setExecutionTime(mTask.getTimeMillis() + mPostponeMillis);
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/RetryPolicy.java b/src/com/android/phone/vvm/omtp/scheduling/RetryPolicy.java
new file mode 100644
index 0000000..3c2274f
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/RetryPolicy.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.content.Intent;
+
+import com.android.phone.vvm.omtp.VvmLog;
+
+/**
+ * A task with this policy will automatically re-queue itself if {@link BaseTask#fail()} has been
+ * called during {@link BaseTask#onExecuteInBackgroundThread()}. A task will be retried at most
+ * <code>retryLimit</code> times and with a <code>retryDelayMillis</code> interval in between.
+ */
+public class RetryPolicy implements Policy {
+
+    private static final String TAG = "RetryPolicy";
+    private static final String EXTRA_RETRY_COUNT = "extra_retry_count";
+
+    private final int mRetryLimit;
+    private final int mRetryDelayMillis;
+
+    private BaseTask mTask;
+
+    private int mRetryCount;
+    private boolean mFailed;
+
+    public RetryPolicy(int retryLimit, int retryDelayMillis) {
+        mRetryLimit = retryLimit;
+        mRetryDelayMillis = retryDelayMillis;
+    }
+
+    @Override
+    public void onCreate(BaseTask task, Intent intent, int flags, int startId) {
+        mTask = task;
+        mRetryCount = intent.getIntExtra(EXTRA_RETRY_COUNT, 0);
+        if (mRetryCount > 0) {
+            VvmLog.d(TAG, "retry #" + mRetryCount + " for " + mTask + " queued, executing in "
+                    + mRetryDelayMillis);
+            mTask.setExecutionTime(mTask.getTimeMillis() + mRetryDelayMillis);
+        }
+    }
+
+    @Override
+    public void onBeforeExecute() {
+
+    }
+
+    @Override
+    public void onCompleted() {
+        if (!mFailed) {
+            return;
+        }
+        if (mRetryCount >= mRetryLimit) {
+            VvmLog.d(TAG, "Retry limit for " + mTask + " reached");
+            return;
+        }
+
+        Intent intent = mTask.createRestartIntent();
+        intent.putExtra(EXTRA_RETRY_COUNT, mRetryCount + 1);
+
+        mTask.getContext().startService(intent);
+    }
+
+    @Override
+    public void onFail() {
+        mFailed = true;
+    }
+
+    @Override
+    public void onDuplicatedTaskAdded() {
+
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/Task.java b/src/com/android/phone/vvm/omtp/scheduling/Task.java
new file mode 100644
index 0000000..62762e7
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/Task.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.annotation.MainThread;
+import android.annotation.WorkerThread;
+import android.content.Context;
+import android.content.Intent;
+
+import java.util.Objects;
+
+/**
+ * A task for {@link TaskSchedulerService} to execute. Since the task is sent through a intent to
+ * the scheduler, The task must be constructable with the intent. Specifically, It must have a
+ * constructor with zero arguments, and have all relevant data packed inside the intent. Use {@link
+ * TaskSchedulerService#createIntent(Context, Class)} to create a intent that will construct the
+ * Task.
+ *
+ * <p>Only {@link #onExecuteInBackgroundThread()} is run on the worker thread.
+ */
+public interface Task {
+
+    /**
+     * TaskId to indicate it has not be set. If a task does not provide a default TaskId it should
+     * be set before {@link Task#onCreate(Context, Intent, int, int) returns}
+     */
+    int TASK_INVALID = -1;
+
+    /**
+     * TaskId to indicate it should always be queued regardless of duplicates. {@link
+     * Task#onDuplicatedTaskAdded(Task)} will never be called on tasks with this TaskId.
+     */
+    int TASK_ALLOW_DUPLICATES = -2;
+
+    int TASK_UPLOAD = 1;
+    int TASK_SYNC = 2;
+
+    /**
+     * Used to differentiate between types of tasks. If a task with the same TaskId is already in
+     * the queue the new task will be rejected.
+     */
+    class TaskId {
+
+        /**
+         * Special subId value to indicate unspecified subId. Having SUB_ID_ANY does NOT prevent
+         * task on other subId from executing.
+         */
+        public static final int SUB_ID_ANY = -1;
+
+        /**
+         * Indicates the operation type of the task.
+         */
+        public final int id;
+        /**
+         * Same operation for a different subId is allowed. subId is used to differentiate phone
+         * accounts in multi-SIM scenario. For example, each SIM can queue a sync task for their
+         * own.
+         */
+        public final int subId;
+
+        public TaskId(int id, int subId) {
+            this.id = id;
+            this.subId = subId;
+        }
+
+        @Override
+        public boolean equals(Object object) {
+            if (!(object instanceof TaskId)) {
+                return false;
+            }
+            TaskId other = (TaskId) object;
+            return id == other.id && subId == other.subId;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(id, subId);
+        }
+    }
+
+    TaskId getId();
+
+    @MainThread
+    void onCreate(Context context, Intent intent, int flags, int startId);
+
+    /**
+     * @return number of milliSeconds the scheduler should wait before running this task. A value
+     * less than {@link TaskSchedulerService#READY_TOLERANCE_MILLISECONDS} will be considered ready.
+     * If no tasks are ready, the scheduler will sleep for this amount of time before doing another
+     * check (it will still wake if a new task is added). The first task in the queue that is ready
+     * will be executed.
+     */
+    @MainThread
+    long getReadyInMilliSeconds();
+
+    /**
+     * Called on the main thread when the scheduler is about to send the task into the worker
+     * thread, calling {@link #onExecuteInBackgroundThread()}
+     */
+    @MainThread
+    void onBeforeExecute();
+
+    /**
+     * The actual payload of the task, executed on the worker thread.
+     */
+    @WorkerThread
+    void onExecuteInBackgroundThread();
+
+    /**
+     * Called on the main thread when {@link #onExecuteInBackgroundThread()} has finished or thrown
+     * an uncaught exception. The task is already removed from the queue at this point, and a same
+     * task can be queued again.
+     */
+    @MainThread
+    void onCompleted();
+
+    /**
+     * Another task with the same TaskId has been added. Necessary data can be retrieved from the
+     * other task, and after this returns the task will be discarded.
+     */
+    @MainThread
+    void onDuplicatedTaskAdded(Task task);
+}
diff --git a/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerService.java b/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerService.java
new file mode 100644
index 0000000..7bc3262
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerService.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.annotation.MainThread;
+import android.annotation.Nullable;
+import android.annotation.WorkerThread;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.PowerManager.WakeLock;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.phone.Assert;
+import com.android.phone.NeededForTesting;
+import com.android.phone.vvm.omtp.VvmLog;
+import com.android.phone.vvm.omtp.scheduling.Task.TaskId;
+
+import java.util.ArrayDeque;
+import java.util.Queue;
+
+/**
+ * A service to queue and run {@link Task} on a worker thread. Only one task will be ran at a time,
+ * and same task cannot exist in the queue at the same time. The service will be started when a
+ * intent is received, and stopped when there are no more tasks in the queue.
+ */
+public class TaskSchedulerService extends Service {
+
+    private static final String TAG = "TaskSchedulerService";
+
+    private static final int READY_TOLERANCE_MILLISECONDS = 100;
+    /**
+     * When there are no more tasks to be run the service should be stopped. But when all tasks has
+     * finished there might still be more tasks in the message queue waiting to be processed,
+     * especially the ones submitted in {@link Task#onCompleted()}. Wait for a while before stopping
+     * the service to make sure there are no pending messages.
+     */
+    private static final int STOP_DELAY_MILLISECONDS = 5_000;
+    private static final String EXTRA_CLASS_NAME = "extra_class_name";
+
+    private static final String WAKE_LOCK_TAG = "TaskSchedulerService_wakelock";
+
+    // The thread to run tasks on
+    private volatile WorkerThreadHandler mWorkerThreadHandler;
+
+    private Context mContext = this;
+    /**
+     * Used by tests to turn task handling into a single threaded process by calling {@link
+     * Handler#handleMessage(Message)} directly
+     */
+    private MessageSender mMessageSender = new MessageSender();
+
+    private MainThreadHandler mMainThreadHandler;
+
+    private WakeLock mWakeLock;
+
+    /**
+     * Main thread only, access through {@link #getTasks()}
+     */
+    private final Queue<Task> mTasks = new ArrayDeque<>();
+    private boolean mWorkerThreadIsBusy = false;
+
+    private final Runnable mStopServiceWithDelay = new Runnable() {
+        @Override
+        public void run() {
+            VvmLog.d(TAG, "Stopping service");
+            stopSelf();
+
+        }
+    };
+    /**
+     * Should attempt to run the next task when a task has finished or been added.
+     */
+    private boolean mTaskAutoRunDisabledForTesting = false;
+
+    @VisibleForTesting
+    final class WorkerThreadHandler extends Handler {
+
+        public WorkerThreadHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        @WorkerThread
+        public void handleMessage(Message msg) {
+            Assert.isNotMainThread();
+            Task task = (Task) msg.obj;
+            try {
+                VvmLog.v(TAG, "executing task " + task);
+                task.onExecuteInBackgroundThread();
+            } catch (Throwable throwable) {
+                VvmLog.e(TAG, "Exception while executing task " + task + ":" + throwable);
+            }
+
+            Message schedulerMessage = mMainThreadHandler.obtainMessage();
+            schedulerMessage.obj = task;
+            mMessageSender.send(schedulerMessage);
+        }
+    }
+
+    @VisibleForTesting
+    final class MainThreadHandler extends Handler {
+
+        public MainThreadHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        @MainThread
+        public void handleMessage(Message msg) {
+            Assert.isMainThread();
+            Task task = (Task) msg.obj;
+            getTasks().remove(task);
+            task.onCompleted();
+            mWorkerThreadIsBusy = false;
+            maybeRunNextTask();
+        }
+    }
+
+    @Override
+    @MainThread
+    public void onCreate() {
+        super.onCreate();
+        mWakeLock = getSystemService(PowerManager.class)
+                .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
+        mWakeLock.acquire();
+        HandlerThread thread = new HandlerThread("VvmTaskSchedulerService");
+        thread.start();
+
+        mWorkerThreadHandler = new WorkerThreadHandler(thread.getLooper());
+        mMainThreadHandler = new MainThreadHandler(Looper.getMainLooper());
+    }
+
+    @Override
+    public void onDestroy() {
+        mWorkerThreadHandler.getLooper().quit();
+        mWakeLock.release();
+    }
+
+    @Override
+    @MainThread
+    public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
+        Assert.isMainThread();
+        Task task = createTask(intent, flags, startId);
+        if (task == null) {
+            VvmLog.e(TAG, "cannot create task form intent");
+        } else {
+            addTask(task);
+        }
+        // STICKY means the service will be automatically restarted will the last intent if it is
+        // killed.
+        return START_NOT_STICKY;
+    }
+
+    @MainThread
+    @VisibleForTesting
+    void addTask(Task task) {
+        Assert.isMainThread();
+        if (task.getId().id == Task.TASK_INVALID) {
+            throw new AssertionError("Task id was not set to a valid value before adding.");
+        }
+        if (task.getId().id != Task.TASK_ALLOW_DUPLICATES) {
+            Task oldTask = getTask(task.getId());
+            if (oldTask != null) {
+                oldTask.onDuplicatedTaskAdded(task);
+                return;
+            }
+        }
+        mMainThreadHandler.removeCallbacks(mStopServiceWithDelay);
+        getTasks().add(task);
+        maybeRunNextTask();
+
+    }
+
+    @MainThread
+    @Nullable
+    private Task getTask(TaskId taskId) {
+        Assert.isMainThread();
+        for (Task task : getTasks()) {
+            if (task.getId().equals(taskId)) {
+                return task;
+            }
+        }
+        return null;
+    }
+
+    @MainThread
+    private Queue<Task> getTasks() {
+        Assert.isMainThread();
+        return mTasks;
+    }
+
+    /**
+     * Create an intent that will queue the <code>task</code>
+     */
+    public static Intent createIntent(Context context, Class<? extends Task> task) {
+        Intent intent = new Intent(context, TaskSchedulerService.class);
+        intent.putExtra(EXTRA_CLASS_NAME, task.getName());
+        return intent;
+    }
+
+    @VisibleForTesting
+    @MainThread
+    @Nullable
+    Task createTask(@Nullable Intent intent, int flags, int startId) {
+        Assert.isMainThread();
+        if (intent == null) {
+            return null;
+        }
+        String className = intent.getStringExtra(EXTRA_CLASS_NAME);
+        VvmLog.d(TAG, "create task:" + className);
+        if (className == null) {
+            throw new IllegalArgumentException("EXTRA_CLASS_NAME expected");
+        }
+        try {
+            Task task = (Task) Class.forName(className).newInstance();
+            task.onCreate(mContext, intent, flags, startId);
+            return task;
+        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    @MainThread
+    private void maybeRunNextTask() {
+        Assert.isMainThread();
+        if (mWorkerThreadIsBusy) {
+            return;
+        }
+        if (mTaskAutoRunDisabledForTesting) {
+            // If mTaskAutoRunDisabledForTesting is true, runNextTask() must be explicitly called
+            // to run the next task.
+            return;
+        }
+
+        runNextTask();
+    }
+
+    @VisibleForTesting
+    @MainThread
+    void runNextTask() {
+        Assert.isMainThread();
+        if (getTasks().isEmpty()) {
+            prepareStop();
+            return;
+        }
+        Long minimalWaitTime = null;
+        for (Task task : getTasks()) {
+            long waitTime = task.getReadyInMilliSeconds();
+            if (waitTime < READY_TOLERANCE_MILLISECONDS) {
+                task.onBeforeExecute();
+                Message message = mWorkerThreadHandler.obtainMessage();
+                message.obj = task;
+                mWorkerThreadIsBusy = true;
+                mMessageSender.send(message);
+                return;
+            } else {
+                if (minimalWaitTime == null || waitTime < minimalWaitTime) {
+                    minimalWaitTime = waitTime;
+                }
+            }
+        }
+        VvmLog.d(TAG, "minimal wait time:" + minimalWaitTime);
+        if (!mTaskAutoRunDisabledForTesting && minimalWaitTime != null) {
+            // No tests are currently ready. Sleep until the next one should be.
+            // If a new task is added during the sleep the service will wake immediately.
+            mMainThreadHandler.postDelayed(new Runnable() {
+                @Override
+                public void run() {
+                    maybeRunNextTask();
+                }
+            }, minimalWaitTime);
+        }
+    }
+
+    private void prepareStop() {
+        VvmLog.d(TAG,
+                "No more tasks, stopping service if no task are added in "
+                        + STOP_DELAY_MILLISECONDS + " millis");
+        mMainThreadHandler.postDelayed(mStopServiceWithDelay, STOP_DELAY_MILLISECONDS);
+    }
+
+    static class MessageSender {
+
+        public void send(Message message) {
+            message.sendToTarget();
+        }
+    }
+
+    @NeededForTesting
+    void setContextForTest(Context context) {
+        mContext = context;
+    }
+
+    @NeededForTesting
+    void setTaskAutoRunDisabledForTest(boolean value) {
+        mTaskAutoRunDisabledForTesting = value;
+    }
+
+    @NeededForTesting
+    void setMessageSenderForTest(MessageSender sender) {
+        mMessageSender = sender;
+    }
+
+    @NeededForTesting
+    void clearTasksForTest() {
+        mTasks.clear();
+    }
+
+    @Override
+    @Nullable
+    public IBinder onBind(Intent intent) {
+        return new LocalBinder();
+    }
+
+    @NeededForTesting
+    class LocalBinder extends Binder {
+
+        @NeededForTesting
+        public TaskSchedulerService getService() {
+            return TaskSchedulerService.this;
+        }
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java b/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
index 30ea43d..fd7b7a6 100644
--- a/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
+++ b/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
@@ -35,6 +35,8 @@
 import com.android.phone.vvm.omtp.VvmLog;
 import com.android.phone.vvm.omtp.sync.OmtpVvmSourceManager;
 import com.android.phone.vvm.omtp.sync.OmtpVvmSyncService;
+import com.android.phone.vvm.omtp.sync.SyncOneTask;
+import com.android.phone.vvm.omtp.sync.SyncTask;
 import com.android.phone.vvm.omtp.sync.VoicemailsQueryHelper;
 import com.android.phone.vvm.omtp.utils.PhoneAccountHandleConverter;
 
@@ -132,15 +134,11 @@
                 if (queryHelper.isVoicemailUnique(voicemail)) {
                     Uri uri = VoicemailContract.Voicemails.insert(mContext, voicemail);
                     voicemail = builder.setId(ContentUris.parseId(uri)).setUri(uri).build();
-                    serviceIntent = OmtpVvmSyncService.getSyncIntent(mContext,
-                            OmtpVvmSyncService.SYNC_DOWNLOAD_ONE_TRANSCRIPTION, phone,
-                            voicemail, true /* firstAttempt */);
+                    SyncOneTask.start(mContext, phone, voicemail);
                 }
                 break;
             case OmtpConstants.MAILBOX_UPDATE:
-                serviceIntent = OmtpVvmSyncService.getSyncIntent(
-                        mContext, OmtpVvmSyncService.SYNC_DOWNLOAD_ONLY, phone,
-                        true /* firstAttempt */);
+                SyncTask.start(mContext, phone, OmtpVvmSyncService.SYNC_DOWNLOAD_ONLY);
                 break;
             case OmtpConstants.GREETINGS_UPDATE:
                 // Not implemented in V1
@@ -150,10 +148,6 @@
                         "Unrecognized sync trigger event: " + message.getSyncTriggerEvent());
                 break;
         }
-
-        if (serviceIntent != null) {
-            mContext.startService(serviceIntent);
-        }
     }
 
     private void updateSource(PhoneAccountHandle phone, int subId, StatusMessage message) {
@@ -171,10 +165,7 @@
             // Add the source to indicate that it is active.
             vvmSourceManager.addSource(phone);
 
-            Intent serviceIntent = OmtpVvmSyncService.getSyncIntent(
-                    mContext, OmtpVvmSyncService.SYNC_FULL_SYNC, phone,
-                    true /* firstAttempt */);
-            mContext.startService(serviceIntent);
+            SyncTask.start(mContext, phone, OmtpVvmSyncService.SYNC_FULL_SYNC);
 
             PhoneGlobals.getInstance().clearMwiIndicator(subId);
         } else {
diff --git a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSourceManager.java b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSourceManager.java
index 1b9e53f..1972ca6 100644
--- a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSourceManager.java
+++ b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSourceManager.java
@@ -111,7 +111,6 @@
                 .handleEvent(OmtpEvents.OTHER_SOURCE_REMOVED);
         removePhoneStateListener(phoneAccount);
         mActiveVvmSources.remove(phoneAccount);
-        OmtpVvmSyncService.cancelAllRetries(mContext, phoneAccount);
     }
 
     public void addPhoneStateListener(Phone phone) {
diff --git a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncReceiver.java b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncReceiver.java
index 415fc91..b424281 100644
--- a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncReceiver.java
+++ b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncReceiver.java
@@ -31,10 +31,7 @@
     public void onReceive(final Context context, Intent intent) {
         if (VoicemailContract.ACTION_SYNC_VOICEMAIL.equals(intent.getAction())) {
             VvmLog.v(TAG, "Sync intent received");
-            Intent syncIntent = OmtpVvmSyncService
-                    .getSyncIntent(context, OmtpVvmSyncService.SYNC_FULL_SYNC, null, true);
-            intent.putExtra(OmtpVvmSyncService.EXTRA_IS_MANUAL_SYNC, true);
-            context.startService(syncIntent);
+            SyncTask.start(context, null, OmtpVvmSyncService.SYNC_FULL_SYNC);
         }
     }
 }
diff --git a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncService.java b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncService.java
index 7e62829..163b204 100644
--- a/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncService.java
+++ b/src/com/android/phone/vvm/omtp/sync/OmtpVvmSyncService.java
@@ -15,13 +15,8 @@
  */
 package com.android.phone.vvm.omtp.sync;
 
-import android.app.AlarmManager;
-import android.app.IntentService;
-import android.app.PendingIntent;
 import android.content.Context;
-import android.content.Intent;
 import android.net.Network;
-import android.net.NetworkInfo;
 import android.net.Uri;
 import android.provider.VoicemailContract;
 import android.telecom.PhoneAccountHandle;
@@ -29,7 +24,6 @@
 import android.text.TextUtils;
 
 import com.android.phone.PhoneUtils;
-import com.android.phone.VoicemailStatus;
 import com.android.phone.settings.VisualVoicemailSettingsUtil;
 import com.android.phone.vvm.omtp.OmtpEvents;
 import com.android.phone.vvm.omtp.OmtpVvmCarrierConfigHelper;
@@ -37,6 +31,9 @@
 import com.android.phone.vvm.omtp.VvmLog;
 import com.android.phone.vvm.omtp.fetch.VoicemailFetchedCallback;
 import com.android.phone.vvm.omtp.imap.ImapHelper;
+import com.android.phone.vvm.omtp.scheduling.BaseTask;
+import com.android.phone.vvm.omtp.sync.VvmNetworkRequest.NetworkWrapper;
+import com.android.phone.vvm.omtp.utils.PhoneAccountHandleConverter;
 
 import java.util.HashMap;
 import java.util.List;
@@ -46,13 +43,10 @@
 /**
  * Sync OMTP visual voicemail.
  */
-public class OmtpVvmSyncService extends IntentService {
+public class OmtpVvmSyncService {
 
     private static final String TAG = OmtpVvmSyncService.class.getSimpleName();
 
-    // Number of retries
-    private static final int NETWORK_RETRY_COUNT = 3;
-
     /**
      * Signifies a sync with both uploading to the server and downloading from the server.
      */
@@ -70,23 +64,8 @@
      */
     public static final String SYNC_DOWNLOAD_ONE_TRANSCRIPTION =
             "download_one_transcription";
-    /**
-     * The account to sync.
-     */
-    public static final String EXTRA_PHONE_ACCOUNT = "phone_account";
-    /**
-     * The voicemail to fetch.
-     */
-    public static final String EXTRA_VOICEMAIL = "voicemail";
-    /**
-     * The sync request is initiated by the user, should allow shorter sync interval.
-     */
-    public static final String EXTRA_IS_MANUAL_SYNC = "is_manual_sync";
-    // Minimum time allowed between full syncs
-    private static final int MINIMUM_FULL_SYNC_INTERVAL_MILLIS = 60 * 1000;
 
-    // Minimum time allowed between manual syncs
-    private static final int MINIMUM_MANUAL_SYNC_INTERVAL_MILLIS = 3 * 1000;
+    private final Context mContext;
 
     // Record the timestamp of the last full sync so that duplicate syncs can be reduced.
     private static final String LAST_FULL_SYNC_TIMESTAMP = "last_full_sync_timestamp";
@@ -95,170 +74,68 @@
 
     private VoicemailsQueryHelper mQueryHelper;
 
-    public OmtpVvmSyncService() {
-        super("OmtpVvmSyncService");
+    public OmtpVvmSyncService(Context context) {
+        mContext = context;
+        mQueryHelper = new VoicemailsQueryHelper(mContext);
     }
 
-    public static Intent getSyncIntent(Context context, String action,
-            PhoneAccountHandle phoneAccount, boolean firstAttempt) {
-        return getSyncIntent(context, action, phoneAccount, null, firstAttempt);
-    }
-
-    public static Intent getSyncIntent(Context context, String action,
-            PhoneAccountHandle phoneAccount, Voicemail voicemail, boolean firstAttempt) {
-
-        Intent serviceIntent = new Intent(context, OmtpVvmSyncService.class);
-        serviceIntent.setAction(action);
-        if (phoneAccount != null) {
-            serviceIntent.putExtra(EXTRA_PHONE_ACCOUNT, phoneAccount);
-        }
-        if (voicemail != null) {
-            serviceIntent.putExtra(EXTRA_VOICEMAIL, voicemail);
-        }
-
-        cancelRetriesForIntent(context, serviceIntent);
-        return serviceIntent;
-    }
-
-    /**
-     * Cancel all retry syncs for an account.
-     *
-     * @param context The context the service runs in.
-     * @param phoneAccount The phone account for which to cancel syncs.
-     */
-    public static void cancelAllRetries(Context context, PhoneAccountHandle phoneAccount) {
-        cancelRetriesForIntent(context, getSyncIntent(context, SYNC_FULL_SYNC, phoneAccount,
-                false));
-    }
-
-    /**
-     * A helper method to cancel all pending alarms for intents that would be identical to the given
-     * intent.
-     *
-     * @param context The context the service runs in.
-     * @param intent The intent to search and cancel.
-     */
-    private static void cancelRetriesForIntent(Context context, Intent intent) {
-        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
-        alarmManager.cancel(PendingIntent.getService(context, 0, intent, 0));
-
-        Intent copyIntent = new Intent(intent);
-        if (SYNC_FULL_SYNC.equals(copyIntent.getAction())) {
-            // A full sync action should also cancel both of the other types of syncs
-            copyIntent.setAction(SYNC_DOWNLOAD_ONLY);
-            alarmManager.cancel(PendingIntent.getService(context, 0, copyIntent, 0));
-            copyIntent.setAction(SYNC_UPLOAD_ONLY);
-            alarmManager.cancel(PendingIntent.getService(context, 0, copyIntent, 0));
-        }
-    }
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        mQueryHelper = new VoicemailsQueryHelper(this);
-    }
-
-    @Override
-    protected void onHandleIntent(Intent intent) {
-        if (intent == null) {
-            VvmLog.d(TAG, "onHandleIntent: could not handle null intent");
-            return;
-        }
-        String action = intent.getAction();
-        PhoneAccountHandle phoneAccount = intent.getParcelableExtra(EXTRA_PHONE_ACCOUNT);
+    public void sync(BaseTask task, String action, PhoneAccountHandle phoneAccount,
+            Voicemail voicemail) {
         VvmLog.log(TAG, "Sync requested: " + action +
                 " for all accounts: " + String.valueOf(phoneAccount == null));
-
-        boolean isManualSync = intent.getBooleanExtra(EXTRA_IS_MANUAL_SYNC, false);
-        Voicemail voicemail = intent.getParcelableExtra(EXTRA_VOICEMAIL);
         if (phoneAccount != null) {
             VvmLog.v(TAG, "Sync requested: " + action + " - for account: " + phoneAccount);
-            setupAndSendRequest(phoneAccount, voicemail, action, isManualSync);
+            setupAndSendRequest(task, phoneAccount, voicemail, action);
         } else {
             VvmLog.v(TAG, "Sync requested: " + action + " - for all accounts");
             OmtpVvmSourceManager vvmSourceManager =
-                    OmtpVvmSourceManager.getInstance(this);
+                    OmtpVvmSourceManager.getInstance(mContext);
             Set<PhoneAccountHandle> sources = vvmSourceManager.getOmtpVvmSources();
             for (PhoneAccountHandle source : sources) {
-                setupAndSendRequest(source, null, action, isManualSync);
+                setupAndSendRequest(task, source, null, action);
             }
         }
     }
 
-    private void setupAndSendRequest(PhoneAccountHandle phoneAccount, Voicemail voicemail,
-            String action, boolean isManualSync) {
-        if (!VisualVoicemailSettingsUtil.isEnabled(this, phoneAccount)) {
+    private void setupAndSendRequest(BaseTask task, PhoneAccountHandle phoneAccount,
+            Voicemail voicemail, String action) {
+        if (!VisualVoicemailSettingsUtil.isEnabled(mContext, phoneAccount)) {
             VvmLog.v(TAG, "Sync requested for disabled account");
             return;
         }
 
-        if (SYNC_FULL_SYNC.equals(action)) {
-            long lastSyncTime = new VisualVoicemailPreferences(this, phoneAccount)
-                    .getLong(LAST_FULL_SYNC_TIMESTAMP, NO_PRIOR_FULL_SYNC);
-            long currentTime = System.currentTimeMillis();
-            int minimumInterval = isManualSync ? MINIMUM_MANUAL_SYNC_INTERVAL_MILLIS
-                    : MINIMUM_MANUAL_SYNC_INTERVAL_MILLIS;
-            if (currentTime - lastSyncTime < minimumInterval) {
-                // If it's been less than a minute since the last sync, bail.
-                VvmLog.v(TAG, "Avoiding duplicate full sync: synced recently for "
-                        + phoneAccount.getId());
-
-                /**
-                 *  Perform a NOOP change to the database so the sender can observe the sync is
-                 *  completed.
-                 *  TODO: Instead of this hack, refactor the sync to be synchronous so the sender
-                 *  can use sendOrderedBroadcast() to register a callback once all syncs are
-                 *  finished
-                 *  b/26937720
-                 */
-                VoicemailStatus.edit(this, phoneAccount).apply();
+        OmtpVvmCarrierConfigHelper config = new OmtpVvmCarrierConfigHelper(mContext,
+                PhoneAccountHandleConverter.toSubId(phoneAccount));
+        try (NetworkWrapper network = VvmNetworkRequest.getNetwork(config, phoneAccount)) {
+            if (network == null) {
+                VvmLog.e(TAG, "unable to acquire network");
+                task.fail();
                 return;
             }
-            new VisualVoicemailPreferences(this, phoneAccount).edit()
-                    .putLong(LAST_FULL_SYNC_TIMESTAMP, currentTime)
-                    .apply();
+            doSync(task, network.get(), phoneAccount, voicemail, action);
         }
-
-        VvmNetworkRequestCallback networkCallback = new SyncNetworkRequestCallback(this,
-                phoneAccount, voicemail, action);
-        networkCallback.requestNetwork();
     }
 
-    private void doSync(Network network, VvmNetworkRequestCallback callback,
-            PhoneAccountHandle phoneAccount, Voicemail voicemail, String action) {
-        int retryCount = NETWORK_RETRY_COUNT;
-        try {
-            while (retryCount > 0) {
-                try (ImapHelper imapHelper = new ImapHelper(this, phoneAccount, network)) {
-                    if (!imapHelper.isSuccessfullyInitialized()) {
-                        VvmLog.w(TAG, "Can't retrieve Imap credentials.");
-                        return;
-                    }
-
-                    boolean success = true;
-                    if (voicemail == null) {
-                        success = syncAll(action, imapHelper, phoneAccount);
-                    } else {
-                        success = syncOne(imapHelper, voicemail, phoneAccount);
-                    }
-                    imapHelper.updateQuota();
-
-                    // Need to check again for whether visual voicemail is enabled because it could have
-                    // been disabled while waiting for the response from the network.
-                    if (VisualVoicemailSettingsUtil.isEnabled(this, phoneAccount) &&
-                        !success) {
-                        retryCount--;
-                        VvmLog.v(TAG, "Retrying " + action);
-                    } else {
-                        // Nothing more to do here, just exit.
-                        imapHelper.handleEvent(OmtpEvents.DATA_IMAP_OPERATION_COMPLETED);
-                        return;
-                    }
-                }
+    private void doSync(BaseTask task, Network network, PhoneAccountHandle phoneAccount,
+            Voicemail voicemail, String action) {
+        try(ImapHelper imapHelper = new ImapHelper(mContext, phoneAccount, network)) {
+            if (!imapHelper.isSuccessfullyInitialized()) {
+                VvmLog.w(TAG, "Can't retrieve Imap credentials.");
+                return;
             }
-        } finally {
-            if (callback != null) {
-                callback.releaseNetwork();
+
+            boolean success;
+            if (voicemail == null) {
+                success = syncAll(action, imapHelper, phoneAccount);
+            } else {
+                success = syncOne(imapHelper, voicemail, phoneAccount);
+            }
+            imapHelper.updateQuota();
+
+            if (success) {
+                imapHelper.handleEvent(OmtpEvents.DATA_IMAP_OPERATION_COMPLETED);
+            } else {
+                task.fail();
             }
         }
     }
@@ -277,58 +154,22 @@
         VvmLog.v(TAG, "upload succeeded: [" + String.valueOf(uploadSuccess)
                 + "] download succeeded: [" + String.valueOf(downloadSuccess) + "]");
 
-        boolean success = uploadSuccess && downloadSuccess;
-        if (!uploadSuccess || !downloadSuccess) {
-            if (uploadSuccess) {
-                action = SYNC_DOWNLOAD_ONLY;
-            } else if (downloadSuccess) {
-                action = SYNC_UPLOAD_ONLY;
-            }
-        }
-
-        return success;
+        return uploadSuccess && downloadSuccess;
     }
 
     private boolean syncOne(ImapHelper imapHelper, Voicemail voicemail,
             PhoneAccountHandle account) {
         if (shouldPerformPrefetch(account, imapHelper)) {
-            VoicemailFetchedCallback callback = new VoicemailFetchedCallback(this,
+            VoicemailFetchedCallback callback = new VoicemailFetchedCallback(mContext,
                     voicemail.getUri());
             imapHelper.fetchVoicemailPayload(callback, voicemail.getSourceData());
         }
 
         return imapHelper.fetchTranscription(
-                new TranscriptionFetchedCallback(this, voicemail),
+                new TranscriptionFetchedCallback(mContext, voicemail),
                 voicemail.getSourceData());
     }
 
-    private class SyncNetworkRequestCallback extends VvmNetworkRequestCallback {
-
-        Voicemail mVoicemail;
-        private String mAction;
-
-        public SyncNetworkRequestCallback(Context context, PhoneAccountHandle phoneAccount,
-                Voicemail voicemail, String action) {
-            super(context, phoneAccount);
-            mAction = action;
-            mVoicemail = voicemail;
-        }
-
-        @Override
-        public void onAvailable(Network network) {
-            super.onAvailable(network);
-            NetworkInfo info = getConnectivityManager().getNetworkInfo(network);
-            if (info == null) {
-                VvmLog.d(TAG, "Network Type: Unknown");
-            } else {
-                VvmLog.d(TAG, "Network Type: " + info.getTypeName());
-            }
-
-            doSync(network, this, mPhoneAccount, mVoicemail, mAction);
-        }
-
-    }
-
     private boolean upload(ImapHelper imapHelper) {
         List<Voicemail> readVoicemails = mQueryHelper.getReadVoicemails();
         List<Voicemail> deletedVoicemails = mQueryHelper.getDeletedVoicemails();
@@ -393,9 +234,10 @@
         // The leftover messages are messages that exist on the server but not locally.
         boolean prefetchEnabled = shouldPerformPrefetch(account, imapHelper);
         for (Voicemail remoteVoicemail : remoteMap.values()) {
-            Uri uri = VoicemailContract.Voicemails.insert(this, remoteVoicemail);
+            Uri uri = VoicemailContract.Voicemails.insert(mContext, remoteVoicemail);
             if (prefetchEnabled) {
-                VoicemailFetchedCallback fetchedCallback = new VoicemailFetchedCallback(this, uri);
+                VoicemailFetchedCallback fetchedCallback =
+                        new VoicemailFetchedCallback(mContext, uri);
                 imapHelper.fetchVoicemailPayload(fetchedCallback, remoteVoicemail.getSourceData());
             }
         }
@@ -405,7 +247,7 @@
 
     private boolean shouldPerformPrefetch(PhoneAccountHandle account, ImapHelper imapHelper) {
         OmtpVvmCarrierConfigHelper carrierConfigHelper = new OmtpVvmCarrierConfigHelper(
-                this, PhoneUtils.getSubIdForPhoneAccountHandle(account));
+                mContext, PhoneUtils.getSubIdForPhoneAccountHandle(account));
         return carrierConfigHelper.isPrefetchEnabled() && !imapHelper.isRoaming();
     }
 
diff --git a/src/com/android/phone/vvm/omtp/sync/SyncOneTask.java b/src/com/android/phone/vvm/omtp/sync/SyncOneTask.java
new file mode 100644
index 0000000..17dfaed
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/sync/SyncOneTask.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.sync;
+
+import android.content.Context;
+import android.content.Intent;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.Voicemail;
+
+import com.android.phone.vvm.omtp.scheduling.BaseTask;
+import com.android.phone.vvm.omtp.scheduling.RetryPolicy;
+import com.android.phone.vvm.omtp.utils.PhoneAccountHandleConverter;
+
+/**
+ * Task to download a single voicemail from the server. This task is initiated by a SMS notifying
+ * the new voicemail arrival, and ignores the duplicated tasks constraint.
+ */
+public class SyncOneTask extends BaseTask {
+
+    private static final int RETRY_TIMES = 2;
+    private static final int RETRY_INTERVAL_MILLIS = 5_000;
+
+    private static final String EXTRA_PHONE_ACCOUNT_HANDLE = "extra_phone_account_handle";
+    private static final String EXTRA_SYNC_TYPE = "extra_sync_type";
+    private static final String EXTRA_VOICEMAIL = "extra_voicemail";
+
+    private PhoneAccountHandle mPhone;
+    private String mSyncType;
+    private Voicemail mVoicemail;
+
+    public static void start(Context context, PhoneAccountHandle phone, Voicemail voicemail) {
+        Intent intent = BaseTask
+                .createIntent(context, SyncTask.class, PhoneAccountHandleConverter.toSubId(phone));
+        intent.putExtra(EXTRA_PHONE_ACCOUNT_HANDLE, phone);
+        intent.putExtra(EXTRA_SYNC_TYPE, OmtpVvmSyncService.SYNC_DOWNLOAD_ONE_TRANSCRIPTION);
+        intent.putExtra(EXTRA_VOICEMAIL, voicemail);
+        context.startService(intent);
+    }
+
+    public SyncOneTask() {
+        super(TASK_ALLOW_DUPLICATES);
+        addPolicy(new RetryPolicy(RETRY_TIMES, RETRY_INTERVAL_MILLIS));
+    }
+
+    public void onCreate(Context context, Intent intent, int flags, int startId) {
+        super.onCreate(context, intent, flags, startId);
+        mPhone = intent.getParcelableExtra(EXTRA_PHONE_ACCOUNT_HANDLE);
+        mSyncType = intent.getStringExtra(EXTRA_SYNC_TYPE);
+        mVoicemail = intent.getParcelableExtra(EXTRA_VOICEMAIL);
+    }
+
+    @Override
+    public void onExecuteInBackgroundThread() {
+        OmtpVvmSyncService service = new OmtpVvmSyncService(getContext());
+        service.sync(this, mSyncType, mPhone, mVoicemail);
+    }
+
+    @Override
+    public Intent createRestartIntent() {
+        Intent intent = super.createRestartIntent();
+        intent.putExtra(EXTRA_PHONE_ACCOUNT_HANDLE, mPhone);
+        intent.putExtra(EXTRA_SYNC_TYPE, mSyncType);
+        intent.putExtra(EXTRA_VOICEMAIL, mVoicemail);
+        return intent;
+    }
+
+}
diff --git a/src/com/android/phone/vvm/omtp/sync/SyncTask.java b/src/com/android/phone/vvm/omtp/sync/SyncTask.java
new file mode 100644
index 0000000..d4efc6e
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/sync/SyncTask.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.sync;
+
+import android.content.Context;
+import android.content.Intent;
+import android.telecom.PhoneAccountHandle;
+
+import com.android.phone.vvm.omtp.scheduling.BaseTask;
+import com.android.phone.vvm.omtp.scheduling.MinimalIntervalPolicy;
+import com.android.phone.vvm.omtp.scheduling.RetryPolicy;
+import com.android.phone.vvm.omtp.utils.PhoneAccountHandleConverter;
+
+/**
+ * System initiated sync request.
+ */
+public class SyncTask extends BaseTask {
+
+    private static final int RETRY_TIMES = 2;
+    private static final int RETRY_INTERVAL_MILLIS = 5_000;
+    private static final int MINIMAL_INTERVAL_MILLIS = 60_000;
+
+    private static final String EXTRA_PHONE_ACCOUNT_HANDLE = "extra_phone_account_handle";
+    private static final String EXTRA_SYNC_TYPE = "extra_sync_type";
+
+    private PhoneAccountHandle mPhone;
+    private String mSyncType;
+
+    public static void start(Context context, PhoneAccountHandle phone, String syncType) {
+        Intent intent = BaseTask
+                .createIntent(context, SyncTask.class, PhoneAccountHandleConverter.toSubId(phone));
+        intent.putExtra(EXTRA_PHONE_ACCOUNT_HANDLE, phone);
+        intent.putExtra(EXTRA_SYNC_TYPE, syncType);
+        context.startService(intent);
+    }
+
+    public SyncTask() {
+        super(TASK_SYNC);
+        addPolicy(new RetryPolicy(RETRY_TIMES, RETRY_INTERVAL_MILLIS));
+        addPolicy(new MinimalIntervalPolicy(MINIMAL_INTERVAL_MILLIS));
+    }
+
+    public void onCreate(Context context, Intent intent, int flags, int startId) {
+        super.onCreate(context, intent, flags, startId);
+        mPhone = intent.getParcelableExtra(EXTRA_PHONE_ACCOUNT_HANDLE);
+        mSyncType = intent.getStringExtra(EXTRA_SYNC_TYPE);
+    }
+
+    @Override
+    public void onExecuteInBackgroundThread() {
+        OmtpVvmSyncService service = new OmtpVvmSyncService(getContext());
+        service.sync(this, mSyncType, mPhone, null);
+    }
+
+    @Override
+    public Intent createRestartIntent() {
+        Intent intent = super.createRestartIntent();
+        intent.putExtra(EXTRA_PHONE_ACCOUNT_HANDLE, mPhone);
+        intent.putExtra(EXTRA_SYNC_TYPE, mSyncType);
+        return intent;
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/sync/UploadTask.java b/src/com/android/phone/vvm/omtp/sync/UploadTask.java
new file mode 100644
index 0000000..afdee58
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/sync/UploadTask.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.sync;
+
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.phone.vvm.omtp.scheduling.BaseTask;
+import com.android.phone.vvm.omtp.scheduling.PostponePolicy;
+
+/**
+ * Upload task triggered by database changes. Will wait until the database has been stable for
+ * {@link #POSTPONE_MILLIS} to execute.
+ */
+public class UploadTask extends BaseTask {
+
+    private static final int POSTPONE_MILLIS = 5_000;
+
+    public UploadTask() {
+        super(TASK_UPLOAD);
+        addPolicy(new PostponePolicy(POSTPONE_MILLIS));
+    }
+
+    public static void start(Context context) {
+        Intent intent = BaseTask
+                .createIntent(context, UploadTask.class, TaskId.SUB_ID_ANY);
+        context.startService(intent);
+    }
+
+    @Override
+    public void onExecuteInBackgroundThread() {
+        OmtpVvmSyncService service = new OmtpVvmSyncService(getContext());
+        service.sync(this, OmtpVvmSyncService.SYNC_UPLOAD_ONLY, null, null);
+    }
+}
diff --git a/src/com/android/phone/vvm/omtp/sync/VoicemailProviderChangeReceiver.java b/src/com/android/phone/vvm/omtp/sync/VoicemailProviderChangeReceiver.java
index c2e6178..bf2e125 100644
--- a/src/com/android/phone/vvm/omtp/sync/VoicemailProviderChangeReceiver.java
+++ b/src/com/android/phone/vvm/omtp/sync/VoicemailProviderChangeReceiver.java
@@ -24,15 +24,14 @@
  * Receives changes to the voicemail provider so they can be sent to the voicemail server.
  */
 public class VoicemailProviderChangeReceiver extends BroadcastReceiver {
+
     @Override
     public void onReceive(Context context, Intent intent) {
         boolean isSelfChanged = intent.getBooleanExtra(VoicemailContract.EXTRA_SELF_CHANGE, false);
         OmtpVvmSourceManager vvmSourceManager =
                 OmtpVvmSourceManager.getInstance(context);
         if (vvmSourceManager.getOmtpVvmSources().size() > 0 && !isSelfChanged) {
-            Intent serviceIntent = OmtpVvmSyncService.getSyncIntent(
-                    context, OmtpVvmSyncService.SYNC_UPLOAD_ONLY, null, true /* firstAttempt */);
-            context.startService(serviceIntent);
+            UploadTask.start(context);
         }
     }
 }
diff --git a/src/com/android/phone/vvm/omtp/sync/VvmNetworkRequest.java b/src/com/android/phone/vvm/omtp/sync/VvmNetworkRequest.java
new file mode 100644
index 0000000..fba6a26
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/sync/VvmNetworkRequest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.sync;
+
+import android.net.Network;
+import android.telecom.PhoneAccountHandle;
+
+import com.android.phone.vvm.omtp.OmtpVvmCarrierConfigHelper;
+import com.android.phone.vvm.omtp.VvmLog;
+
+import java.io.Closeable;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+/**
+ * Class to retrieve a {@link Network} synchronously. {@link #getNetwork(OmtpVvmCarrierConfigHelper,
+ * PhoneAccountHandle)} will block until a suitable network is retrieved or it has failed.
+ */
+public class VvmNetworkRequest {
+
+    private static final String TAG = "VvmNetworkRequest";
+
+    /**
+     * A wrapper around a Network returned by a {@link VvmNetworkRequestCallback}, which should be
+     * closed once not needed anymore.
+     */
+    public static class NetworkWrapper implements Closeable {
+
+        private final Network mNetwork;
+        private final VvmNetworkRequestCallback mCallback;
+
+        private NetworkWrapper(Network network, VvmNetworkRequestCallback callback) {
+            mNetwork = network;
+            mCallback = callback;
+        }
+
+        public Network get() {
+            return mNetwork;
+        }
+
+        @Override
+        public void close() {
+            mCallback.releaseNetwork();
+        }
+    }
+
+    public static NetworkWrapper getNetwork(OmtpVvmCarrierConfigHelper config,
+            PhoneAccountHandle handle) {
+        FutureNetworkRequestCallback callback = new FutureNetworkRequestCallback(config, handle);
+        callback.requestNetwork();
+        try {
+            return callback.getFuture().get();
+        } catch (InterruptedException | ExecutionException e) {
+            VvmLog.e(TAG, "can't get future network", e);
+            return null;
+        }
+    }
+
+    private static class FutureNetworkRequestCallback extends VvmNetworkRequestCallback {
+
+        /**
+         * {@link CompletableFuture#get()} will block until {@link CompletableFuture#
+         * complete(Object) } has been called on the other thread.
+         */
+        private final CompletableFuture<NetworkWrapper> mFuture = new CompletableFuture<>();
+
+        public FutureNetworkRequestCallback(OmtpVvmCarrierConfigHelper config,
+                PhoneAccountHandle phoneAccount) {
+            super(config, phoneAccount);
+        }
+
+        public Future<NetworkWrapper> getFuture() {
+            return mFuture;
+        }
+
+        @Override
+        public void onAvailable(Network network) {
+            super.onAvailable(network);
+            mFuture.complete(new NetworkWrapper(network, this));
+        }
+
+        @Override
+        public void onFailed(String reason) {
+            super.onFailed(reason);
+            mFuture.complete(null);
+        }
+
+    }
+}
diff --git a/tests/Android.mk b/tests/Android.mk
index e1b564f..59cba42 100644
--- a/tests/Android.mk
+++ b/tests/Android.mk
@@ -25,7 +25,7 @@
 
 LOCAL_MODULE_TAGS := tests
 
-LOCAL_JAVA_LIBRARIES := telephony-common
+LOCAL_JAVA_LIBRARIES := telephony-common android-support-test
 
 LOCAL_INSTRUMENTATION_FOR := TeleService
 
diff --git a/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTest.java b/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTest.java
new file mode 100644
index 0000000..27dd87e
--- /dev/null
+++ b/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.phone.vvm.omtp.scheduling.Task.TaskId;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class BaseTaskTest extends BaseTaskTestBase {
+
+
+    @Test
+    public void testBaseTask() {
+        DummyBaseTask task = (DummyBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, DummyBaseTask.class, 123));
+        assertTrue(task.getId().equals(new TaskId(1, 123)));
+        assertTrue(!task.hasStarted());
+        assertTrue(!task.hasRun);
+        mService.runNextTask();
+        assertTrue(task.hasStarted());
+        assertTrue(task.hasRun);
+        verify(task.policy).onBeforeExecute();
+        verify(task.policy).onCompleted();
+    }
+
+    @Test
+    public void testFail() {
+        FailingBaseTask task = (FailingBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, FailingBaseTask.class, 0));
+        mService.runNextTask();
+        verify(task.policy).onFail();
+    }
+
+    @Test
+    public void testDuplicated() {
+        DummyBaseTask task1 = (DummyBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, DummyBaseTask.class, 123));
+        verify(task1.policy, never()).onDuplicatedTaskAdded();
+
+        DummyBaseTask task2 = (DummyBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, DummyBaseTask.class, 123));
+        verify(task1.policy).onDuplicatedTaskAdded();
+
+        mService.runNextTask();
+        assertTrue(task1.hasRun);
+        assertTrue(!task2.hasRun);
+    }
+
+    @Test
+    public void testDuplicated_DifferentSubId() {
+        DummyBaseTask task1 = (DummyBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, DummyBaseTask.class, 123));
+        verify(task1.policy, never()).onDuplicatedTaskAdded();
+
+        DummyBaseTask task2 = (DummyBaseTask) submitTask(
+                BaseTask.createIntent(mTestContext, DummyBaseTask.class, 456));
+        verify(task1.policy, never()).onDuplicatedTaskAdded();
+        mService.runNextTask();
+        assertTrue(task1.hasRun);
+        assertTrue(!task2.hasRun);
+
+        mService.runNextTask();
+        assertTrue(task2.hasRun);
+    }
+
+    @Test
+    public void testReadyTime() {
+        BaseTask task = spy(new DummyBaseTask());
+        assertTrue(task.getReadyInMilliSeconds() == 0);
+        mTime = 500;
+        assertTrue(task.getReadyInMilliSeconds() == -500);
+        task.setExecutionTime(1000);
+        assertTrue(task.getReadyInMilliSeconds() == 500);
+    }
+
+    public static class DummyBaseTask extends BaseTask {
+
+        public Policy policy;
+        public boolean hasRun = false;
+
+        public DummyBaseTask() {
+            super(1);
+            policy = mock(Policy.class);
+            addPolicy(policy);
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            hasRun = true;
+        }
+    }
+
+    public static class FailingBaseTask extends BaseTask {
+
+        public Policy policy;
+        public FailingBaseTask() {
+            super(1);
+            policy = mock(Policy.class);
+            addPolicy(policy);
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            fail();
+        }
+    }
+}
diff --git a/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTestBase.java b/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTestBase.java
new file mode 100644
index 0000000..1ffd3c4
--- /dev/null
+++ b/tests/src/com/android/phone/vvm/omtp/scheduling/BaseTaskTestBase.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import com.android.phone.vvm.omtp.scheduling.BaseTask.Clock;
+
+import org.junit.After;
+import org.junit.Before;
+
+public class BaseTaskTestBase extends TaskSchedulerServiceTestBase {
+
+    /**
+     * "current time" of the deterministic clock.
+     */
+    public long mTime;
+
+    @Before
+    public void setUpBaseTaskTest() {
+        mTime = 0;
+        BaseTask.setClockForTesting(new TestClock());
+    }
+
+    @After
+    public void tearDownBaseTaskTest() {
+        BaseTask.setClockForTesting(new Clock());
+    }
+
+
+    private class TestClock extends Clock {
+
+        @Override
+        public long getTimeMillis() {
+            return mTime;
+        }
+    }
+}
diff --git a/tests/src/com/android/phone/vvm/omtp/scheduling/PolicyTest.java b/tests/src/com/android/phone/vvm/omtp/scheduling/PolicyTest.java
new file mode 100644
index 0000000..9761d01
--- /dev/null
+++ b/tests/src/com/android/phone/vvm/omtp/scheduling/PolicyTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class PolicyTest extends BaseTaskTestBase {
+
+    private static int sExecuteCounter;
+
+    @Before
+    public void setUpPolicyTest() {
+        sExecuteCounter = 0;
+    }
+
+    @Test
+    public void testPostponePolicy() {
+        Task task = submitTask(BaseTask.createIntent(mTestContext, PostponeTask.class, 0));
+        mService.runNextTask();
+        assertTrue(task.getReadyInMilliSeconds() == 1000);
+        submitTask(BaseTask.createIntent(mTestContext, PostponeTask.class, 0));
+        assertTrue(task.getReadyInMilliSeconds() == 1000);
+        mTime = 500;
+        submitTask(BaseTask.createIntent(mTestContext, PostponeTask.class, 0));
+        assertTrue(task.getReadyInMilliSeconds() == 1000);
+        mTime = 2500;
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 1);
+    }
+
+    @Test
+    public void testRetryPolicy() {
+        Task task = submitTask(BaseTask.createIntent(mTestContext, FailingRetryTask.class, 0));
+        mService.runNextTask();
+        // Should queue retry at 1000
+        assertTrue(sExecuteCounter == 1);
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 1);
+        mTime = 1500;
+        mService.runNextTask();
+        // Should queue retry at 2500
+        assertTrue(sExecuteCounter == 2);
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 2);
+        mTime = 2000;
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 2);
+        mTime = 3000;
+        mService.runNextTask();
+        // No more retries are queued.
+        assertTrue(sExecuteCounter == 3);
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 3);
+        mTime = 4500;
+        mService.runNextTask();
+        assertTrue(sExecuteCounter == 3);
+    }
+
+    @Test
+    public void testMinimalIntervalPolicy() {
+        MinimalIntervalPolicyTask task1 = (MinimalIntervalPolicyTask) submitTask(
+                BaseTask.createIntent(mTestContext, MinimalIntervalPolicyTask.class, 0));
+        mService.runNextTask();
+        assertTrue(task1.hasRan);
+        MinimalIntervalPolicyTask task2 = (MinimalIntervalPolicyTask) submitTask(
+                BaseTask.createIntent(mTestContext, MinimalIntervalPolicyTask.class, 0));
+        mService.runNextTask();
+        assertTrue(!task2.hasRan);
+
+        mTime = 1500;
+        mService.runNextTask();
+
+        MinimalIntervalPolicyTask task3 = (MinimalIntervalPolicyTask) submitTask(
+                BaseTask.createIntent(mTestContext, MinimalIntervalPolicyTask.class, 0));
+        mService.runNextTask();
+        assertTrue(task3.hasRan);
+    }
+
+    public abstract static class PolicyTestTask extends BaseTask {
+
+        public PolicyTestTask() {
+            super(1);
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            sExecuteCounter++;
+        }
+    }
+
+    public static class PostponeTask extends PolicyTestTask {
+
+        PostponeTask() {
+            addPolicy(new PostponePolicy(1000));
+        }
+    }
+
+    public static class FailingRetryTask extends PolicyTestTask {
+
+        public FailingRetryTask() {
+            addPolicy(new RetryPolicy(2, 1000));
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            super.onExecuteInBackgroundThread();
+            fail();
+        }
+    }
+
+    public static class MinimalIntervalPolicyTask extends PolicyTestTask {
+
+        boolean hasRan;
+
+        MinimalIntervalPolicyTask() {
+            addPolicy(new MinimalIntervalPolicy(1000));
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            super.onExecuteInBackgroundThread();
+            hasRan = true;
+        }
+    }
+
+}
diff --git a/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTest.java b/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTest.java
new file mode 100644
index 0000000..2dd4ecf
--- /dev/null
+++ b/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.phone.vvm.omtp.scheduling.Task.TaskId;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.TimeoutException;
+
+@RunWith(AndroidJUnit4.class)
+public class TaskSchedulerServiceTest extends TaskSchedulerServiceTestBase {
+
+    @Test
+    public void testTaskIdComparison() {
+        TaskId id1 = new TaskId(1, 1);
+        TaskId id2 = new TaskId(1, 1);
+        TaskId id3 = new TaskId(1, 2);
+        assertTrue(id1.equals(id2));
+        assertTrue(id1.equals(id1));
+        assertTrue(!id1.equals(id3));
+    }
+
+    @Test
+    public void testAddDuplicatedTask() throws TimeoutException {
+        TestTask task1 = (TestTask) submitTask(
+                TaskSchedulerService.createIntent(mTestContext, TestTask.class));
+        TestTask task2 = (TestTask) submitTask(
+                TaskSchedulerService.createIntent(mTestContext, TestTask.class));
+        assertTrue(task1.onDuplicatedTaskAddedCounter.invokedOnce());
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyNotRan(task2);
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyNotRan(task2);
+    }
+
+    @Test
+    public void testAddDuplicatedTaskAfterFirstCompleted() throws TimeoutException {
+        TestTask task1 = (TestTask) submitTask(
+                TaskSchedulerService.createIntent(mTestContext, TestTask.class));
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        TestTask task2 = (TestTask) submitTask(
+                TaskSchedulerService.createIntent(mTestContext, TestTask.class));
+        assertTrue(task1.onDuplicatedTaskAddedCounter.neverInvoked());
+        mService.runNextTask();
+        verifyRanOnce(task2);
+    }
+
+    @Test
+    public void testAddMultipleTask() {
+        TestTask task1 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(1, 0)));
+        TestTask task2 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(2, 0)));
+        TestTask task3 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(1, 1)));
+        assertTrue(task1.onDuplicatedTaskAddedCounter.neverInvoked());
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyNotRan(task2);
+        verifyNotRan(task3);
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyRanOnce(task2);
+        verifyNotRan(task3);
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyRanOnce(task2);
+        verifyRanOnce(task3);
+    }
+
+    @Test
+    public void testNotReady() {
+        TestTask task1 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(1, 0)));
+        task1.readyInMilliseconds = 1000;
+        mService.runNextTask();
+        verifyNotRan(task1);
+        TestTask task2 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(2, 0)));
+        mService.runNextTask();
+        verifyNotRan(task1);
+        verifyRanOnce(task2);
+        task1.readyInMilliseconds = 50;
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyRanOnce(task2);
+    }
+
+    @Test
+    public void testInvalidTaskId() {
+        Task task = mock(Task.class);
+        when(task.getId()).thenReturn(new TaskId(Task.TASK_INVALID, 0));
+        thrown.expect(AssertionError.class);
+        mService.addTask(task);
+    }
+
+    @Test
+    public void testDuplicatesAllowedTaskId() {
+        TestTask task1 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(Task.TASK_ALLOW_DUPLICATES, 0)));
+        TestTask task2 = (TestTask) submitTask(
+                putTaskId(TaskSchedulerService.createIntent(mTestContext, TestTask.class),
+                        new TaskId(Task.TASK_ALLOW_DUPLICATES, 0)));
+        assertTrue(task1.onDuplicatedTaskAddedCounter.neverInvoked());
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyNotRan(task2);
+        mService.runNextTask();
+        verifyRanOnce(task1);
+        verifyRanOnce(task2);
+    }
+}
diff --git a/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTestBase.java b/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTestBase.java
new file mode 100644
index 0000000..63f5c2f
--- /dev/null
+++ b/tests/src/com/android/phone/vvm/omtp/scheduling/TaskSchedulerServiceTestBase.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2016 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.phone.vvm.omtp.scheduling;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.IBinder;
+import android.os.Message;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.rule.ServiceTestRule;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.phone.Assert;
+import com.android.phone.vvm.omtp.scheduling.Task.TaskId;
+import com.android.phone.vvm.omtp.scheduling.TaskSchedulerService.MainThreadHandler;
+import com.android.phone.vvm.omtp.scheduling.TaskSchedulerService.WorkerThreadHandler;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.TimeoutException;
+
+@RunWith(AndroidJUnit4.class)
+public class TaskSchedulerServiceTestBase {
+
+    private static final String EXTRA_ID = "test_extra_id";
+    private static final String EXTRA_SUB_ID = "test_extra_sub_id";
+
+    public TaskSchedulerService mService;
+
+    @Rule
+    public final ExpectedException thrown = ExpectedException.none();
+
+    @Rule
+    public final ServiceTestRule mServiceRule = new ServiceTestRule();
+
+    public Context mTargetContext;
+    public Context mTestContext;
+
+    private static boolean sIsMainThread = true;
+
+    private final TestMessageSender mMessageSender = new TestMessageSender();
+
+    public static Intent putTaskId(Intent intent, TaskId taskId) {
+        intent.putExtra(EXTRA_ID, taskId.id);
+        intent.putExtra(EXTRA_SUB_ID, taskId.subId);
+        return intent;
+    }
+
+    public static TaskId getTaskId(Intent intent) {
+        return new TaskId(intent.getIntExtra(EXTRA_ID, 0), intent.getIntExtra(EXTRA_SUB_ID, 0));
+    }
+
+    @Before
+    public void setUp() throws TimeoutException {
+        Assert.setIsMainThreadForTesting(true);
+        mTargetContext = InstrumentationRegistry.getTargetContext();
+        IBinder binder = null;
+        // bindService() might returns null on 2nd invocation because the service is not unbinded
+        // yet. See https://code.google.com/p/android/issues/detail?id=180396
+        while (binder == null) {
+            binder = mServiceRule
+                    .bindService(new Intent(mTargetContext, TaskSchedulerService.class));
+        }
+        mService = ((TaskSchedulerService.LocalBinder) binder).getService();
+        mTestContext = createTestContext(mTargetContext, mService);
+        mService.setMessageSenderForTest(mMessageSender);
+        mService.setTaskAutoRunDisabledForTest(true);
+        mService.setContextForTest(mTestContext);
+    }
+
+    @After
+    public void tearDown() {
+        Assert.setIsMainThreadForTesting(null);
+        mService.setTaskAutoRunDisabledForTest(false);
+        mService.clearTasksForTest();
+        mService.stopSelf();
+    }
+
+    public Task submitTask(Intent intent) {
+        Task task = mService.createTask(intent, 0, 0);
+        mService.addTask(task);
+        return task;
+    }
+
+    public static void verifyRanOnce(TestTask task) {
+        assertTrue(task.onBeforeExecuteCounter.invokedOnce());
+        assertTrue(task.executeCounter.invokedOnce());
+        assertTrue(task.onCompletedCounter.invokedOnce());
+    }
+
+    public static void verifyNotRan(TestTask task) {
+        assertTrue(task.onBeforeExecuteCounter.neverInvoked());
+        assertTrue(task.executeCounter.neverInvoked());
+        assertTrue(task.onCompletedCounter.neverInvoked());
+    }
+
+    public static class TestTask implements Task {
+
+        public int readyInMilliseconds;
+
+        private TaskId mId;
+
+        public final InvocationCounter onCreateCounter = new InvocationCounter();
+        public final InvocationCounter onBeforeExecuteCounter = new InvocationCounter();
+        public final InvocationCounter executeCounter = new InvocationCounter();
+        public final InvocationCounter onCompletedCounter = new InvocationCounter();
+        public final InvocationCounter onDuplicatedTaskAddedCounter = new InvocationCounter();
+
+        @Override
+        public void onCreate(Context context, Intent intent, int flags, int startId) {
+            onCreateCounter.invoke();
+            mId = getTaskId(intent);
+        }
+
+        @Override
+        public TaskId getId() {
+            return mId;
+        }
+
+        @Override
+        public long getReadyInMilliSeconds() {
+            Assert.isMainThread();
+            return readyInMilliseconds;
+        }
+
+        @Override
+        public void onBeforeExecute() {
+            Assert.isMainThread();
+            onBeforeExecuteCounter.invoke();
+        }
+
+        @Override
+        public void onExecuteInBackgroundThread() {
+            Assert.isNotMainThread();
+            executeCounter.invoke();
+        }
+
+        @Override
+        public void onCompleted() {
+            Assert.isMainThread();
+            onCompletedCounter.invoke();
+        }
+
+        @Override
+        public void onDuplicatedTaskAdded(Task task) {
+            Assert.isMainThread();
+            onDuplicatedTaskAddedCounter.invoke();
+        }
+    }
+
+    public static class InvocationCounter {
+
+        private int mCounter;
+
+        public void invoke() {
+            mCounter++;
+        }
+
+        public boolean invokedOnce() {
+            return mCounter == 1;
+        }
+
+        public boolean neverInvoked() {
+            return mCounter == 0;
+        }
+    }
+
+    private class TestMessageSender extends TaskSchedulerService.MessageSender {
+
+        @Override
+        public void send(Message message) {
+            if (message.getTarget() instanceof MainThreadHandler) {
+                Assert.setIsMainThreadForTesting(true);
+            } else if (message.getTarget() instanceof WorkerThreadHandler) {
+                Assert.setIsMainThreadForTesting(false);
+            } else {
+                throw new AssertionError("unexpected Handler " + message.getTarget());
+            }
+            message.getTarget().handleMessage(message);
+        }
+    }
+
+    public static void assertTrue(boolean condition) {
+        if (!condition) {
+            throw new AssertionError();
+        }
+    }
+
+    private static Context createTestContext(Context targetContext, TaskSchedulerService service) {
+        TestContext context = mock(TestContext.class);
+        when(context.getService()).thenReturn(service);
+        when(context.startService(any())).thenCallRealMethod();
+        when(context.getPackageName()).thenReturn(targetContext.getPackageName());
+        return context;
+    }
+
+    public abstract class TestContext extends Context {
+
+        @Override
+        public ComponentName startService(Intent service) {
+            getService().onStartCommand(service, 0, 0);
+            return null;
+        }
+
+        public abstract TaskSchedulerService getService();
+    }
+}