Move all files to natural position.

This commit is contained in:
Alan Evans
2020-01-06 10:52:48 -05:00
parent 0df36047e7
commit 9ebe920195
3016 changed files with 6 additions and 36 deletions

View File

@@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.AlarmManager;
import android.app.Application;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import java.util.List;
import java.util.UUID;
/**
* Schedules tasks using the {@link AlarmManager}.
*
* Given that this scheduler is only used when {@link KeepAliveService} is also used (which keeps
* all of the {@link ConstraintObserver}s running), this only needs to schedule future runs in
* situations where all constraints are already met. Otherwise, the {@link ConstraintObserver}s will
* trigger future runs when the constraints are met.
*
* For the same reason, this class also doesn't have to schedule jobs that don't have delays.
*
* Important: Only use on API < 26.
*/
public class AlarmManagerScheduler implements Scheduler {
private static final String TAG = AlarmManagerScheduler.class.getSimpleName();
private final Application application;
AlarmManagerScheduler(@NonNull Application application) {
this.application = application;
}
@Override
public void schedule(long delay, @NonNull List<Constraint> constraints) {
if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) {
setUniqueAlarm(application, System.currentTimeMillis() + delay);
}
}
private void setUniqueAlarm(@NonNull Context context, long time) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent intent = new Intent(context, RetryReceiver.class);
intent.setAction(BuildConfig.APPLICATION_ID + UUID.randomUUID().toString());
alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, 0));
Log.i(TAG, "Set an alarm to retry a job in " + (time - System.currentTimeMillis()) + " ms.");
}
public static class RetryReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Log.i(TAG, "Received an alarm to retry a job.");
ApplicationDependencies.getJobManager().wakeUp();
}
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.jobmanager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.thoughtcrime.securesms.logging.Log;
public class BootReceiver extends BroadcastReceiver {
private static final String TAG = BootReceiver.class.getSimpleName();
@Override
public void onReceive(Context context, Intent intent) {
Log.i(TAG, "Boot received. Application is created, kickstarting JobManager.");
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import java.util.Arrays;
import java.util.List;
class CompositeScheduler implements Scheduler {
private final List<Scheduler> schedulers;
CompositeScheduler(@NonNull Scheduler... schedulers) {
this.schedulers = Arrays.asList(schedulers);
}
@Override
public void schedule(long delay, @NonNull List<Constraint> constraints) {
for (Scheduler scheduler : schedulers) {
scheduler.schedule(delay, constraints);
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.job.JobInfo;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
public interface Constraint {
boolean isMet();
@NonNull String getFactoryKey();
@RequiresApi(26)
void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder);
interface Factory<T extends Constraint> {
T create();
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
public class ConstraintInstantiator {
private final Map<String, Constraint.Factory> constraintFactories;
ConstraintInstantiator(@NonNull Map<String, Constraint.Factory> constraintFactories) {
this.constraintFactories = new HashMap<>(constraintFactories);
}
public @NonNull Constraint instantiate(@NonNull String constraintFactoryKey) {
if (constraintFactories.containsKey(constraintFactoryKey)) {
return constraintFactories.get(constraintFactoryKey).create();
} else {
throw new IllegalStateException("Tried to instantiate a constraint with key '" + constraintFactoryKey + "', but no matching factory was found.");
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
public interface ConstraintObserver {
void register(@NonNull Notifier notifier);
interface Notifier {
void onConstraintMet(@NonNull String reason);
}
}

View File

@@ -0,0 +1,307 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.HashMap;
import java.util.Map;
public class Data {
public static final Data EMPTY = new Data.Builder().build();
@JsonProperty private final Map<String, String> strings;
@JsonProperty private final Map<String, String[]> stringArrays;
@JsonProperty private final Map<String, Integer> integers;
@JsonProperty private final Map<String, int[]> integerArrays;
@JsonProperty private final Map<String, Long> longs;
@JsonProperty private final Map<String, long[]> longArrays;
@JsonProperty private final Map<String, Float> floats;
@JsonProperty private final Map<String, float[]> floatArrays;
@JsonProperty private final Map<String, Double> doubles;
@JsonProperty private final Map<String, double[]> doubleArrays;
@JsonProperty private final Map<String, Boolean> booleans;
@JsonProperty private final Map<String, boolean[]> booleanArrays;
public Data(@JsonProperty("strings") @NonNull Map<String, String> strings,
@JsonProperty("stringArrays") @NonNull Map<String, String[]> stringArrays,
@JsonProperty("integers") @NonNull Map<String, Integer> integers,
@JsonProperty("integerArrays") @NonNull Map<String, int[]> integerArrays,
@JsonProperty("longs") @NonNull Map<String, Long> longs,
@JsonProperty("longArrays") @NonNull Map<String, long[]> longArrays,
@JsonProperty("floats") @NonNull Map<String, Float> floats,
@JsonProperty("floatArrays") @NonNull Map<String, float[]> floatArrays,
@JsonProperty("doubles") @NonNull Map<String, Double> doubles,
@JsonProperty("doubleArrays") @NonNull Map<String, double[]> doubleArrays,
@JsonProperty("booleans") @NonNull Map<String, Boolean> booleans,
@JsonProperty("booleanArrays") @NonNull Map<String, boolean[]> booleanArrays)
{
this.strings = strings;
this.stringArrays = stringArrays;
this.integers = integers;
this.integerArrays = integerArrays;
this.longs = longs;
this.longArrays = longArrays;
this.floats = floats;
this.floatArrays = floatArrays;
this.doubles = doubles;
this.doubleArrays = doubleArrays;
this.booleans = booleans;
this.booleanArrays = booleanArrays;
}
public boolean hasString(@NonNull String key) {
return strings.containsKey(key);
}
public String getString(@NonNull String key) {
throwIfAbsent(strings, key);
return strings.get(key);
}
public String getStringOrDefault(@NonNull String key, String defaultValue) {
if (hasString(key)) return getString(key);
else return defaultValue;
}
public boolean hasStringArray(@NonNull String key) {
return stringArrays.containsKey(key);
}
public String[] getStringArray(@NonNull String key) {
throwIfAbsent(stringArrays, key);
return stringArrays.get(key);
}
public boolean hasInt(@NonNull String key) {
return integers.containsKey(key);
}
public int getInt(@NonNull String key) {
throwIfAbsent(integers, key);
return integers.get(key);
}
public int getIntOrDefault(@NonNull String key, int defaultValue) {
if (hasInt(key)) return getInt(key);
else return defaultValue;
}
public boolean hasIntegerArray(@NonNull String key) {
return integerArrays.containsKey(key);
}
public int[] getIntegerArray(@NonNull String key) {
throwIfAbsent(integerArrays, key);
return integerArrays.get(key);
}
public boolean hasLong(@NonNull String key) {
return longs.containsKey(key);
}
public long getLong(@NonNull String key) {
throwIfAbsent(longs, key);
return longs.get(key);
}
public long getLongOrDefault(@NonNull String key, long defaultValue) {
if (hasLong(key)) return getLong(key);
else return defaultValue;
}
public boolean hasLongArray(@NonNull String key) {
return longArrays.containsKey(key);
}
public long[] getLongArray(@NonNull String key) {
throwIfAbsent(longArrays, key);
return longArrays.get(key);
}
public boolean hasFloat(@NonNull String key) {
return floats.containsKey(key);
}
public float getFloat(@NonNull String key) {
throwIfAbsent(floats, key);
return floats.get(key);
}
public float getFloatOrDefault(@NonNull String key, float defaultValue) {
if (hasFloat(key)) return getFloat(key);
else return defaultValue;
}
public boolean hasFloatArray(@NonNull String key) {
return floatArrays.containsKey(key);
}
public float[] getFloatArray(@NonNull String key) {
throwIfAbsent(floatArrays, key);
return floatArrays.get(key);
}
public boolean hasDouble(@NonNull String key) {
return doubles.containsKey(key);
}
public double getDouble(@NonNull String key) {
throwIfAbsent(doubles, key);
return doubles.get(key);
}
public double getDoubleOrDefault(@NonNull String key, double defaultValue) {
if (hasDouble(key)) return getDouble(key);
else return defaultValue;
}
public boolean hasDoubleArray(@NonNull String key) {
return floatArrays.containsKey(key);
}
public double[] getDoubleArray(@NonNull String key) {
throwIfAbsent(doubleArrays, key);
return doubleArrays.get(key);
}
public boolean hasBoolean(@NonNull String key) {
return booleans.containsKey(key);
}
public boolean getBoolean(@NonNull String key) {
throwIfAbsent(booleans, key);
return booleans.get(key);
}
public boolean getBooleanOrDefault(@NonNull String key, boolean defaultValue) {
if (hasBoolean(key)) return getBoolean(key);
else return defaultValue;
}
public boolean hasBooleanArray(@NonNull String key) {
return booleanArrays.containsKey(key);
}
public boolean[] getBooleanArray(@NonNull String key) {
throwIfAbsent(booleanArrays, key);
return booleanArrays.get(key);
}
private void throwIfAbsent(@NonNull Map map, @NonNull String key) {
if (!map.containsKey(key)) {
throw new IllegalStateException("Tried to retrieve a value with key '" + key + "', but it wasn't present.");
}
}
public static class Builder {
private final Map<String, String> strings = new HashMap<>();
private final Map<String, String[]> stringArrays = new HashMap<>();
private final Map<String, Integer> integers = new HashMap<>();
private final Map<String, int[]> integerArrays = new HashMap<>();
private final Map<String, Long> longs = new HashMap<>();
private final Map<String, long[]> longArrays = new HashMap<>();
private final Map<String, Float> floats = new HashMap<>();
private final Map<String, float[]> floatArrays = new HashMap<>();
private final Map<String, Double> doubles = new HashMap<>();
private final Map<String, double[]> doubleArrays = new HashMap<>();
private final Map<String, Boolean> booleans = new HashMap<>();
private final Map<String, boolean[]> booleanArrays = new HashMap<>();
public Builder putString(@NonNull String key, @Nullable String value) {
strings.put(key, value);
return this;
}
public Builder putStringArray(@NonNull String key, @NonNull String[] value) {
stringArrays.put(key, value);
return this;
}
public Builder putInt(@NonNull String key, int value) {
integers.put(key, value);
return this;
}
public Builder putIntArray(@NonNull String key, @NonNull int[] value) {
integerArrays.put(key, value);
return this;
}
public Builder putLong(@NonNull String key, long value) {
longs.put(key, value);
return this;
}
public Builder putLongArray(@NonNull String key, @NonNull long[] value) {
longArrays.put(key, value);
return this;
}
public Builder putFloat(@NonNull String key, float value) {
floats.put(key, value);
return this;
}
public Builder putFloatArray(@NonNull String key, @NonNull float[] value) {
floatArrays.put(key, value);
return this;
}
public Builder putDouble(@NonNull String key, double value) {
doubles.put(key, value);
return this;
}
public Builder putDoubleArray(@NonNull String key, @NonNull double[] value) {
doubleArrays.put(key, value);
return this;
}
public Builder putBoolean(@NonNull String key, boolean value) {
booleans.put(key, value);
return this;
}
public Builder putBooleanArray(@NonNull String key, @NonNull boolean[] value) {
booleanArrays.put(key, value);
return this;
}
public Data build() {
return new Data(strings,
stringArrays,
integers,
integerArrays,
longs,
longArrays,
floats,
floatArrays,
doubles,
doubleArrays,
booleans,
booleanArrays);
}
}
public interface Serializer {
@NonNull String serialize(@NonNull Data data);
@NonNull Data deserialize(@NonNull String serialized);
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (C) 2014 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.jobmanager;
/**
* Interface responsible for injecting dependencies into Jobs.
*/
public interface DependencyInjector {
void injectDependencies(Object object);
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import java.util.concurrent.ExecutorService;
public interface ExecutorFactory {
@NonNull ExecutorService newSingleThreadExecutor(@NonNull String name);
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.jobmanager;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.logging.Log;
import java.util.List;
/**
* Schedules future runs on an in-app handler. Intended to be used in combination with a persistent
* {@link Scheduler} to improve responsiveness when the app is open.
*
* This should only schedule runs when all constraints are met. Because this only works when the
* app is foregrounded, jobs that don't have their constraints met will be run when the relevant
* {@link ConstraintObserver} is triggered.
*
* Similarly, this does not need to schedule retries with no delay, as this doesn't provide any
* persistence, and other mechanisms will take care of that.
*/
class InAppScheduler implements Scheduler {
private static final String TAG = InAppScheduler.class.getSimpleName();
private final JobManager jobManager;
private final Handler handler;
InAppScheduler(@NonNull JobManager jobManager) {
HandlerThread handlerThread = new HandlerThread("InAppScheduler");
handlerThread.start();
this.jobManager = jobManager;
this.handler = new Handler(handlerThread.getLooper());
}
@Override
public void schedule(long delay, @NonNull List<Constraint> constraints) {
if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) {
Log.i(TAG, "Scheduling a retry in " + delay + " ms.");
handler.postDelayed(() -> {
Log.i(TAG, "Triggering a job retry.");
jobManager.wakeUp();
}, delay);
}
}
}

View File

@@ -0,0 +1,385 @@
package org.thoughtcrime.securesms.jobmanager;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.logging.Log;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* A durable unit of work.
*
* Jobs have {@link Parameters} that describe the conditions upon when you'd like them to run, how
* often they should be retried, and how long they should be retried for.
*
* Never rely on a specific instance of this class being run. It can be created and destroyed as the
* job is retried. State that you want to save is persisted to a {@link Data} object in
* {@link #serialize()}. Your job is then recreated using a {@link Factory} that you register in
* {@link JobManager.Configuration.Builder#setJobFactories(Map)}, which is given the saved
* {@link Data} bundle.
*/
public abstract class Job {
private static final String TAG = Log.tag(Job.class);
private final Parameters parameters;
private int runAttempt;
private long nextRunAttemptTime;
protected Context context;
public Job(@NonNull Parameters parameters) {
this.parameters = parameters;
}
public final @NonNull String getId() {
return parameters.getId();
}
public final @NonNull Parameters getParameters() {
return parameters;
}
public final int getRunAttempt() {
return runAttempt;
}
public final long getNextRunAttemptTime() {
return nextRunAttemptTime;
}
/**
* This is already called by {@link JobController} during job submission, but if you ever run a
* job without submitting it to the {@link JobManager}, then you'll need to invoke this yourself.
*/
public final void setContext(@NonNull Context context) {
this.context = context;
}
/** Should only be invoked by {@link JobController} */
final void setRunAttempt(int runAttempt) {
this.runAttempt = runAttempt;
}
/** Should only be invoked by {@link JobController} */
final void setNextRunAttemptTime(long nextRunAttemptTime) {
this.nextRunAttemptTime = nextRunAttemptTime;
}
@WorkerThread
final void onSubmit() {
Log.i(TAG, JobLogger.format(this, "onSubmit()"));
onAdded();
}
/**
* Called when the job is first submitted to the {@link JobManager}.
*/
@WorkerThread
public void onAdded() {
}
/**
* Called after a job has run and its determined that a retry is required.
*/
@WorkerThread
public void onRetry() {
}
/**
* Serialize your job state so that it can be recreated in the future.
*/
public abstract @NonNull Data serialize();
/**
* Returns the key that can be used to find the relevant factory needed to create your job.
*/
public abstract @NonNull String getFactoryKey();
/**
* Called to do your actual work.
*/
@WorkerThread
public abstract @NonNull Result run();
/**
* Called when your job has completely failed.
*/
@WorkerThread
public abstract void onCanceled();
public interface Factory<T extends Job> {
@NonNull T create(@NonNull Parameters parameters, @NonNull Data data);
}
public static final class Result {
private static final Result SUCCESS = new Result(ResultType.SUCCESS, null);
private static final Result RETRY = new Result(ResultType.RETRY, null);
private static final Result FAILURE = new Result(ResultType.FAILURE, null);
private final ResultType resultType;
private final RuntimeException runtimeException;
private Result(@NonNull ResultType resultType, @Nullable RuntimeException runtimeException) {
this.resultType = resultType;
this.runtimeException = runtimeException;
}
/** Job completed successfully. */
public static Result success() {
return SUCCESS;
}
/** Job did not complete successfully, but it can be retried later. */
public static Result retry() {
return RETRY;
}
/** Job did not complete successfully and should not be tried again. Dependent jobs will also be failed.*/
public static Result failure() {
return FAILURE;
}
/** Same as {@link #failure()}, except the app should also crash with the provided exception. */
public static Result fatalFailure(@NonNull RuntimeException runtimeException) {
return new Result(ResultType.FAILURE, runtimeException);
}
boolean isSuccess() {
return resultType == ResultType.SUCCESS;
}
boolean isRetry() {
return resultType == ResultType.RETRY;
}
boolean isFailure() {
return resultType == ResultType.FAILURE;
}
@Nullable RuntimeException getException() {
return runtimeException;
}
@Override
public @NonNull String toString() {
switch (resultType) {
case SUCCESS:
case RETRY:
return resultType.toString();
case FAILURE:
if (runtimeException == null) {
return resultType.toString();
} else {
return "FATAL_FAILURE";
}
}
return "UNKNOWN?";
}
private enum ResultType {
SUCCESS, FAILURE, RETRY
}
}
public static final class Parameters {
public static final String MIGRATION_QUEUE_KEY = "MIGRATION";
public static final int IMMORTAL = -1;
public static final int UNLIMITED = -1;
private final String id;
private final long createTime;
private final long lifespan;
private final int maxAttempts;
private final long maxBackoff;
private final int maxInstances;
private final String queue;
private final List<String> constraintKeys;
private Parameters(@NonNull String id,
long createTime,
long lifespan,
int maxAttempts,
long maxBackoff,
int maxInstances,
@Nullable String queue,
@NonNull List<String> constraintKeys)
{
this.id = id;
this.createTime = createTime;
this.lifespan = lifespan;
this.maxAttempts = maxAttempts;
this.maxBackoff = maxBackoff;
this.maxInstances = maxInstances;
this.queue = queue;
this.constraintKeys = constraintKeys;
}
@NonNull String getId() {
return id;
}
long getCreateTime() {
return createTime;
}
long getLifespan() {
return lifespan;
}
int getMaxAttempts() {
return maxAttempts;
}
long getMaxBackoff() {
return maxBackoff;
}
int getMaxInstances() {
return maxInstances;
}
public @Nullable String getQueue() {
return queue;
}
@NonNull List<String> getConstraintKeys() {
return constraintKeys;
}
public Builder toBuilder() {
return new Builder(id, createTime, maxBackoff, lifespan, maxAttempts, maxInstances, queue, constraintKeys);
}
public static final class Builder {
private String id;
private long createTime;
private long maxBackoff;
private long lifespan;
private int maxAttempts;
private int maxInstances;
private String queue;
private List<String> constraintKeys;
public Builder() {
this(UUID.randomUUID().toString());
}
Builder(@NonNull String id) {
this(id, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(30), IMMORTAL, 1, UNLIMITED, null, new LinkedList<>());
}
private Builder(@NonNull String id,
long createTime,
long maxBackoff,
long lifespan,
int maxAttempts,
int maxInstances,
@Nullable String queue,
@NonNull List<String> constraintKeys)
{
this.id = id;
this.createTime = createTime;
this.maxBackoff = maxBackoff;
this.lifespan = lifespan;
this.maxAttempts = maxAttempts;
this.maxInstances = maxInstances;
this.queue = queue;
this.constraintKeys = constraintKeys;
}
/** Should only be invoked by {@link JobController} */
Builder setCreateTime(long createTime) {
this.createTime = createTime;
return this;
}
/**
* Specify the amount of time this job is allowed to be retried. Defaults to {@link #IMMORTAL}.
*/
public @NonNull Builder setLifespan(long lifespan) {
this.lifespan = lifespan;
return this;
}
/**
* Specify the maximum number of times you want to attempt this job. Defaults to 1.
*/
public @NonNull Builder setMaxAttempts(int maxAttempts) {
this.maxAttempts = maxAttempts;
return this;
}
/**
* Specify the longest amount of time to wait between retries. No guarantees that this will
* be respected on API >= 26.
*/
public @NonNull Builder setMaxBackoff(long maxBackoff) {
this.maxBackoff = maxBackoff;
return this;
}
/**
* Specify the maximum number of instances you'd want of this job at any given time. If
* enqueueing this job would put it over that limit, it will be ignored.
*
* Duplicates are determined by two jobs having the same {@link Job#getFactoryKey()}.
*
* This property is ignored if the job is submitted as part of a {@link JobManager.Chain}.
*
* Defaults to {@link #UNLIMITED}.
*/
public @NonNull Builder setMaxInstances(int maxInstances) {
this.maxInstances = maxInstances;
return this;
}
/**
* Specify a string representing a queue. All jobs within the same queue are run in a
* serialized fashion -- one after the other, in order of insertion. Failure of a job earlier
* in the queue has no impact on the execution of jobs later in the queue.
*/
public @NonNull Builder setQueue(@Nullable String queue) {
this.queue = queue;
return this;
}
/**
* Add a constraint via the key that was used to register its factory in
* {@link JobManager.Configuration)};
*/
public @NonNull Builder addConstraint(@NonNull String constraintKey) {
constraintKeys.add(constraintKey);
return this;
}
/**
* Set constraints via the key that was used to register its factory in
* {@link JobManager.Configuration)};
*/
public @NonNull Builder setConstraints(@NonNull List<String> constraintKeys) {
this.constraintKeys.clear();
this.constraintKeys.addAll(constraintKeys);
return this;
}
public @NonNull Parameters build() {
return new Parameters(id, createTime, lifespan, maxAttempts, maxBackoff, maxInstances, queue, constraintKeys);
}
}
}
}

View File

@@ -0,0 +1,373 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.Debouncer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Manages the queue of jobs. This is the only class that should write to {@link JobStorage} to
* ensure consistency.
*/
class JobController {
private static final String TAG = JobController.class.getSimpleName();
private final Application application;
private final JobStorage jobStorage;
private final JobInstantiator jobInstantiator;
private final ConstraintInstantiator constraintInstantiator;
private final Data.Serializer dataSerializer;
private final JobTracker jobTracker;
private final Scheduler scheduler;
private final Debouncer debouncer;
private final Callback callback;
private final Set<String> runningJobs;
JobController(@NonNull Application application,
@NonNull JobStorage jobStorage,
@NonNull JobInstantiator jobInstantiator,
@NonNull ConstraintInstantiator constraintInstantiator,
@NonNull Data.Serializer dataSerializer,
@NonNull JobTracker jobTracker,
@NonNull Scheduler scheduler,
@NonNull Debouncer debouncer,
@NonNull Callback callback)
{
this.application = application;
this.jobStorage = jobStorage;
this.jobInstantiator = jobInstantiator;
this.constraintInstantiator = constraintInstantiator;
this.dataSerializer = dataSerializer;
this.jobTracker = jobTracker;
this.scheduler = scheduler;
this.debouncer = debouncer;
this.callback = callback;
this.runningJobs = new HashSet<>();
}
@WorkerThread
synchronized void init() {
jobStorage.updateAllJobsToBePending();
notifyAll();
}
synchronized void wakeUp() {
notifyAll();
}
@WorkerThread
synchronized void submitNewJobChain(@NonNull List<List<Job>> chain) {
chain = Stream.of(chain).filterNot(List::isEmpty).toList();
if (chain.isEmpty()) {
Log.w(TAG, "Tried to submit an empty job chain. Skipping.");
return;
}
if (chainExceedsMaximumInstances(chain)) {
Job solo = chain.get(0).get(0);
jobTracker.onStateChange(solo.getId(), JobTracker.JobState.IGNORED);
Log.w(TAG, JobLogger.format(solo, "Already at the max instance count of " + solo.getParameters().getMaxInstances() + ". Skipping."));
return;
}
insertJobChain(chain);
scheduleJobs(chain.get(0));
triggerOnSubmit(chain);
notifyAll();
}
@WorkerThread
synchronized void onRetry(@NonNull Job job) {
int nextRunAttempt = job.getRunAttempt() + 1;
long nextRunAttemptTime = calculateNextRunAttemptTime(System.currentTimeMillis(), nextRunAttempt, job.getParameters().getMaxBackoff());
String serializedData = dataSerializer.serialize(job.serialize());
jobStorage.updateJobAfterRetry(job.getId(), false, nextRunAttempt, nextRunAttemptTime, serializedData);
jobTracker.onStateChange(job.getId(), JobTracker.JobState.PENDING);
List<Constraint> constraints = Stream.of(jobStorage.getConstraintSpecs(job.getId()))
.map(ConstraintSpec::getFactoryKey)
.map(constraintInstantiator::instantiate)
.toList();
long delay = Math.max(0, nextRunAttemptTime - System.currentTimeMillis());
Log.i(TAG, JobLogger.format(job, "Scheduling a retry in " + delay + " ms."));
scheduler.schedule(delay, constraints);
notifyAll();
}
synchronized void onJobFinished(@NonNull Job job) {
runningJobs.remove(job.getId());
}
@WorkerThread
synchronized void onSuccess(@NonNull Job job) {
jobStorage.deleteJob(job.getId());
jobTracker.onStateChange(job.getId(), JobTracker.JobState.SUCCESS);
notifyAll();
}
/**
* @return The list of all dependent jobs that should also be failed.
*/
@WorkerThread
synchronized @NonNull List<Job> onFailure(@NonNull Job job) {
List<Job> dependents = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(job.getId()))
.map(DependencySpec::getJobId)
.map(jobStorage::getJobSpec)
.withoutNulls()
.map(jobSpec -> {
List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId());
return createJob(jobSpec, constraintSpecs);
})
.toList();
List<Job> all = new ArrayList<>(dependents.size() + 1);
all.add(job);
all.addAll(dependents);
jobStorage.deleteJobs(Stream.of(all).map(Job::getId).toList());
Stream.of(all).forEach(j -> jobTracker.onStateChange(j.getId(), JobTracker.JobState.FAILURE));
return dependents;
}
/**
* Retrieves the next job that is eligible for execution. To be 'eligible' means that the job:
* - Has no dependencies
* - Has no unmet constraints
*
* This method will block until a job is available.
* When the job returned from this method has been run, you must call {@link #onJobFinished(Job)}.
*/
@WorkerThread
synchronized @NonNull Job pullNextEligibleJobForExecution() {
try {
Job job;
while ((job = getNextEligibleJobForExecution()) == null) {
if (runningJobs.isEmpty()) {
debouncer.publish(callback::onEmpty);
}
wait();
}
jobStorage.updateJobRunningState(job.getId(), true);
runningJobs.add(job.getId());
jobTracker.onStateChange(job.getId(), JobTracker.JobState.RUNNING);
return job;
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted.");
throw new AssertionError(e);
}
}
/**
* Retrieves a string representing the state of the job queue. Intended for debugging.
*/
@WorkerThread
synchronized @NonNull String getDebugInfo() {
List<JobSpec> jobs = jobStorage.getAllJobSpecs();
List<ConstraintSpec> constraints = jobStorage.getAllConstraintSpecs();
List<DependencySpec> dependencies = jobStorage.getAllDependencySpecs();
StringBuilder info = new StringBuilder();
info.append("-- Jobs\n");
if (!jobs.isEmpty()) {
Stream.of(jobs).forEach(j -> info.append(j.toString()).append('\n'));
} else {
info.append("None\n");
}
info.append("\n-- Constraints\n");
if (!constraints.isEmpty()) {
Stream.of(constraints).forEach(c -> info.append(c.toString()).append('\n'));
} else {
info.append("None\n");
}
info.append("\n-- Dependencies\n");
if (!dependencies.isEmpty()) {
Stream.of(dependencies).forEach(d -> info.append(d.toString()).append('\n'));
} else {
info.append("None\n");
}
return info.toString();
}
@WorkerThread
private boolean chainExceedsMaximumInstances(@NonNull List<List<Job>> chain) {
if (chain.size() == 1 && chain.get(0).size() == 1) {
Job solo = chain.get(0).get(0);
if (solo.getParameters().getMaxInstances() != Job.Parameters.UNLIMITED &&
jobStorage.getJobInstanceCount(solo.getFactoryKey()) >= solo.getParameters().getMaxInstances())
{
return true;
}
}
return false;
}
@WorkerThread
private void triggerOnSubmit(@NonNull List<List<Job>> chain) {
Stream.of(chain)
.forEach(list -> Stream.of(list).forEach(job -> {
job.setContext(application);
job.onSubmit();
}));
}
@WorkerThread
private void insertJobChain(@NonNull List<List<Job>> chain) {
List<FullSpec> fullSpecs = new LinkedList<>();
List<Job> dependsOn = Collections.emptyList();
for (List<Job> jobList : chain) {
for (Job job : jobList) {
fullSpecs.add(buildFullSpec(job, dependsOn));
}
dependsOn = jobList;
}
jobStorage.insertJobs(fullSpecs);
}
@WorkerThread
private @NonNull FullSpec buildFullSpec(@NonNull Job job, @NonNull List<Job> dependsOn) {
job.setRunAttempt(0);
JobSpec jobSpec = new JobSpec(job.getId(),
job.getFactoryKey(),
job.getParameters().getQueue(),
System.currentTimeMillis(),
job.getNextRunAttemptTime(),
job.getRunAttempt(),
job.getParameters().getMaxAttempts(),
job.getParameters().getMaxBackoff(),
job.getParameters().getLifespan(),
job.getParameters().getMaxInstances(),
dataSerializer.serialize(job.serialize()),
false);
List<ConstraintSpec> constraintSpecs = Stream.of(job.getParameters().getConstraintKeys())
.map(key -> new ConstraintSpec(jobSpec.getId(), key))
.toList();
List<DependencySpec> dependencySpecs = Stream.of(dependsOn)
.map(depends -> new DependencySpec(job.getId(), depends.getId()))
.toList();
return new FullSpec(jobSpec, constraintSpecs, dependencySpecs);
}
@WorkerThread
private void scheduleJobs(@NonNull List<Job> jobs) {
for (Job job : jobs) {
List<Constraint> constraints = Stream.of(job.getParameters().getConstraintKeys())
.map(key -> new ConstraintSpec(job.getId(), key))
.map(ConstraintSpec::getFactoryKey)
.map(constraintInstantiator::instantiate)
.toList();
scheduler.schedule(0, constraints);
}
}
@WorkerThread
private @Nullable Job getNextEligibleJobForExecution() {
List<JobSpec> jobSpecs = jobStorage.getPendingJobsWithNoDependenciesInCreatedOrder(System.currentTimeMillis());
for (JobSpec jobSpec : jobSpecs) {
List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId());
List<Constraint> constraints = Stream.of(constraintSpecs)
.map(ConstraintSpec::getFactoryKey)
.map(constraintInstantiator::instantiate)
.toList();
if (Stream.of(constraints).allMatch(Constraint::isMet)) {
return createJob(jobSpec, constraintSpecs);
}
}
return null;
}
private @NonNull Job createJob(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) {
Job.Parameters parameters = buildJobParameters(jobSpec, constraintSpecs);
try {
Data data = dataSerializer.deserialize(jobSpec.getSerializedData());
Job job = jobInstantiator.instantiate(jobSpec.getFactoryKey(), parameters, data);
job.setRunAttempt(jobSpec.getRunAttempt());
job.setNextRunAttemptTime(jobSpec.getNextRunAttemptTime());
job.setContext(application);
return job;
} catch (RuntimeException e) {
Log.e(TAG, "Failed to instantiate job! Failing it and its dependencies without calling Job#onCanceled. Crash imminent.");
List<String> failIds = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(jobSpec.getId()))
.map(DependencySpec::getJobId)
.toList();
jobStorage.deleteJob(jobSpec.getId());
jobStorage.deleteJobs(failIds);
Log.e(TAG, "Failed " + failIds.size() + " dependent jobs.");
throw e;
}
}
private @NonNull Job.Parameters buildJobParameters(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) {
return new Job.Parameters.Builder(jobSpec.getId())
.setCreateTime(jobSpec.getCreateTime())
.setLifespan(jobSpec.getLifespan())
.setMaxAttempts(jobSpec.getMaxAttempts())
.setQueue(jobSpec.getQueueKey())
.setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList())
.setMaxBackoff(jobSpec.getMaxBackoff())
.build();
}
private long calculateNextRunAttemptTime(long currentTime, int nextAttempt, long maxBackoff) {
int boundedAttempt = Math.min(nextAttempt, 30);
long exponentialBackoff = (long) Math.pow(2, boundedAttempt) * 1000;
long actualBackoff = Math.min(exponentialBackoff, maxBackoff);
return currentTime + actualBackoff;
}
interface Callback {
void onEmpty();
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
class JobInstantiator {
private final Map<String, Job.Factory> jobFactories;
JobInstantiator(@NonNull Map<String, Job.Factory> jobFactories) {
this.jobFactories = new HashMap<>(jobFactories);
}
public @NonNull Job instantiate(@NonNull String jobFactoryKey, @NonNull Job.Parameters parameters, @NonNull Data data) {
if (jobFactories.containsKey(jobFactoryKey)) {
return jobFactories.get(jobFactoryKey).create(parameters, data);
} else {
throw new IllegalStateException("Tried to instantiate a job with key '" + jobFactoryKey + "', but no matching factory was found.");
}
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import java.util.Locale;
public class JobLogger {
public static String format(@NonNull Job job, @NonNull String event) {
return format(job, "", event);
}
public static String format(@NonNull Job job, @NonNull String extraTag, @NonNull String event) {
String id = job.getId();
String tag = TextUtils.isEmpty(extraTag) ? "" : "[" + extraTag + "]";
long timeSinceSubmission = System.currentTimeMillis() - job.getParameters().getCreateTime();
int runAttempt = job.getRunAttempt() + 1;
String maxAttempts = job.getParameters().getMaxAttempts() == Job.Parameters.UNLIMITED ? "Unlimited"
: String.valueOf(job.getParameters().getMaxAttempts());
String lifespan = job.getParameters().getLifespan() == Job.Parameters.IMMORTAL ? "Immortal"
: String.valueOf(job.getParameters().getLifespan()) + " ms";
return String.format(Locale.US,
"[%s][%s]%s %s (Time Since Submission: %d ms, Lifespan: %s, Run Attempt: %d/%s)",
"JOB::" + id, job.getClass().getSimpleName(), tag, event, timeSinceSubmission, lifespan, runAttempt, maxAttempts);
}
}

View File

@@ -0,0 +1,380 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.Application;
import android.content.Intent;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import org.thoughtcrime.securesms.jobmanager.impl.DefaultExecutorFactory;
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
import org.thoughtcrime.securesms.jobmanager.workmanager.WorkManagerMigrator;
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
/**
* Allows the scheduling of durable jobs that will be run as early as possible.
*/
public class JobManager implements ConstraintObserver.Notifier {
private static final String TAG = JobManager.class.getSimpleName();
public static final int CURRENT_VERSION = 4;
private final Application application;
private final Configuration configuration;
private final ExecutorService executor;
private final JobController jobController;
private final JobTracker jobTracker;
private final Set<EmptyQueueListener> emptyQueueListeners = new CopyOnWriteArraySet<>();
public JobManager(@NonNull Application application, @NonNull Configuration configuration) {
this.application = application;
this.configuration = configuration;
this.executor = configuration.getExecutorFactory().newSingleThreadExecutor("signal-JobManager");
this.jobTracker = configuration.getJobTracker();
this.jobController = new JobController(application,
configuration.getJobStorage(),
configuration.getJobInstantiator(),
configuration.getConstraintFactories(),
configuration.getDataSerializer(),
configuration.getJobTracker(),
Build.VERSION.SDK_INT < 26 ? new AlarmManagerScheduler(application)
: new CompositeScheduler(new InAppScheduler(this), new JobSchedulerScheduler(application)),
new Debouncer(500),
this::onEmptyQueue);
executor.execute(() -> {
if (WorkManagerMigrator.needsMigration(application)) {
Log.i(TAG, "Detected an old WorkManager database. Migrating.");
WorkManagerMigrator.migrate(application, configuration.getJobStorage(), configuration.getDataSerializer());
}
JobStorage jobStorage = configuration.getJobStorage();
jobStorage.init();
int latestVersion = configuration.getJobMigrator().migrate(jobStorage, configuration.getDataSerializer());
TextSecurePreferences.setJobManagerVersion(application, latestVersion);
jobController.init();
for (ConstraintObserver constraintObserver : configuration.getConstraintObservers()) {
constraintObserver.register(this);
}
if (Build.VERSION.SDK_INT < 26) {
application.startService(new Intent(application, KeepAliveService.class));
}
});
}
/**
* Begins the execution of jobs.
*/
public void beginJobLoop() {
executor.execute(() -> {
for (int i = 0; i < configuration.getJobThreadCount(); i++) {
new JobRunner(application, i + 1, jobController).start();
}
wakeUp();
});
}
/**
* Add a listener to subscribe to job state updates. Listeners will be invoked on an arbitrary
* background thread. You must eventually call {@link #removeListener(JobTracker.JobListener)} to avoid
* memory leaks.
*/
public void addListener(@NonNull String id, @NonNull JobTracker.JobListener listener) {
jobTracker.addListener(id, listener);
}
/**
* Unsubscribe the provided listener from all job updates.
*/
public void removeListener(@NonNull JobTracker.JobListener listener) {
jobTracker.removeListener(listener);
}
/**
* Enqueues a single job to be run.
*/
public void add(@NonNull Job job) {
new Chain(this, Collections.singletonList(job)).enqueue();
}
/**
* Begins the creation of a job chain with a single job.
* @see Chain
*/
public Chain startChain(@NonNull Job job) {
return new Chain(this, Collections.singletonList(job));
}
/**
* Begins the creation of a job chain with a set of jobs that can be run in parallel.
* @see Chain
*/
public Chain startChain(@NonNull List<? extends Job> jobs) {
return new Chain(this, jobs);
}
/**
* Retrieves a string representing the state of the job queue. Intended for debugging.
*/
public @NonNull String getDebugInfo() {
Future<String> result = executor.submit(jobController::getDebugInfo);
try {
return result.get();
} catch (ExecutionException | InterruptedException e) {
Log.w(TAG, "Failed to retrieve Job info.", e);
return "Failed to retrieve Job info.";
}
}
/**
* Adds a listener that will be notified when the job queue has been drained.
*/
void addOnEmptyQueueListener(@NonNull EmptyQueueListener listener) {
executor.execute(() -> {
emptyQueueListeners.add(listener);
});
}
/**
* Removes a listener that was added via {@link #addOnEmptyQueueListener(EmptyQueueListener)}.
*/
void removeOnEmptyQueueListener(@NonNull EmptyQueueListener listener) {
executor.execute(() -> {
emptyQueueListeners.remove(listener);
});
}
@Override
public void onConstraintMet(@NonNull String reason) {
Log.i(TAG, "onConstraintMet(" + reason + ")");
wakeUp();
}
/**
* Pokes the system to take another pass at the job queue.
*/
void wakeUp() {
executor.execute(jobController::wakeUp);
}
private void enqueueChain(@NonNull Chain chain) {
for (List<Job> jobList : chain.getJobListChain()) {
for (Job job : jobList) {
jobTracker.onStateChange(job.getId(), JobTracker.JobState.PENDING);
}
}
executor.execute(() -> {
jobController.submitNewJobChain(chain.getJobListChain());
wakeUp();
});
}
private void onEmptyQueue() {
executor.execute(() -> {
for (EmptyQueueListener listener : emptyQueueListeners) {
listener.onQueueEmpty();
}
});
}
public interface EmptyQueueListener {
void onQueueEmpty();
}
/**
* Allows enqueuing work that depends on each other. Jobs that appear later in the chain will
* only run after all jobs earlier in the chain have been completed. If a job fails, all jobs
* that occur later in the chain will also be failed.
*/
public static class Chain {
private final JobManager jobManager;
private final List<List<Job>> jobs;
private Chain(@NonNull JobManager jobManager, @NonNull List<? extends Job> jobs) {
this.jobManager = jobManager;
this.jobs = new LinkedList<>();
this.jobs.add(new ArrayList<>(jobs));
}
public Chain then(@NonNull Job job) {
return then(Collections.singletonList(job));
}
public Chain then(@NonNull List<? extends Job> jobs) {
if (!jobs.isEmpty()) {
this.jobs.add(new ArrayList<>(jobs));
}
return this;
}
public void enqueue() {
jobManager.enqueueChain(this);
}
private List<List<Job>> getJobListChain() {
return jobs;
}
}
public static class Configuration {
private final ExecutorFactory executorFactory;
private final int jobThreadCount;
private final JobInstantiator jobInstantiator;
private final ConstraintInstantiator constraintInstantiator;
private final List<ConstraintObserver> constraintObservers;
private final Data.Serializer dataSerializer;
private final JobStorage jobStorage;
private final JobMigrator jobMigrator;
private final JobTracker jobTracker;
private Configuration(int jobThreadCount,
@NonNull ExecutorFactory executorFactory,
@NonNull JobInstantiator jobInstantiator,
@NonNull ConstraintInstantiator constraintInstantiator,
@NonNull List<ConstraintObserver> constraintObservers,
@NonNull Data.Serializer dataSerializer,
@NonNull JobStorage jobStorage,
@NonNull JobMigrator jobMigrator,
@NonNull JobTracker jobTracker)
{
this.executorFactory = executorFactory;
this.jobThreadCount = jobThreadCount;
this.jobInstantiator = jobInstantiator;
this.constraintInstantiator = constraintInstantiator;
this.constraintObservers = constraintObservers;
this.dataSerializer = dataSerializer;
this.jobStorage = jobStorage;
this.jobMigrator = jobMigrator;
this.jobTracker = jobTracker;
}
int getJobThreadCount() {
return jobThreadCount;
}
@NonNull ExecutorFactory getExecutorFactory() {
return executorFactory;
}
@NonNull JobInstantiator getJobInstantiator() {
return jobInstantiator;
}
@NonNull
ConstraintInstantiator getConstraintFactories() {
return constraintInstantiator;
}
@NonNull List<ConstraintObserver> getConstraintObservers() {
return constraintObservers;
}
@NonNull Data.Serializer getDataSerializer() {
return dataSerializer;
}
@NonNull JobStorage getJobStorage() {
return jobStorage;
}
@NonNull JobMigrator getJobMigrator() {
return jobMigrator;
}
@NonNull JobTracker getJobTracker() {
return jobTracker;
}
public static class Builder {
private ExecutorFactory executorFactory = new DefaultExecutorFactory();
private int jobThreadCount = Math.max(2, Math.min(Runtime.getRuntime().availableProcessors() - 1, 4));
private Map<String, Job.Factory> jobFactories = new HashMap<>();
private Map<String, Constraint.Factory> constraintFactories = new HashMap<>();
private List<ConstraintObserver> constraintObservers = new ArrayList<>();
private Data.Serializer dataSerializer = new JsonDataSerializer();
private JobStorage jobStorage = null;
private JobMigrator jobMigrator = null;
private JobTracker jobTracker = new JobTracker();
public @NonNull Builder setJobThreadCount(int jobThreadCount) {
this.jobThreadCount = jobThreadCount;
return this;
}
public @NonNull Builder setExecutorFactory(@NonNull ExecutorFactory executorFactory) {
this.executorFactory = executorFactory;
return this;
}
public @NonNull Builder setJobFactories(@NonNull Map<String, Job.Factory> jobFactories) {
this.jobFactories = jobFactories;
return this;
}
public @NonNull Builder setConstraintFactories(@NonNull Map<String, Constraint.Factory> constraintFactories) {
this.constraintFactories = constraintFactories;
return this;
}
public @NonNull Builder setConstraintObservers(@NonNull List<ConstraintObserver> constraintObservers) {
this.constraintObservers = constraintObservers;
return this;
}
public @NonNull Builder setDataSerializer(@NonNull Data.Serializer dataSerializer) {
this.dataSerializer = dataSerializer;
return this;
}
public @NonNull Builder setJobStorage(@NonNull JobStorage jobStorage) {
this.jobStorage = jobStorage;
return this;
}
public @NonNull Builder setJobMigrator(@NonNull JobMigrator jobMigrator) {
this.jobMigrator = jobMigrator;
return this;
}
public @NonNull Configuration build() {
return new Configuration(jobThreadCount,
executorFactory,
new JobInstantiator(jobFactories),
new ConstraintInstantiator(constraintFactories),
new ArrayList<>(constraintObservers),
dataSerializer,
jobStorage,
jobMigrator,
jobTracker);
}
}
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Create a subclass of this to perform a migration on persisted {@link Job}s. A migration targets
* a specific end version, and the assumption is that it can migrate jobs to that end version from
* the previous version. The class will be provided a bundle of job data for each persisted job and
* give back an updated version (if applicable).
*/
public abstract class JobMigration {
private final int endVersion;
protected JobMigration(int endVersion) {
this.endVersion = endVersion;
}
/**
* Given a bundle of job data, return a bundle of job data that should be used in place of it.
* You may obviously return the same object if you don't wish to change it.
*/
protected abstract @NonNull JobData migrate(@NonNull JobData jobData);
int getEndVersion() {
return endVersion;
}
public static class JobData {
private final String factoryKey;
private final String queueKey;
private final Data data;
public JobData(@NonNull String factoryKey, @Nullable String queueKey, @NonNull Data data) {
this.factoryKey = factoryKey;
this.queueKey = queueKey;
this.data = data;
}
public @NonNull JobData withFactoryKey(@NonNull String newFactoryKey) {
return new JobData(newFactoryKey, queueKey, data);
}
public @NonNull JobData withQueueKey(@Nullable String newQueueKey) {
return new JobData(factoryKey, newQueueKey, data);
}
public @NonNull JobData withData(@NonNull Data newData) {
return new JobData(factoryKey, queueKey, newData);
}
public @NonNull String getFactoryKey() {
return factoryKey;
}
public @Nullable String getQueueKey() {
return queueKey;
}
public @NonNull Data getData() {
return data;
}
}
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.jobmanager;
import android.annotation.SuppressLint;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.JobMigration.JobData;
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
@SuppressLint("UseSparseArrays")
public class JobMigrator {
private static final String TAG = Log.tag(JobMigrator.class);
private final int lastSeenVersion;
private final int currentVersion;
private final Map<Integer, JobMigration> migrations;
public JobMigrator(int lastSeenVersion, int currentVersion, @NonNull List<JobMigration> migrations) {
this.lastSeenVersion = lastSeenVersion;
this.currentVersion = currentVersion;
this.migrations = new HashMap<>();
if (migrations.size() != currentVersion - 1) {
throw new AssertionError("You must have a migration for every version!");
}
for (int i = 0; i < migrations.size(); i++) {
JobMigration migration = migrations.get(i);
if (migration.getEndVersion() != i + 2) {
throw new AssertionError("Missing migration for version " + (i + 2) + "!");
}
this.migrations.put(migration.getEndVersion(), migrations.get(i));
}
}
/**
* @return The version that has been migrated to.
*/
int migrate(@NonNull JobStorage jobStorage, @NonNull Data.Serializer dataSerializer) {
List<JobSpec> jobSpecs = jobStorage.getAllJobSpecs();
for (int i = lastSeenVersion; i < currentVersion; i++) {
Log.i(TAG, "Migrating from " + i + " to " + (i + 1));
ListIterator<JobSpec> iter = jobSpecs.listIterator();
JobMigration migration = migrations.get(i + 1);
assert migration != null;
while (iter.hasNext()) {
JobSpec jobSpec = iter.next();
Data data = dataSerializer.deserialize(jobSpec.getSerializedData());
JobData originalJobData = new JobData(jobSpec.getFactoryKey(), jobSpec.getQueueKey(), data);
JobData updatedJobData = migration.migrate(originalJobData);
JobSpec updatedJobSpec = new JobSpec(jobSpec.getId(),
updatedJobData.getFactoryKey(),
updatedJobData.getQueueKey(),
jobSpec.getCreateTime(),
jobSpec.getNextRunAttemptTime(),
jobSpec.getRunAttempt(),
jobSpec.getMaxAttempts(),
jobSpec.getMaxBackoff(),
jobSpec.getLifespan(),
jobSpec.getMaxInstances(),
dataSerializer.serialize(updatedJobData.getData()),
jobSpec.isRunning());
iter.set(updatedJobSpec);
}
}
jobStorage.updateJobs(jobSpecs);
return currentVersion;
}
}

View File

@@ -0,0 +1,124 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.Application;
import android.os.PowerManager;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.WakeLockUtil;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* A thread that constantly checks for available {@link Job}s owned by the {@link JobController}.
* When one is available, this class will execute it and call the appropriate methods on
* {@link JobController} based on the result.
*
* {@link JobRunner} and {@link JobController} were written such that you should be able to have
* N concurrent {@link JobRunner}s operating over the same {@link JobController}.
*/
class JobRunner extends Thread {
private static final String TAG = JobRunner.class.getSimpleName();
private static long WAKE_LOCK_TIMEOUT = TimeUnit.MINUTES.toMillis(10);
private final Application application;
private final int id;
private final JobController jobController;
JobRunner(@NonNull Application application, int id, @NonNull JobController jobController) {
super("signal-JobRunner-" + id);
this.application = application;
this.id = id;
this.jobController = jobController;
}
@Override
public synchronized void run() {
//noinspection InfiniteLoopStatement
while (true) {
Job job = jobController.pullNextEligibleJobForExecution();
Job.Result result = run(job);
jobController.onJobFinished(job);
if (result.isSuccess()) {
jobController.onSuccess(job);
} else if (result.isRetry()) {
jobController.onRetry(job);
job.onRetry();
} else if (result.isFailure()) {
List<Job> dependents = jobController.onFailure(job);
job.onCanceled();
Stream.of(dependents).forEach(Job::onCanceled);
if (result.getException() != null) {
throw result.getException();
}
} else {
throw new AssertionError("Invalid job result!");
}
}
}
private Job.Result run(@NonNull Job job) {
Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Running job."));
if (isJobExpired(job)) {
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its lifespan."));
return Job.Result.failure();
}
Job.Result result = null;
PowerManager.WakeLock wakeLock = null;
try {
wakeLock = WakeLockUtil.acquire(application, PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TIMEOUT, job.getId());
result = job.run();
} catch (Exception e) {
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing due to an unexpected exception."), e);
return Job.Result.failure();
} finally {
if (wakeLock != null) {
WakeLockUtil.release(wakeLock, job.getId());
}
}
printResult(job, result);
if (result.isRetry() &&
job.getRunAttempt() + 1 >= job.getParameters().getMaxAttempts() &&
job.getParameters().getMaxAttempts() != Job.Parameters.UNLIMITED)
{
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its max number of attempts."));
return Job.Result.failure();
}
return result;
}
private boolean isJobExpired(@NonNull Job job) {
long expirationTime = job.getParameters().getCreateTime() + job.getParameters().getLifespan();
if (expirationTime < 0) {
expirationTime = Long.MAX_VALUE;
}
return job.getParameters().getLifespan() != Job.Parameters.IMMORTAL && expirationTime <= System.currentTimeMillis();
}
private void printResult(@NonNull Job job, @NonNull Job.Result result) {
if (result.getException() != null) {
Log.e(TAG, JobLogger.format(job, String.valueOf(id), "Job failed with a fatal exception. Crash imminent."));
} else if (result.isFailure()) {
Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Job failed."));
} else {
Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Job finished with result: " + result));
}
}
}

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.Application;
import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.content.ComponentName;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import java.util.List;
@RequiresApi(26)
public class JobSchedulerScheduler implements Scheduler {
private static final String TAG = JobSchedulerScheduler.class.getSimpleName();
private static final String PREF_NAME = "JobSchedulerScheduler_prefs";
private static final String PREF_NEXT_ID = "pref_next_id";
private static final int MAX_ID = 20;
private final Application application;
JobSchedulerScheduler(@NonNull Application application) {
this.application = application;
}
@RequiresApi(26)
@Override
public void schedule(long delay, @NonNull List<Constraint> constraints) {
JobInfo.Builder jobInfoBuilder = new JobInfo.Builder(getNextId(), new ComponentName(application, SystemService.class))
.setMinimumLatency(delay)
.setPersisted(true);
for (Constraint constraint : constraints) {
constraint.applyToJobInfo(jobInfoBuilder);
}
Log.i(TAG, "Scheduling a run in " + delay + " ms.");
JobScheduler jobScheduler = application.getSystemService(JobScheduler.class);
jobScheduler.schedule(jobInfoBuilder.build());
}
private int getNextId() {
SharedPreferences prefs = application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
int returnedId = prefs.getInt(PREF_NEXT_ID, 0);
int nextId = returnedId + 1 > MAX_ID ? 0 : returnedId + 1;
prefs.edit().putInt(PREF_NEXT_ID, nextId).apply();
return returnedId;
}
@RequiresApi(api = 26)
public static class SystemService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
Log.d(TAG, "onStartJob()");
JobManager jobManager = ApplicationDependencies.getJobManager();
jobManager.addOnEmptyQueueListener(new JobManager.EmptyQueueListener() {
@Override
public void onQueueEmpty() {
jobManager.removeOnEmptyQueueListener(this);
jobFinished(params, false);
Log.d(TAG, "jobFinished()");
}
});
jobManager.wakeUp();
return true;
}
@Override
public boolean onStopJob(JobParameters params) {
Log.d(TAG, "onStopJob()");
return false;
}
}
}

View File

@@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.util.LRUCache;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Executor;
/**
* Tracks the state of {@link Job}s and allows callers to listen to changes.
*/
public class JobTracker {
private final Map<String, TrackingState> trackingStates;
private final Executor listenerExecutor;
JobTracker() {
this.trackingStates = new LRUCache<>(1000);
this.listenerExecutor = SignalExecutors.BOUNDED;
}
/**
* Add a listener to subscribe to job state updates. Listeners will be invoked on an arbitrary
* background thread. You must eventually call {@link #removeListener(JobListener)} to avoid
* memory leaks.
*/
synchronized void addListener(@NonNull String id, @NonNull JobListener jobListener) {
TrackingState state = getOrCreateTrackingState(id);
JobState currentJobState = state.getJobState();
state.addListener(jobListener);
if (currentJobState != null) {
listenerExecutor.execute(() -> jobListener.onStateChanged(currentJobState));
}
}
/**
* Unsubscribe the provided listener from all job updates.
*/
synchronized void removeListener(@NonNull JobListener jobListener) {
Collection<TrackingState> allTrackingState = trackingStates.values();
for (TrackingState state : allTrackingState) {
state.removeListener(jobListener);
}
}
/**
* Update the state of a job with the associated ID.
*/
synchronized void onStateChange(@NonNull String id, @NonNull JobState jobState) {
TrackingState trackingState = getOrCreateTrackingState(id);
trackingState.setJobState(jobState);
for (JobListener listener : trackingState.getListeners()) {
listenerExecutor.execute(() -> listener.onStateChanged(jobState));
}
}
private @NonNull TrackingState getOrCreateTrackingState(@NonNull String id) {
TrackingState state = trackingStates.get(id);
if (state == null) {
state = new TrackingState();
}
trackingStates.put(id, state);
return state;
}
public interface JobListener {
void onStateChanged(@NonNull JobState jobState);
}
public enum JobState {
PENDING(false), RUNNING(false), SUCCESS(true), FAILURE(true), IGNORED(true);
private final boolean complete;
JobState(boolean complete) {
this.complete = complete;
}
public boolean isComplete() {
return complete;
}
}
private static class TrackingState {
private JobState jobState;
private final CopyOnWriteArraySet<JobListener> listeners = new CopyOnWriteArraySet<>();
void addListener(@NonNull JobListener jobListener) {
listeners.add(jobListener);
}
void removeListener(@NonNull JobListener jobListener) {
listeners.remove(jobListener);
}
@NonNull Collection<JobListener> getListeners() {
return listeners;
}
void setJobState(@NonNull JobState jobState) {
this.jobState = jobState;
}
@Nullable JobState getJobState() {
return jobState;
}
}
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.jobmanager;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import androidx.annotation.Nullable;
/**
* Service that keeps the application in memory while the app is closed.
*
* Important: Should only be used on API < 26.
*/
public class KeepAliveService extends Service {
@Override
public @Nullable IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.jobmanager;
import androidx.annotation.NonNull;
import java.util.List;
public interface Scheduler {
void schedule(long delay, @NonNull List<Constraint> constraints);
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.app.job.JobInfo;
import android.telephony.ServiceState;
import android.telephony.TelephonyManager;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Constraint;
import org.thoughtcrime.securesms.sms.TelephonyServiceState;
import org.thoughtcrime.securesms.util.ServiceUtil;
public class CellServiceConstraint implements Constraint {
public static final String KEY = "CellServiceConstraint";
private final Application application;
public CellServiceConstraint(@NonNull Application application) {
this.application = application;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public boolean isMet() {
return CellServiceConstraintObserver.getInstance(application).hasService();
}
@Override
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
}
public static final class Factory implements Constraint.Factory<CellServiceConstraint> {
private final Application application;
public Factory(@NonNull Application application) {
this.application = application;
}
@Override
public CellServiceConstraint create() {
return new CellServiceConstraint(application);
}
}
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.content.Context;
import androidx.annotation.NonNull;
import android.telephony.PhoneStateListener;
import android.telephony.ServiceState;
import android.telephony.TelephonyManager;
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
public class CellServiceConstraintObserver implements ConstraintObserver {
private static final String REASON = CellServiceConstraintObserver.class.getSimpleName();
private volatile Notifier notifier;
private volatile ServiceState lastKnownState;
private static CellServiceConstraintObserver instance;
public static synchronized CellServiceConstraintObserver getInstance(@NonNull Application application) {
if (instance == null) {
instance = new CellServiceConstraintObserver(application);
}
return instance;
}
private CellServiceConstraintObserver(@NonNull Application application) {
TelephonyManager telephonyManager = (TelephonyManager) application.getSystemService(Context.TELEPHONY_SERVICE);
ServiceStateListener serviceStateListener = new ServiceStateListener();
telephonyManager.listen(serviceStateListener, PhoneStateListener.LISTEN_SERVICE_STATE);
}
@Override
public void register(@NonNull Notifier notifier) {
this.notifier = notifier;
}
public boolean hasService() {
return lastKnownState != null && lastKnownState.getState() == ServiceState.STATE_IN_SERVICE;
}
private class ServiceStateListener extends PhoneStateListener {
@Override
public void onServiceStateChanged(ServiceState serviceState) {
lastKnownState = serviceState;
if (serviceState.getState() == ServiceState.STATE_IN_SERVICE && notifier != null) {
notifier.onConstraintMet(REASON);
}
}
}
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.ExecutorFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class DefaultExecutorFactory implements ExecutorFactory {
@Override
public @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name) {
return Executors.newSingleThreadExecutor(r -> new Thread(r, name));
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.JsonUtils;
import java.io.IOException;
public class JsonDataSerializer implements Data.Serializer {
private static final String TAG = Log.tag(JsonDataSerializer.class);
@Override
public @NonNull String serialize(@NonNull Data data) {
try {
return JsonUtils.toJson(data);
} catch (IOException e) {
Log.e(TAG, "Failed to serialize to JSON.", e);
throw new AssertionError(e);
}
}
@Override
public @NonNull Data deserialize(@NonNull String serialized) {
try {
return JsonUtils.fromJson(serialized, Data.class);
} catch (IOException e) {
Log.e(TAG, "Failed to deserialize JSON.", e);
throw new AssertionError(e);
}
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.app.job.JobInfo;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.jobmanager.Constraint;
public class NetworkConstraint implements Constraint {
public static final String KEY = "NetworkConstraint";
private final Application application;
private NetworkConstraint(@NonNull Application application) {
this.application = application;
}
@Override
public boolean isMet() {
ConnectivityManager connectivityManager = (ConnectivityManager) application.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
return activeNetworkInfo != null && activeNetworkInfo.isConnected();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@RequiresApi(26)
@Override
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
}
public static final class Factory implements Constraint.Factory<NetworkConstraint> {
private final Application application;
public Factory(@NonNull Application application) {
this.application = application;
}
@Override
public NetworkConstraint create() {
return new NetworkConstraint(application);
}
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
public class NetworkConstraintObserver implements ConstraintObserver {
private static final String REASON = NetworkConstraintObserver.class.getSimpleName();
private final Application application;
public NetworkConstraintObserver(Application application) {
this.application = application;
}
@Override
public void register(@NonNull Notifier notifier) {
application.registerReceiver(new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
NetworkConstraint constraint = new NetworkConstraint.Factory(application).create();
if (constraint.isMet()) {
notifier.onConstraintMet(REASON);
}
}
}, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.app.job.JobInfo;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Constraint;
public class NetworkOrCellServiceConstraint implements Constraint {
public static final String KEY = "NetworkOrCellServiceConstraint";
private final NetworkConstraint networkConstraint;
private final CellServiceConstraint serviceConstraint;
public NetworkOrCellServiceConstraint(@NonNull Application application) {
networkConstraint = new NetworkConstraint.Factory(application).create();
serviceConstraint = new CellServiceConstraint.Factory(application).create();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public boolean isMet() {
return networkConstraint.isMet() || serviceConstraint.isMet();
}
@Override
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
}
public static class Factory implements Constraint.Factory<NetworkOrCellServiceConstraint> {
private final Application application;
public Factory(@NonNull Application application) {
this.application = application;
}
@Override
public NetworkOrCellServiceConstraint create() {
return new NetworkOrCellServiceConstraint(application);
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.Application;
import android.app.job.JobInfo;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Constraint;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class SqlCipherMigrationConstraint implements Constraint {
public static final String KEY = "SqlCipherMigrationConstraint";
private final Application application;
private SqlCipherMigrationConstraint(@NonNull Application application) {
this.application = application;
}
@Override
public boolean isMet() {
return !TextSecurePreferences.getNeedsSqlCipherMigration(application);
}
@NonNull
@Override
public String getFactoryKey() {
return KEY;
}
@Override
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
}
public static final class Factory implements Constraint.Factory<SqlCipherMigrationConstraint> {
private final Application application;
public Factory(@NonNull Application application) {
this.application = application;
}
@Override
public SqlCipherMigrationConstraint create() {
return new SqlCipherMigrationConstraint(application);
}
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
public class SqlCipherMigrationConstraintObserver implements ConstraintObserver {
private static final String REASON = SqlCipherMigrationConstraintObserver.class.getSimpleName();
private Notifier notifier;
public SqlCipherMigrationConstraintObserver() {
EventBus.getDefault().register(this);
}
@Override
public void register(@NonNull Notifier notifier) {
this.notifier = notifier;
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEvent(SqlCipherNeedsMigrationEvent event) {
if (notifier != null) notifier.onConstraintMet(REASON);
}
public static class SqlCipherNeedsMigrationEvent {
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.jobmanager.migrations;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.JobMigration;
/**
* Fixes things that went wrong in {@link RecipientIdJobMigration}. In particular, some jobs didn't
* have some necessary data fields carried over. Thankfully they're relatively non-critical, so
* we'll just swap them out with failing jobs if they're missing something.
*/
public class RecipientIdFollowUpJobMigration extends JobMigration {
public RecipientIdFollowUpJobMigration() {
this(3);
}
RecipientIdFollowUpJobMigration(int endVersion) {
super(endVersion);
}
@Override
protected @NonNull JobData migrate(@NonNull JobData jobData) {
switch(jobData.getFactoryKey()) {
case "RequestGroupInfoJob": return migrateRequestGroupInfoJob(jobData);
case "SendDeliveryReceiptJob": return migrateSendDeliveryReceiptJob(jobData);
default:
return jobData;
}
}
private static @NonNull JobData migrateRequestGroupInfoJob(@NonNull JobData jobData) {
if (!jobData.getData().hasString("source") || !jobData.getData().hasString("group_id")) {
return failingJobData();
} else {
return jobData;
}
}
private static @NonNull JobData migrateSendDeliveryReceiptJob(@NonNull JobData jobData) {
if (!jobData.getData().hasString("recipient") ||
!jobData.getData().hasLong("message_id") ||
!jobData.getData().hasLong("timestamp"))
{
return failingJobData();
} else {
return jobData;
}
}
private static JobData failingJobData() {
return new JobData("FailingJob", null, new Data.Builder().build());
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.jobmanager.migrations;
/**
* Unfortunately there was a bug in {@link RecipientIdFollowUpJobMigration} that requires it to be
* run again.
*/
public class RecipientIdFollowUpJobMigration2 extends RecipientIdFollowUpJobMigration {
public RecipientIdFollowUpJobMigration2() {
super(4);
}
}

View File

@@ -0,0 +1,235 @@
package org.thoughtcrime.securesms.jobmanager.migrations;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.JobMigration;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
import java.io.IOException;
import java.io.Serializable;
public class RecipientIdJobMigration extends JobMigration {
private final Application application;
public RecipientIdJobMigration(@NonNull Application application) {
super(2);
this.application = application;
}
@Override
protected @NonNull JobData migrate(@NonNull JobData jobData) {
switch(jobData.getFactoryKey()) {
case "MultiDeviceContactUpdateJob": return migrateMultiDeviceContactUpdateJob(jobData);
case "MultiDeviceRevealUpdateJob": return migrateMultiDeviceViewOnceOpenJob(jobData);
case "RequestGroupInfoJob": return migrateRequestGroupInfoJob(jobData);
case "SendDeliveryReceiptJob": return migrateSendDeliveryReceiptJob(jobData);
case "MultiDeviceVerifiedUpdateJob": return migrateMultiDeviceVerifiedUpdateJob(jobData);
case "RetrieveProfileJob": return migrateRetrieveProfileJob(jobData);
case "PushGroupSendJob": return migratePushGroupSendJob(jobData);
case "PushGroupUpdateJob": return migratePushGroupUpdateJob(jobData);
case "DirectoryRefreshJob": return migrateDirectoryRefreshJob(jobData);
case "RetrieveProfileAvatarJob": return migrateRetrieveProfileAvatarJob(jobData);
case "MultiDeviceReadUpdateJob": return migrateMultiDeviceReadUpdateJob(jobData);
case "PushTextSendJob": return migratePushTextSendJob(jobData);
case "PushMediaSendJob": return migratePushMediaSendJob(jobData);
case "SmsSendJob": return migrateSmsSendJob(jobData);
default: return jobData;
}
}
private @NonNull JobData migrateMultiDeviceContactUpdateJob(@NonNull JobData jobData) {
String address = jobData.getData().hasString("address") ? jobData.getData().getString("address") : null;
Data updatedData = new Data.Builder().putString("recipient", address != null ? Recipient.external(application, address).getId().serialize() : null)
.putBoolean("force_sync", jobData.getData().getBoolean("force_sync"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateMultiDeviceViewOnceOpenJob(@NonNull JobData jobData) {
try {
String rawOld = jobData.getData().getString("message_id");
OldSerializableSyncMessageId old = JsonUtils.fromJson(rawOld, OldSerializableSyncMessageId.class);
Recipient recipient = Recipient.external(application, old.sender);
NewSerializableSyncMessageId updated = new NewSerializableSyncMessageId(recipient.getId().serialize(), old.timestamp);
String rawUpdated = JsonUtils.toJson(updated);
Data updatedData = new Data.Builder().putString("message_id", rawUpdated).build();
return jobData.withData(updatedData);
} catch (IOException e) {
throw new AssertionError(e);
}
}
private @NonNull JobData migrateRequestGroupInfoJob(@NonNull JobData jobData) {
String address = jobData.getData().getString("source");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("source", recipient.getId().serialize())
.putString("group_id", jobData.getData().getString("group_id"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateSendDeliveryReceiptJob(@NonNull JobData jobData) {
String address = jobData.getData().getString("address");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize())
.putLong("message_id", jobData.getData().getLong("message_id"))
.putLong("timestamp", jobData.getData().getLong("timestamp"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateMultiDeviceVerifiedUpdateJob(@NonNull JobData jobData) {
String address = jobData.getData().getString("destination");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("destination", recipient.getId().serialize())
.putString("identity_key", jobData.getData().getString("identity_key"))
.putInt("verified_status", jobData.getData().getInt("verified_status"))
.putLong("timestamp", jobData.getData().getLong("timestamp"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateRetrieveProfileJob(@NonNull JobData jobData) {
String address = jobData.getData().getString("address");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize()).build();
return jobData.withData(updatedData);
}
private @NonNull JobData migratePushGroupSendJob(@NonNull JobData jobData) {
// noinspection ConstantConditions
Recipient queueRecipient = Recipient.external(application, jobData.getQueueKey());
String address = jobData.getData().hasString("filter_address") ? jobData.getData().getString("filter_address") : null;
RecipientId recipientId = address != null ? Recipient.external(application, address).getId() : null;
Data updatedData = new Data.Builder().putString("filter_recipient", recipientId != null ? recipientId.serialize() : null)
.putLong("message_id", jobData.getData().getLong("message_id"))
.build();
return jobData.withQueueKey(queueRecipient.getId().toQueueKey())
.withData(updatedData);
}
private @NonNull JobData migratePushGroupUpdateJob(@NonNull JobData jobData) {
String address = jobData.getData().getString("source");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("source", recipient.getId().serialize())
.putString("group_id", jobData.getData().getString("group_id"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateDirectoryRefreshJob(@NonNull JobData jobData) {
String address = jobData.getData().hasString("address") ? jobData.getData().getString("address") : null;
Recipient recipient = address != null ? Recipient.external(application, address) : null;
Data updatedData = new Data.Builder().putString("recipient", recipient != null ? recipient.getId().serialize() : null)
.putBoolean("notify_of_new_users", jobData.getData().getBoolean("notify_of_new_users"))
.build();
return jobData.withData(updatedData);
}
private @NonNull JobData migrateRetrieveProfileAvatarJob(@NonNull JobData jobData) {
//noinspection ConstantConditions
String queueAddress = jobData.getQueueKey().substring("RetrieveProfileAvatarJob".length());
Recipient queueRecipient = Recipient.external(application, queueAddress);
String address = jobData.getData().getString("address");
Recipient recipient = Recipient.external(application, address);
Data updatedData = new Data.Builder().putString("recipient", recipient.getId().serialize())
.putString("profile_avatar", jobData.getData().getString("profile_avatar"))
.build();
return jobData.withQueueKey("RetrieveProfileAvatarJob::" + queueRecipient.getId().toQueueKey())
.withData(updatedData);
}
private @NonNull JobData migrateMultiDeviceReadUpdateJob(@NonNull JobData jobData) {
try {
String[] rawOld = jobData.getData().getStringArray("message_ids");
String[] rawUpdated = new String[rawOld.length];
for (int i = 0; i < rawOld.length; i++) {
OldSerializableSyncMessageId old = JsonUtils.fromJson(rawOld[i], OldSerializableSyncMessageId.class);
Recipient recipient = Recipient.external(application, old.sender);
NewSerializableSyncMessageId updated = new NewSerializableSyncMessageId(recipient.getId().serialize(), old.timestamp);
rawUpdated[i] = JsonUtils.toJson(updated);
}
Data updatedData = new Data.Builder().putStringArray("message_ids", rawUpdated).build();
return jobData.withData(updatedData);
} catch (IOException e) {
throw new AssertionError(e);
}
}
private @NonNull JobData migratePushTextSendJob(@NonNull JobData jobData) {
//noinspection ConstantConditions
Recipient recipient = Recipient.external(application, jobData.getQueueKey());
return jobData.withQueueKey(recipient.getId().toQueueKey());
}
private @NonNull JobData migratePushMediaSendJob(@NonNull JobData jobData) {
//noinspection ConstantConditions
Recipient recipient = Recipient.external(application, jobData.getQueueKey());
return jobData.withQueueKey(recipient.getId().toQueueKey());
}
private @NonNull JobData migrateSmsSendJob(@NonNull JobData jobData) {
//noinspection ConstantConditions
if (jobData.getQueueKey() != null) {
Recipient recipient = Recipient.external(application, jobData.getQueueKey());
return jobData.withQueueKey(recipient.getId().toQueueKey());
} else {
return jobData;
}
}
@VisibleForTesting
static class OldSerializableSyncMessageId implements Serializable {
private static final long serialVersionUID = 1L;
@JsonProperty
private final String sender;
@JsonProperty
private final long timestamp;
OldSerializableSyncMessageId(@JsonProperty("sender") String sender, @JsonProperty("timestamp") long timestamp) {
this.sender = sender;
this.timestamp = timestamp;
}
}
@VisibleForTesting
static class NewSerializableSyncMessageId implements Serializable {
private static final long serialVersionUID = 1L;
@JsonProperty
private final String recipientId;
@JsonProperty
private final long timestamp;
NewSerializableSyncMessageId(@JsonProperty("recipientId") String recipientId, @JsonProperty("timestamp") long timestamp) {
this.recipientId = recipientId;
this.timestamp = timestamp;
}
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.jobmanager.persistence;
import androidx.annotation.NonNull;
import java.util.Objects;
public final class ConstraintSpec {
private final String jobSpecId;
private final String factoryKey;
public ConstraintSpec(@NonNull String jobSpecId, @NonNull String factoryKey) {
this.jobSpecId = jobSpecId;
this.factoryKey = factoryKey;
}
public String getJobSpecId() {
return jobSpecId;
}
public String getFactoryKey() {
return factoryKey;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ConstraintSpec that = (ConstraintSpec) o;
return Objects.equals(jobSpecId, that.jobSpecId) &&
Objects.equals(factoryKey, that.factoryKey);
}
@Override
public int hashCode() {
return Objects.hash(jobSpecId, factoryKey);
}
@Override
public @NonNull String toString() {
return String.format("jobSpecId: JOB::%s | factoryKey: %s", jobSpecId, factoryKey);
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.jobmanager.persistence;
import androidx.annotation.NonNull;
import java.util.Objects;
public final class DependencySpec {
private final String jobId;
private final String dependsOnJobId;
public DependencySpec(@NonNull String jobId, @NonNull String dependsOnJobId) {
this.jobId = jobId;
this.dependsOnJobId = dependsOnJobId;
}
public @NonNull String getJobId() {
return jobId;
}
public @NonNull String getDependsOnJobId() {
return dependsOnJobId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DependencySpec that = (DependencySpec) o;
return Objects.equals(jobId, that.jobId) &&
Objects.equals(dependsOnJobId, that.dependsOnJobId);
}
@Override
public int hashCode() {
return Objects.hash(jobId, dependsOnJobId);
}
@Override
public @NonNull String toString() {
return String.format("jobSpecId: JOB::%s | dependsOnJobSpecId: JOB::%s", jobId, dependsOnJobId);
}
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.jobmanager.persistence;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.Objects;
public final class FullSpec {
private final JobSpec jobSpec;
private final List<ConstraintSpec> constraintSpecs;
private final List<DependencySpec> dependencySpecs;
public FullSpec(@NonNull JobSpec jobSpec,
@NonNull List<ConstraintSpec> constraintSpecs,
@NonNull List<DependencySpec> dependencySpecs)
{
this.jobSpec = jobSpec;
this.constraintSpecs = constraintSpecs;
this.dependencySpecs = dependencySpecs;
}
public @NonNull JobSpec getJobSpec() {
return jobSpec;
}
public @NonNull List<ConstraintSpec> getConstraintSpecs() {
return constraintSpecs;
}
public @NonNull List<DependencySpec> getDependencySpecs() {
return dependencySpecs;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FullSpec fullSpec = (FullSpec) o;
return Objects.equals(jobSpec, fullSpec.jobSpec) &&
Objects.equals(constraintSpecs, fullSpec.constraintSpecs) &&
Objects.equals(dependencySpecs, fullSpec.dependencySpecs);
}
@Override
public int hashCode() {
return Objects.hash(jobSpec, constraintSpecs, dependencySpecs);
}
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.jobmanager.persistence;
import android.annotation.SuppressLint;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Objects;
public final class JobSpec {
private final String id;
private final String factoryKey;
private final String queueKey;
private final long createTime;
private final long nextRunAttemptTime;
private final int runAttempt;
private final int maxAttempts;
private final long maxBackoff;
private final long lifespan;
private final int maxInstances;
private final String serializedData;
private final boolean isRunning;
public JobSpec(@NonNull String id,
@NonNull String factoryKey,
@Nullable String queueKey,
long createTime,
long nextRunAttemptTime,
int runAttempt,
int maxAttempts,
long maxBackoff,
long lifespan,
int maxInstances,
@NonNull String serializedData,
boolean isRunning)
{
this.id = id;
this.factoryKey = factoryKey;
this.queueKey = queueKey;
this.createTime = createTime;
this.nextRunAttemptTime = nextRunAttemptTime;
this.maxBackoff = maxBackoff;
this.runAttempt = runAttempt;
this.maxAttempts = maxAttempts;
this.lifespan = lifespan;
this.maxInstances = maxInstances;
this.serializedData = serializedData;
this.isRunning = isRunning;
}
public @NonNull String getId() {
return id;
}
public @NonNull String getFactoryKey() {
return factoryKey;
}
public @Nullable String getQueueKey() {
return queueKey;
}
public long getCreateTime() {
return createTime;
}
public long getNextRunAttemptTime() {
return nextRunAttemptTime;
}
public int getRunAttempt() {
return runAttempt;
}
public int getMaxAttempts() {
return maxAttempts;
}
public long getMaxBackoff() {
return maxBackoff;
}
public int getMaxInstances() {
return maxInstances;
}
public long getLifespan() {
return lifespan;
}
public @NonNull String getSerializedData() {
return serializedData;
}
public boolean isRunning() {
return isRunning;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
JobSpec jobSpec = (JobSpec) o;
return createTime == jobSpec.createTime &&
nextRunAttemptTime == jobSpec.nextRunAttemptTime &&
runAttempt == jobSpec.runAttempt &&
maxAttempts == jobSpec.maxAttempts &&
maxBackoff == jobSpec.maxBackoff &&
lifespan == jobSpec.lifespan &&
maxInstances == jobSpec.maxInstances &&
isRunning == jobSpec.isRunning &&
Objects.equals(id, jobSpec.id) &&
Objects.equals(factoryKey, jobSpec.factoryKey) &&
Objects.equals(queueKey, jobSpec.queueKey) &&
Objects.equals(serializedData, jobSpec.serializedData);
}
@Override
public int hashCode() {
return Objects.hash(id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, lifespan, maxInstances, serializedData, isRunning);
}
@SuppressLint("DefaultLocale")
@Override
public @NonNull String toString() {
return String.format("id: JOB::%s | factoryKey: %s | queueKey: %s | createTime: %d | nextRunAttemptTime: %d | runAttempt: %d | maxAttempts: %d | maxBackoff: %d | maxInstances: %d | lifespan: %d | isRunning: %b | data: %s",
id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, maxInstances, lifespan, isRunning, serializedData);
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.jobmanager.persistence;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import java.util.List;
public interface JobStorage {
@WorkerThread
void init();
@WorkerThread
void insertJobs(@NonNull List<FullSpec> fullSpecs);
@WorkerThread
@Nullable JobSpec getJobSpec(@NonNull String id);
@WorkerThread
@NonNull List<JobSpec> getAllJobSpecs();
@WorkerThread
@NonNull List<JobSpec> getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime);
@WorkerThread
int getJobInstanceCount(@NonNull String factoryKey);
@WorkerThread
void updateJobRunningState(@NonNull String id, boolean isRunning);
@WorkerThread
void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime, @NonNull String serializedData);
@WorkerThread
void updateAllJobsToBePending();
@WorkerThread
void updateJobs(@NonNull List<JobSpec> jobSpecs);
@WorkerThread
void deleteJob(@NonNull String id);
@WorkerThread
void deleteJobs(@NonNull List<String> ids);
@WorkerThread
@NonNull List<ConstraintSpec> getConstraintSpecs(@NonNull String jobId);
@WorkerThread
@NonNull List<ConstraintSpec> getAllConstraintSpecs();
@WorkerThread
@NonNull List<DependencySpec> getDependencySpecsThatDependOnJob(@NonNull String jobSpecId);
@WorkerThread
@NonNull List<DependencySpec> getAllDependencySpecs();
}

View File

@@ -0,0 +1,169 @@
package org.thoughtcrime.securesms.jobmanager.workmanager;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.logging.Log;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.util.HashMap;
import java.util.Map;
/**
* Takes a persisted data blob stored by WorkManager and converts it to our {@link Data} class.
*/
final class DataMigrator {
private static final String TAG = Log.tag(DataMigrator.class);
static final Data convert(@NonNull byte[] workManagerData) {
Map<String, Object> values = parseWorkManagerDataMap(workManagerData);
Data.Builder builder = new Data.Builder();
for (Map.Entry<String, Object> entry : values.entrySet()) {
Object value = entry.getValue();
if (value == null) {
builder.putString(entry.getKey(), null);
} else {
Class type = value.getClass();
if (type == String.class) {
builder.putString(entry.getKey(), (String) value);
} else if (type == String[].class) {
builder.putStringArray(entry.getKey(), (String[]) value);
} else if (type == Integer.class || type == int.class) {
builder.putInt(entry.getKey(), (int) value);
} else if (type == Integer[].class || type == int[].class) {
builder.putIntArray(entry.getKey(), convertToIntArray(value, type));
} else if (type == Long.class || type == long.class) {
builder.putLong(entry.getKey(), (long) value);
} else if (type == Long[].class || type == long[].class) {
builder.putLongArray(entry.getKey(), convertToLongArray(value, type));
} else if (type == Float.class || type == float.class) {
builder.putFloat(entry.getKey(), (float) value);
} else if (type == Float[].class || type == float[].class) {
builder.putFloatArray(entry.getKey(), convertToFloatArray(value, type));
} else if (type == Double.class || type == double.class) {
builder.putDouble(entry.getKey(), (double) value);
} else if (type == Double[].class || type == double[].class) {
builder.putDoubleArray(entry.getKey(), convertToDoubleArray(value, type));
} else if (type == Boolean.class || type == boolean.class) {
builder.putBoolean(entry.getKey(), (boolean) value);
} else if (type == Boolean[].class || type == boolean[].class) {
builder.putBooleanArray(entry.getKey(), convertToBooleanArray(value, type));
} else {
Log.w(TAG, "Encountered unexpected type '" + type + "'. Skipping.");
}
}
}
return builder.build();
}
private static @NonNull Map<String, Object> parseWorkManagerDataMap(@NonNull byte[] bytes) throws IllegalStateException {
Map<String, Object> map = new HashMap<>();
ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
ObjectInputStream objectInputStream = null;
try {
objectInputStream = new ObjectInputStream(inputStream);
for (int i = objectInputStream.readInt(); i > 0; i--) {
map.put(objectInputStream.readUTF(), objectInputStream.readObject());
}
} catch (IOException | ClassNotFoundException e) {
Log.w(TAG, "Failed to read WorkManager data.", e);
} finally {
try {
inputStream.close();
if (objectInputStream != null) {
objectInputStream.close();
}
} catch (IOException e) {
Log.e(TAG, "Failed to close streams after reading WorkManager data.", e);
}
}
return map;
}
private static int[] convertToIntArray(Object value, Class type) {
if (type == int[].class) {
return (int[]) value;
}
Integer[] casted = (Integer[]) value;
int[] output = new int[casted.length];
for (int i = 0; i < casted.length; i++) {
output[i] = casted[i];
}
return output;
}
private static long[] convertToLongArray(Object value, Class type) {
if (type == long[].class) {
return (long[]) value;
}
Long[] casted = (Long[]) value;
long[] output = new long[casted.length];
for (int i = 0; i < casted.length; i++) {
output[i] = casted[i];
}
return output;
}
private static float[] convertToFloatArray(Object value, Class type) {
if (type == float[].class) {
return (float[]) value;
}
Float[] casted = (Float[]) value;
float[] output = new float[casted.length];
for (int i = 0; i < casted.length; i++) {
output[i] = casted[i];
}
return output;
}
private static double[] convertToDoubleArray(Object value, Class type) {
if (type == double[].class) {
return (double[]) value;
}
Double[] casted = (Double[]) value;
double[] output = new double[casted.length];
for (int i = 0; i < casted.length; i++) {
output[i] = casted[i];
}
return output;
}
private static boolean[] convertToBooleanArray(Object value, Class type) {
if (type == boolean[].class) {
return (boolean[]) value;
}
Boolean[] casted = (Boolean[]) value;
boolean[] output = new boolean[casted.length];
for (int i = 0; i < casted.length; i++) {
output[i] = casted[i];
}
return output;
}
}

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.jobmanager.workmanager;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
import org.thoughtcrime.securesms.logging.Log;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
final class WorkManagerDatabase extends SQLiteOpenHelper {
private static final String TAG = WorkManagerDatabase.class.getSimpleName();
static final String DB_NAME = "androidx.work.workdb";
WorkManagerDatabase(@NonNull Context context) {
super(context, DB_NAME, null, 5);
}
@Override
public void onCreate(SQLiteDatabase db) {
throw new UnsupportedOperationException("We should never be creating this database, only migrating an existing one!");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// There's a chance that a user who hasn't upgraded in > 6 months could hit this onUpgrade path,
// but we don't use any of the columns that were added in any migrations they could hit, so we
// can ignore this.
Log.w(TAG, "Hit onUpgrade path from " + oldVersion + " to " + newVersion);
}
@NonNull List<FullSpec> getAllJobs(@NonNull Data.Serializer dataSerializer) {
SQLiteDatabase db = getReadableDatabase();
String[] columns = new String[] { "id", "worker_class_name", "input", "required_network_type"};
List<FullSpec> fullSpecs = new LinkedList<>();
try (Cursor cursor = db.query("WorkSpec", columns, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String factoryName = WorkManagerFactoryMappings.getFactoryKey(cursor.getString(cursor.getColumnIndexOrThrow("worker_class_name")));
if (factoryName != null) {
String id = cursor.getString(cursor.getColumnIndexOrThrow("id"));
byte[] data = cursor.getBlob(cursor.getColumnIndexOrThrow("input"));
List<ConstraintSpec> constraints = new LinkedList<>();
JobSpec jobSpec = new JobSpec(id,
factoryName,
getQueueKey(id),
System.currentTimeMillis(),
0,
0,
Job.Parameters.UNLIMITED,
TimeUnit.SECONDS.toMillis(30),
TimeUnit.DAYS.toMillis(1),
Job.Parameters.UNLIMITED,
dataSerializer.serialize(DataMigrator.convert(data)),
false);
if (cursor.getInt(cursor.getColumnIndexOrThrow("required_network_type")) != 0) {
constraints.add(new ConstraintSpec(id, NetworkConstraint.KEY));
}
fullSpecs.add(new FullSpec(jobSpec, constraints, Collections.emptyList()));
} else {
Log.w(TAG, "Failed to find a matching factory for worker class: " + factoryName);
}
}
}
return fullSpecs;
}
private @Nullable String getQueueKey(@NonNull String jobId) {
String query = "work_spec_id = ?";
String[] args = new String[] { jobId };
try (Cursor cursor = getReadableDatabase().query("WorkName", null, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(cursor.getColumnIndexOrThrow("name"));
}
}
return null;
}
}

View File

@@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.jobmanager.workmanager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.jobs.AvatarDownloadJob;
import org.thoughtcrime.securesms.jobs.CleanPreKeysJob;
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.jobs.FailingJob;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsReceiveJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceReadUpdateJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.PushGroupUpdateJob;
import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.PushTextSendJob;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RequestGroupInfoJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RotateCertificateJob;
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob;
import org.thoughtcrime.securesms.jobs.RotateSignedPreKeyJob;
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob;
import org.thoughtcrime.securesms.jobs.SendReadReceiptJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.jobs.SmsReceiveJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.jobs.SmsSentJob;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
import java.util.HashMap;
import java.util.Map;
public class WorkManagerFactoryMappings {
private static final Map<String, String> FACTORY_MAP = new HashMap<String, String>() {{
put(AttachmentDownloadJob.class.getName(), AttachmentDownloadJob.KEY);
put(AttachmentUploadJob.class.getName(), AttachmentUploadJob.KEY);
put(AvatarDownloadJob.class.getName(), AvatarDownloadJob.KEY);
put(CleanPreKeysJob.class.getName(), CleanPreKeysJob.KEY);
put(CreateSignedPreKeyJob.class.getName(), CreateSignedPreKeyJob.KEY);
put(DirectoryRefreshJob.class.getName(), DirectoryRefreshJob.KEY);
put(FcmRefreshJob.class.getName(), FcmRefreshJob.KEY);
put(LocalBackupJob.class.getName(), LocalBackupJob.KEY);
put(MmsDownloadJob.class.getName(), MmsDownloadJob.KEY);
put(MmsReceiveJob.class.getName(), MmsReceiveJob.KEY);
put(MmsSendJob.class.getName(), MmsSendJob.KEY);
put(MultiDeviceBlockedUpdateJob.class.getName(), MultiDeviceBlockedUpdateJob.KEY);
put(MultiDeviceConfigurationUpdateJob.class.getName(), MultiDeviceConfigurationUpdateJob.KEY);
put(MultiDeviceContactUpdateJob.class.getName(), MultiDeviceContactUpdateJob.KEY);
put(MultiDeviceGroupUpdateJob.class.getName(), MultiDeviceGroupUpdateJob.KEY);
put(MultiDeviceProfileKeyUpdateJob.class.getName(), MultiDeviceProfileKeyUpdateJob.KEY);
put(MultiDeviceReadUpdateJob.class.getName(), MultiDeviceReadUpdateJob.KEY);
put(MultiDeviceVerifiedUpdateJob.class.getName(), MultiDeviceVerifiedUpdateJob.KEY);
put("PushContentReceiveJob", FailingJob.KEY);
put("PushDecryptJob", PushDecryptMessageJob.KEY);
put(PushGroupSendJob.class.getName(), PushGroupSendJob.KEY);
put(PushGroupUpdateJob.class.getName(), PushGroupUpdateJob.KEY);
put(PushMediaSendJob.class.getName(), PushMediaSendJob.KEY);
put(PushNotificationReceiveJob.class.getName(), PushNotificationReceiveJob.KEY);
put(PushTextSendJob.class.getName(), PushTextSendJob.KEY);
put(RefreshAttributesJob.class.getName(), RefreshAttributesJob.KEY);
put(RefreshPreKeysJob.class.getName(), RefreshPreKeysJob.KEY);
put("RefreshUnidentifiedDeliveryAbilityJob", FailingJob.KEY);
put(RequestGroupInfoJob.class.getName(), RequestGroupInfoJob.KEY);
put(RetrieveProfileAvatarJob.class.getName(), RetrieveProfileAvatarJob.KEY);
put(RetrieveProfileJob.class.getName(), RetrieveProfileJob.KEY);
put(RotateCertificateJob.class.getName(), RotateCertificateJob.KEY);
put(RotateProfileKeyJob.class.getName(), RotateProfileKeyJob.KEY);
put(RotateSignedPreKeyJob.class.getName(), RotateSignedPreKeyJob.KEY);
put(SendDeliveryReceiptJob.class.getName(), SendDeliveryReceiptJob.KEY);
put(SendReadReceiptJob.class.getName(), SendReadReceiptJob.KEY);
put(ServiceOutageDetectionJob.class.getName(), ServiceOutageDetectionJob.KEY);
put(SmsReceiveJob.class.getName(), SmsReceiveJob.KEY);
put(SmsSendJob.class.getName(), SmsSendJob.KEY);
put(SmsSentJob.class.getName(), SmsSentJob.KEY);
put(TrimThreadJob.class.getName(), TrimThreadJob.KEY);
put(TypingSendJob.class.getName(), TypingSendJob.KEY);
put(UpdateApkJob.class.getName(), UpdateApkJob.KEY);
}};
public static @Nullable String getFactoryKey(@NonNull String workManagerClass) {
return FACTORY_MAP.get(workManagerClass);
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.jobmanager.workmanager;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage;
import org.thoughtcrime.securesms.logging.Log;
import java.util.List;
public class WorkManagerMigrator {
private static final String TAG = Log.tag(WorkManagerMigrator.class);
@SuppressLint("DefaultLocale")
@WorkerThread
public static synchronized void migrate(@NonNull Context context,
@NonNull JobStorage jobStorage,
@NonNull Data.Serializer dataSerializer)
{
long startTime = System.currentTimeMillis();
Log.i(TAG, "Beginning WorkManager migration.");
WorkManagerDatabase database = new WorkManagerDatabase(context);
List<FullSpec> fullSpecs = database.getAllJobs(dataSerializer);
for (FullSpec fullSpec : fullSpecs) {
Log.i(TAG, String.format("Migrating job with key '%s' and %d constraint(s).", fullSpec.getJobSpec().getFactoryKey(), fullSpec.getConstraintSpecs().size()));
}
jobStorage.insertJobs(fullSpecs);
context.deleteDatabase(WorkManagerDatabase.DB_NAME);
Log.i(TAG, String.format("WorkManager migration finished. Migrated %d job(s) in %d ms.", fullSpecs.size(), System.currentTimeMillis() - startTime));
}
@WorkerThread
public static synchronized boolean needsMigration(@NonNull Context context) {
return context.getDatabasePath(WorkManagerDatabase.DB_NAME).exists();
}
}