diff --git a/jobqueue/.gitignore b/jobqueue/.gitignore
new file mode 100644
index 0000000000..796b96d1c4
--- /dev/null
+++ b/jobqueue/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/jobqueue/build.gradle b/jobqueue/build.gradle
new file mode 100644
index 0000000000..0f3bb93fdf
--- /dev/null
+++ b/jobqueue/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 20
+ buildToolsVersion "20.0.0"
+
+ defaultConfig {
+ applicationId "org.whispersystems.jobqueue"
+ minSdkVersion 9
+ targetSdkVersion 19
+ versionCode 1
+ versionName "1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_7
+ targetCompatibility JavaVersion.VERSION_1_7
+ }
+}
\ No newline at end of file
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/JobManagerTest.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/JobManagerTest.java
new file mode 100644
index 0000000000..312de751bc
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/JobManagerTest.java
@@ -0,0 +1,90 @@
+package org.whispersystems.jobqueue;
+
+import android.test.AndroidTestCase;
+
+import org.whispersystems.jobqueue.jobs.PersistentTestJob;
+import org.whispersystems.jobqueue.jobs.RequirementTestJob;
+import org.whispersystems.jobqueue.jobs.TestJob;
+import org.whispersystems.jobqueue.persistence.JavaJobSerializer;
+import org.whispersystems.jobqueue.util.MockRequirement;
+import org.whispersystems.jobqueue.util.MockRequirementProvider;
+import org.whispersystems.jobqueue.util.PersistentMockRequirement;
+import org.whispersystems.jobqueue.util.PersistentRequirement;
+import org.whispersystems.jobqueue.util.PersistentResult;
+
+public class JobManagerTest extends AndroidTestCase {
+
+ public void testTransientJobExecution() throws InterruptedException {
+ TestJob testJob = new TestJob();
+ JobManager jobManager = new JobManager(getContext(), "transient-test", null, null, 1);
+
+ jobManager.add(testJob);
+
+ assertTrue(testJob.isAdded());
+ assertTrue(testJob.isRan());
+ }
+
+ public void testTransientRequirementJobExecution() throws InterruptedException {
+ MockRequirementProvider provider = new MockRequirementProvider();
+ MockRequirement requirement = new MockRequirement(false);
+ TestJob testJob = new RequirementTestJob(requirement);
+ JobManager jobManager = new JobManager(getContext(), "transient-requirement-test", provider, null, 1);
+
+ jobManager.add(testJob);
+
+ assertTrue(testJob.isAdded());
+ assertTrue(!testJob.isRan());
+
+ requirement.setPresent(true);
+ provider.fireChange();
+
+ assertTrue(testJob.isRan());
+
+ }
+
+ public void testPersistentJobExecuton() throws InterruptedException {
+ PersistentMockRequirement requirement = new PersistentMockRequirement();
+ PersistentTestJob testJob = new PersistentTestJob(requirement);
+ JobManager jobManager = new JobManager(getContext(), "persistent-requirement-test3", null, new JavaJobSerializer(getContext()), 1);
+
+ PersistentResult.getInstance().reset();
+ PersistentRequirement.getInstance().setPresent(false);
+
+ jobManager.add(testJob);
+
+ assertTrue(PersistentResult.getInstance().isAdded());
+ assertTrue(!PersistentResult.getInstance().isRan());
+
+ PersistentRequirement.getInstance().setPresent(true);
+ jobManager = new JobManager(getContext(), "persistent-requirement-test3", null, new JavaJobSerializer(getContext()), 1);
+
+ assertTrue(PersistentResult.getInstance().isRan());
+ }
+
+ public void testEncryptedJobExecuton() throws InterruptedException {
+ EncryptionKeys keys = new EncryptionKeys("foobar");
+ PersistentMockRequirement requirement = new PersistentMockRequirement();
+ PersistentTestJob testJob = new PersistentTestJob(requirement, keys);
+ JobManager jobManager = new JobManager(getContext(), "persistent-requirement-test4", null, new JavaJobSerializer(getContext()), 1);
+ jobManager.setEncryptionKeys(keys);
+
+ PersistentResult.getInstance().reset();
+ PersistentRequirement.getInstance().setPresent(false);
+
+ jobManager.add(testJob);
+
+ assertTrue(PersistentResult.getInstance().isAdded());
+ assertTrue(!PersistentResult.getInstance().isRan());
+
+ PersistentRequirement.getInstance().setPresent(true);
+ jobManager = new JobManager(getContext(), "persistent-requirement-test4", null, new JavaJobSerializer(getContext()), 1);
+
+ assertTrue(!PersistentResult.getInstance().isRan());
+
+ jobManager.setEncryptionKeys(keys);
+
+ assertTrue(PersistentResult.getInstance().isRan());
+ }
+
+
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/PersistentTestJob.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/PersistentTestJob.java
new file mode 100644
index 0000000000..0f582e72ef
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/PersistentTestJob.java
@@ -0,0 +1,39 @@
+package org.whispersystems.jobqueue.jobs;
+
+import org.whispersystems.jobqueue.EncryptionKeys;
+import org.whispersystems.jobqueue.Job;
+import org.whispersystems.jobqueue.JobParameters;
+import org.whispersystems.jobqueue.requirements.Requirement;
+import org.whispersystems.jobqueue.util.PersistentResult;
+
+public class PersistentTestJob extends Job {
+
+ public PersistentTestJob(Requirement requirement) {
+ super(JobParameters.newBuilder().withRequirement(requirement).withPersistence().create());
+ }
+
+ public PersistentTestJob(Requirement requirement, EncryptionKeys keys) {
+ super(JobParameters.newBuilder().withRequirement(requirement).withPersistence().withEncryption(keys).create());
+ }
+
+
+ @Override
+ public void onAdded() {
+ PersistentResult.getInstance().onAdded();;
+ }
+
+ @Override
+ public void onRun() throws Throwable {
+ PersistentResult.getInstance().onRun();
+ }
+
+ @Override
+ public void onCanceled() {
+ PersistentResult.getInstance().onCanceled();
+ }
+
+ @Override
+ public boolean onShouldRetry(Throwable throwable) {
+ return false;
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/RequirementTestJob.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/RequirementTestJob.java
new file mode 100644
index 0000000000..8dac14c608
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/RequirementTestJob.java
@@ -0,0 +1,12 @@
+package org.whispersystems.jobqueue.jobs;
+
+import org.whispersystems.jobqueue.JobParameters;
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+public class RequirementTestJob extends TestJob {
+
+ public RequirementTestJob(Requirement requirement) {
+ super(JobParameters.newBuilder().withRequirement(requirement).create());
+ }
+
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/TestJob.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/TestJob.java
new file mode 100644
index 0000000000..228d77d6c7
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/jobs/TestJob.java
@@ -0,0 +1,71 @@
+package org.whispersystems.jobqueue.jobs;
+
+import org.whispersystems.jobqueue.Job;
+import org.whispersystems.jobqueue.JobParameters;
+
+public class TestJob extends Job {
+
+ private final Object ADDED_LOCK = new Object();
+ private final Object RAN_LOCK = new Object();
+ private final Object CANCELED_LOCK = new Object();
+
+ private boolean added = false;
+ private boolean ran = false;
+ private boolean canceled = false;
+
+ public TestJob() {
+ this(JobParameters.newBuilder().create());
+ }
+
+ public TestJob(JobParameters parameters) {
+ super(parameters);
+ }
+
+ @Override
+ public void onAdded() {
+ synchronized (ADDED_LOCK) {
+ this.added = true;
+ this.ADDED_LOCK.notifyAll();
+ }
+ }
+
+ @Override
+ public void onRun() throws Throwable {
+ synchronized (RAN_LOCK) {
+ this.ran = true;
+ }
+ }
+
+ @Override
+ public void onCanceled() {
+ synchronized (CANCELED_LOCK) {
+ this.canceled = true;
+ }
+ }
+
+ @Override
+ public boolean onShouldRetry(Throwable throwable) {
+ return false;
+ }
+
+ public boolean isAdded() throws InterruptedException {
+ synchronized (ADDED_LOCK) {
+ if (!added) ADDED_LOCK.wait(1000);
+ return added;
+ }
+ }
+
+ public boolean isRan() throws InterruptedException {
+ synchronized (RAN_LOCK) {
+ if (!ran) RAN_LOCK.wait(1000);
+ return ran;
+ }
+ }
+
+ public boolean isCanceled() throws InterruptedException {
+ synchronized (CANCELED_LOCK) {
+ if (!canceled) CANCELED_LOCK.wait(1000);
+ return canceled;
+ }
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirement.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirement.java
new file mode 100644
index 0000000000..779d07747e
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirement.java
@@ -0,0 +1,23 @@
+package org.whispersystems.jobqueue.util;
+
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class MockRequirement implements Requirement {
+
+ private AtomicBoolean present;
+
+ public MockRequirement(boolean present) {
+ this.present = new AtomicBoolean(present);
+ }
+
+ public void setPresent(boolean present) {
+ this.present.set(present);
+ }
+
+ @Override
+ public boolean isPresent() {
+ return present.get();
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirementProvider.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirementProvider.java
new file mode 100644
index 0000000000..235449e18e
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/MockRequirementProvider.java
@@ -0,0 +1,18 @@
+package org.whispersystems.jobqueue.util;
+
+import org.whispersystems.jobqueue.requirements.RequirementListener;
+import org.whispersystems.jobqueue.requirements.RequirementProvider;
+
+public class MockRequirementProvider implements RequirementProvider {
+
+ private RequirementListener listener;
+
+ public void fireChange() {
+ listener.onRequirementStatusChanged();
+ }
+
+ @Override
+ public void setListener(RequirementListener listener) {
+ this.listener = listener;
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentMockRequirement.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentMockRequirement.java
new file mode 100644
index 0000000000..f39eb491a3
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentMockRequirement.java
@@ -0,0 +1,10 @@
+package org.whispersystems.jobqueue.util;
+
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+public class PersistentMockRequirement implements Requirement {
+ @Override
+ public boolean isPresent() {
+ return PersistentRequirement.getInstance().isPresent();
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentRequirement.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentRequirement.java
new file mode 100644
index 0000000000..917574805a
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentRequirement.java
@@ -0,0 +1,22 @@
+package org.whispersystems.jobqueue.util;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class PersistentRequirement {
+
+ private AtomicBoolean present = new AtomicBoolean(false);
+
+ private static final PersistentRequirement instance = new PersistentRequirement();
+
+ public static PersistentRequirement getInstance() {
+ return instance;
+ }
+
+ public void setPresent(boolean present) {
+ this.present.set(present);
+ }
+
+ public boolean isPresent() {
+ return present.get();
+ }
+}
diff --git a/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentResult.java b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentResult.java
new file mode 100644
index 0000000000..ffd213ae85
--- /dev/null
+++ b/jobqueue/src/androidTest/java/org/whispersystems/jobqueue/util/PersistentResult.java
@@ -0,0 +1,73 @@
+package org.whispersystems.jobqueue.util;
+
+public class PersistentResult {
+
+ private final Object ADDED_LOCK = new Object();
+ private final Object RAN_LOCK = new Object();
+ private final Object CANCELED_LOCK = new Object();
+
+ private boolean added = false;
+ private boolean ran = false;
+ private boolean canceled = false;
+
+ private static final PersistentResult instance = new PersistentResult();
+
+ public static PersistentResult getInstance() {
+ return instance;
+ }
+
+ public void onAdded() {
+ synchronized (ADDED_LOCK) {
+ this.added = true;
+ this.ADDED_LOCK.notifyAll();
+ }
+ }
+
+ public void onRun() throws Throwable {
+ synchronized (RAN_LOCK) {
+ this.ran = true;
+ }
+ }
+
+ public void onCanceled() {
+ synchronized (CANCELED_LOCK) {
+ this.canceled = true;
+ }
+ }
+
+ public boolean isAdded() throws InterruptedException {
+ synchronized (ADDED_LOCK) {
+ if (!added) ADDED_LOCK.wait(1000);
+ return added;
+ }
+ }
+
+ public boolean isRan() throws InterruptedException {
+ synchronized (RAN_LOCK) {
+ if (!ran) RAN_LOCK.wait(1000);
+ return ran;
+ }
+ }
+
+ public boolean isCanceled() throws InterruptedException {
+ synchronized (CANCELED_LOCK) {
+ if (!canceled) CANCELED_LOCK.wait(1000);
+ return canceled;
+ }
+ }
+
+ public void reset() {
+ synchronized (ADDED_LOCK) {
+ this.added = false;
+ }
+
+ synchronized (RAN_LOCK) {
+ this.ran = false;
+ }
+
+ synchronized (CANCELED_LOCK) {
+ this.canceled = false;
+ }
+ }
+
+}
diff --git a/jobqueue/src/main/AndroidManifest.xml b/jobqueue/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..4265f4c4fa
--- /dev/null
+++ b/jobqueue/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/EncryptionKeys.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/EncryptionKeys.java
new file mode 100644
index 0000000000..f414543e01
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/EncryptionKeys.java
@@ -0,0 +1,14 @@
+package org.whispersystems.jobqueue;
+
+public class EncryptionKeys {
+
+ private transient final String keys;
+
+ public EncryptionKeys(String keys) {
+ this.keys = keys;
+ }
+
+ public String getKeys() {
+ return keys;
+ }
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/Job.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/Job.java
new file mode 100644
index 0000000000..ccc27a67c5
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/Job.java
@@ -0,0 +1,60 @@
+package org.whispersystems.jobqueue;
+
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+import java.io.Serializable;
+import java.util.List;
+
+public abstract class Job implements Serializable {
+
+ private final JobParameters parameters;
+
+ private transient long persistentId;
+
+ public Job(JobParameters parameters) {
+ this.parameters = parameters;
+ }
+
+ public List getRequirements() {
+ return parameters.getRequirements();
+ }
+
+ public boolean isRequirementsMet() {
+ for (Requirement requirement : parameters.getRequirements()) {
+ if (!requirement.isPresent()) return false;
+ }
+
+ return true;
+ }
+
+ public boolean isPersistent() {
+ return parameters.isPersistent();
+ }
+
+ public EncryptionKeys getEncryptionKeys() {
+ return parameters.getEncryptionKeys();
+ }
+
+ public void setEncryptionKeys(EncryptionKeys keys) {
+ parameters.setEncryptionKeys(keys);
+ }
+
+ public int getRetryCount() {
+ return parameters.getRetryCount();
+ }
+
+ public void setPersistentId(long persistentId) {
+ this.persistentId = persistentId;
+ }
+
+ public long getPersistentId() {
+ return persistentId;
+ }
+
+ public abstract void onAdded();
+ public abstract void onRun() throws Throwable;
+ public abstract void onCanceled();
+ public abstract boolean onShouldRetry(Throwable throwable);
+
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/JobConsumer.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobConsumer.java
new file mode 100644
index 0000000000..fd187a7fb4
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobConsumer.java
@@ -0,0 +1,48 @@
+package org.whispersystems.jobqueue;
+
+import org.whispersystems.jobqueue.persistence.PersistentStorage;
+
+public class JobConsumer extends Thread {
+
+ private final JobQueue jobQueue;
+ private final PersistentStorage persistentStorage;
+
+ public JobConsumer(String name, JobQueue jobQueue, PersistentStorage persistentStorage) {
+ super(name);
+ this.jobQueue = jobQueue;
+ this.persistentStorage = persistentStorage;
+ }
+
+ @Override
+ public void run() {
+ while (true) {
+ Job job = jobQueue.getNext();
+
+ if (!runJob(job)) {
+ job.onCanceled();
+ }
+
+ if (job.isPersistent()) {
+ persistentStorage.remove(job.getPersistentId());
+ }
+ }
+ }
+
+ private boolean runJob(Job job) {
+ int retryCount = job.getRetryCount();
+
+ for (int i=retryCount;i>0;i--) {
+ try {
+ job.onRun();
+ return true;
+ } catch (Throwable throwable) {
+ if (!job.onShouldRetry(throwable)) {
+ return false;
+ }
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/JobManager.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobManager.java
new file mode 100644
index 0000000000..0f4aeb0965
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobManager.java
@@ -0,0 +1,95 @@
+package org.whispersystems.jobqueue;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.whispersystems.jobqueue.persistence.JobSerializer;
+import org.whispersystems.jobqueue.persistence.PersistentStorage;
+import org.whispersystems.jobqueue.requirements.RequirementListener;
+import org.whispersystems.jobqueue.requirements.RequirementProvider;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class JobManager implements RequirementListener {
+
+ private final JobQueue jobQueue = new JobQueue();
+ private final Executor eventExecutor = Executors.newSingleThreadExecutor();
+ private final AtomicBoolean hasLoadedEncrypted = new AtomicBoolean(false);
+
+ private final PersistentStorage persistentStorage;
+
+ public JobManager(Context context, String name,
+ RequirementProvider requirementProvider,
+ JobSerializer jobSerializer, int consumers)
+ {
+ this.persistentStorage = new PersistentStorage(context, name, jobSerializer);
+ eventExecutor.execute(new LoadTask(null));
+
+ if (requirementProvider != null) {
+ requirementProvider.setListener(this);
+ }
+
+ for (int i=0;i pendingJobs;
+
+ if (keys == null) pendingJobs = persistentStorage.getAllUnencrypted();
+ else pendingJobs = persistentStorage.getAllEncrypted(keys);
+
+ jobQueue.addAll(pendingJobs);
+ }
+ }
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/JobParameters.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobParameters.java
new file mode 100644
index 0000000000..2504d14618
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobParameters.java
@@ -0,0 +1,82 @@
+package org.whispersystems.jobqueue;
+
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+import java.io.Serializable;
+import java.util.LinkedList;
+import java.util.List;
+
+public class JobParameters implements Serializable {
+
+ private transient EncryptionKeys encryptionKeys;
+
+ private final List requirements;
+ private final boolean isPersistent;
+ private final int retryCount;
+
+ private JobParameters(List requirements,
+ boolean isPersistent,
+ EncryptionKeys encryptionKeys,
+ int retryCount)
+ {
+ this.requirements = requirements;
+ this.isPersistent = isPersistent;
+ this.encryptionKeys = encryptionKeys;
+ this.retryCount = retryCount;
+ }
+
+ public List getRequirements() {
+ return requirements;
+ }
+
+ public boolean isPersistent() {
+ return isPersistent;
+ }
+
+ public EncryptionKeys getEncryptionKeys() {
+ return encryptionKeys;
+ }
+
+ public void setEncryptionKeys(EncryptionKeys encryptionKeys) {
+ this.encryptionKeys = encryptionKeys;
+ }
+
+ public int getRetryCount() {
+ return retryCount;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private List requirements = new LinkedList<>();
+ private boolean isPersistent = false;
+ private EncryptionKeys encryptionKeys = null;
+ private int retryCount = 100;
+
+ public Builder withRequirement(Requirement requirement) {
+ this.requirements.add(requirement);
+ return this;
+ }
+
+ public Builder withPersistence() {
+ this.isPersistent = true;
+ return this;
+ }
+
+ public Builder withEncryption(EncryptionKeys encryptionKeys) {
+ this.encryptionKeys = encryptionKeys;
+ return this;
+ }
+
+ public Builder withRetryCount(int retryCount) {
+ this.retryCount = retryCount;
+ return this;
+ }
+
+ public JobParameters create() {
+ return new JobParameters(requirements, isPersistent, encryptionKeys, retryCount);
+ }
+ }
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/JobQueue.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobQueue.java
new file mode 100644
index 0000000000..2901c900d1
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/JobQueue.java
@@ -0,0 +1,54 @@
+package org.whispersystems.jobqueue;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.ListIterator;
+
+public class JobQueue {
+
+ private final LinkedList jobQueue = new LinkedList<>();
+
+ public synchronized void onRequirementStatusChanged() {
+ notifyAll();
+ }
+
+ public synchronized void add(Job job) {
+ jobQueue.add(job);
+ notifyAll();
+ }
+
+ public synchronized void addAll(List jobs) {
+ jobQueue.addAll(jobs);
+ notifyAll();
+ }
+
+ public synchronized Job getNext() {
+ try {
+ Job nextAvailableJob;
+
+ while ((nextAvailableJob = getNextAvailableJob()) == null) {
+ wait();
+ }
+
+ return nextAvailableJob;
+ } catch (InterruptedException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private Job getNextAvailableJob() {
+ if (jobQueue.isEmpty()) return null;
+
+ ListIterator iterator = jobQueue.listIterator();
+ while (iterator.hasNext()) {
+ Job job = iterator.next();
+
+ if (job.isRequirementsMet()) {
+ iterator.remove();
+ return job;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/dependencies/ContextDependent.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/dependencies/ContextDependent.java
new file mode 100644
index 0000000000..0cdc91abe3
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/dependencies/ContextDependent.java
@@ -0,0 +1,7 @@
+package org.whispersystems.jobqueue.dependencies;
+
+import android.content.Context;
+
+public interface ContextDependent {
+ public void setContext(Context context);
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JavaJobSerializer.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JavaJobSerializer.java
new file mode 100644
index 0000000000..761ef6117d
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JavaJobSerializer.java
@@ -0,0 +1,59 @@
+package org.whispersystems.jobqueue.persistence;
+
+import android.content.Context;
+import android.util.Base64;
+
+import org.whispersystems.jobqueue.EncryptionKeys;
+import org.whispersystems.jobqueue.Job;
+import org.whispersystems.jobqueue.dependencies.ContextDependent;
+import org.whispersystems.jobqueue.requirements.Requirement;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+public class JavaJobSerializer implements JobSerializer {
+
+ private final Context context;
+
+ public JavaJobSerializer(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public String serialize(Job job) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(baos);
+ oos.writeObject(job);
+
+ return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
+ }
+
+ @Override
+ public Job deserialize(EncryptionKeys keys, boolean encrypted, String serialized) throws IOException {
+ try {
+ ByteArrayInputStream bais = new ByteArrayInputStream(Base64.decode(serialized, Base64.NO_WRAP));
+ ObjectInputStream ois = new ObjectInputStream(bais);
+
+ Job job = (Job)ois.readObject();
+
+ if (job instanceof ContextDependent) {
+ ((ContextDependent)job).setContext(context);
+ }
+
+ for (Requirement requirement : job.getRequirements()) {
+ if (requirement instanceof ContextDependent) {
+ ((ContextDependent)requirement).setContext(context);
+ }
+ }
+
+ job.setEncryptionKeys(keys);
+
+ return job;
+ } catch (ClassNotFoundException e) {
+ throw new IOException(e);
+ }
+ }
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JobSerializer.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JobSerializer.java
new file mode 100644
index 0000000000..dea6cacf3d
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/JobSerializer.java
@@ -0,0 +1,13 @@
+package org.whispersystems.jobqueue.persistence;
+
+import org.whispersystems.jobqueue.EncryptionKeys;
+import org.whispersystems.jobqueue.Job;
+
+import java.io.IOException;
+
+public interface JobSerializer {
+
+ public String serialize(Job job) throws IOException;
+ public Job deserialize(EncryptionKeys keys, boolean encrypted, String serialized) throws IOException;
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/PersistentStorage.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/PersistentStorage.java
new file mode 100644
index 0000000000..9e99882b5e
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/persistence/PersistentStorage.java
@@ -0,0 +1,112 @@
+package org.whispersystems.jobqueue.persistence;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.util.Log;
+
+import org.whispersystems.jobqueue.EncryptionKeys;
+import org.whispersystems.jobqueue.Job;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+
+public class PersistentStorage {
+
+ private static final int DATABASE_VERSION = 1;
+
+ private static final String TABLE_NAME = "queue";
+ private static final String ID = "_id";
+ private static final String ITEM = "item";
+ private static final String ENCRYPTED = "encrypted";
+
+ private static final String DATABASE_CREATE = String.format("CREATE TABLE %s (%s INTEGER PRIMARY KEY, %s TEXT NOT NULL, %s INTEGER DEFAULT 0);",
+ TABLE_NAME, ID, ITEM, ENCRYPTED);
+
+ private final DatabaseHelper databaseHelper;
+ private final JobSerializer jobSerializer;
+
+ public PersistentStorage(Context context, String name, JobSerializer serializer) {
+ this.databaseHelper = new DatabaseHelper(context, "_jobqueue-" + name);
+ this.jobSerializer = serializer;
+ }
+
+ public void store(Job job) throws IOException {
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(ITEM, jobSerializer.serialize(job));
+ contentValues.put(ENCRYPTED, job.getEncryptionKeys() != null);
+
+ long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues);
+ job.setPersistentId(id);
+ }
+
+// public List getAll(EncryptionKeys keys) {
+// return getJobs(keys, null);
+// }
+
+ public List getAllUnencrypted() {
+ return getJobs(null, ENCRYPTED + " = 0");
+ }
+
+ public List getAllEncrypted(EncryptionKeys keys) {
+ return getJobs(keys, ENCRYPTED + " = 1");
+ }
+
+ private List getJobs(EncryptionKeys keys, String where) {
+ List results = new LinkedList<>();
+ SQLiteDatabase database = databaseHelper.getReadableDatabase();
+ Cursor cursor = null;
+
+ try {
+ cursor = database.query(TABLE_NAME, null, where, null, null, null, ID + " ASC", null);
+
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
+ String item = cursor.getString(cursor.getColumnIndexOrThrow(ITEM));
+ boolean encrypted = cursor.getInt(cursor.getColumnIndexOrThrow(ENCRYPTED)) == 1;
+
+ try{
+ Job job = jobSerializer.deserialize(keys, encrypted, item);
+
+ job.setPersistentId(id);
+ results.add(job);
+ } catch (IOException e) {
+ Log.w("PersistentStore", e);
+ remove(id);
+ }
+ }
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ return results;
+ }
+
+
+ public void remove(long id) {
+ databaseHelper.getWritableDatabase()
+ .delete(TABLE_NAME, ID + " = ?", new String[] {String.valueOf(id)});
+ }
+
+ private static class DatabaseHelper extends SQLiteOpenHelper {
+
+ public DatabaseHelper(Context context, String name) {
+ super(context, name, null, DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(DATABASE_CREATE);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+
+ }
+ }
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirement.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirement.java
new file mode 100644
index 0000000000..1f1f7c3be8
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirement.java
@@ -0,0 +1,31 @@
+package org.whispersystems.jobqueue.requirements;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+
+import org.whispersystems.jobqueue.dependencies.ContextDependent;
+
+public class NetworkRequirement implements Requirement, ContextDependent {
+
+ private transient Context context;
+
+ public NetworkRequirement(Context context) {
+ this.context = context;
+ }
+
+ public NetworkRequirement() {}
+
+ @Override
+ public boolean isPresent() {
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo netInfo = cm.getActiveNetworkInfo();
+
+ return netInfo != null && netInfo.isConnectedOrConnecting();
+ }
+
+ @Override
+ public void setContext(Context context) {
+ this.context = context;
+ }
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirementProvider.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirementProvider.java
new file mode 100644
index 0000000000..c16863568d
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/NetworkRequirementProvider.java
@@ -0,0 +1,38 @@
+package org.whispersystems.jobqueue.requirements;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+
+public class NetworkRequirementProvider implements RequirementProvider {
+
+ private RequirementListener listener;
+
+ public NetworkRequirementProvider(Context context) {
+ context.getApplicationContext().registerReceiver(new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (listener == null) {
+ return;
+ }
+
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo netInfo = cm.getActiveNetworkInfo();
+ boolean connected = netInfo != null && netInfo.isConnectedOrConnecting();
+
+ if (connected) {
+ listener.onRequirementStatusChanged();
+ }
+ }
+ }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
+ }
+
+ @Override
+ public void setListener(RequirementListener listener) {
+ this.listener = listener;
+ }
+
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/Requirement.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/Requirement.java
new file mode 100644
index 0000000000..22e7a40bc0
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/Requirement.java
@@ -0,0 +1,7 @@
+package org.whispersystems.jobqueue.requirements;
+
+import java.io.Serializable;
+
+public interface Requirement extends Serializable {
+ public boolean isPresent();
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementListener.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementListener.java
new file mode 100644
index 0000000000..34460d6f85
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementListener.java
@@ -0,0 +1,5 @@
+package org.whispersystems.jobqueue.requirements;
+
+public interface RequirementListener {
+ public void onRequirementStatusChanged();
+}
diff --git a/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementProvider.java b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementProvider.java
new file mode 100644
index 0000000000..caaddd1972
--- /dev/null
+++ b/jobqueue/src/main/java/org/whispersystems/jobqueue/requirements/RequirementProvider.java
@@ -0,0 +1,5 @@
+package org.whispersystems.jobqueue.requirements;
+
+public interface RequirementProvider {
+ public void setListener(RequirementListener listener);
+}
diff --git a/jobqueue/src/main/res/values/strings.xml b/jobqueue/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..8542005550
--- /dev/null
+++ b/jobqueue/src/main/res/values/strings.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/settings.gradle b/settings.gradle
index 7caf8cfc8f..beb0a7fce8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':library', ':libaxolotl'
+include ':library', ':libaxolotl', ':jobqueue'