From d4c8c16df3a7f9c53e7b9d4630185ba59729350d Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Fri, 10 Jan 2025 17:27:32 -0500 Subject: [PATCH] Notify a user when they link a device. --- .../securesms/database/JobDatabase.kt | 22 +++-- .../securesms/jobmanager/Job.java | 28 +++++- .../securesms/jobmanager/JobController.java | 8 +- .../securesms/jobmanager/JobMigrator.java | 3 +- .../jobmanager/persistence/JobSpec.kt | 5 +- .../securesms/jobs/FastJobStorage.kt | 14 ++- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/MinimalJobSpec.kt | 3 +- .../jobs/NewLinkedDeviceNotificationJob.kt | 92 +++++++++++++++++++ .../securesms/keyvalue/MiscellaneousValues.kt | 12 +++ .../linkdevice/LinkDeviceFragment.kt | 31 ++++++- .../linkdevice/LinkDeviceSettingsState.kt | 1 + .../linkdevice/LinkDeviceViewModel.kt | 45 ++++++++- .../securesms/megaphone/Megaphones.java | 30 +++++- .../notifications/NotificationChannels.java | 4 +- .../notifications/NotificationIds.java | 1 + app/src/main/protowire/JobData.proto | 5 + .../drawable/ic_inactive_linked_device.xml | 39 -------- .../res/drawable/symbol_linked_device.xml | 39 ++++++++ app/src/main/res/values/strings.xml | 20 ++++ .../securesms/jobmanager/JobMigratorTest.kt | 3 +- .../securesms/jobs/FastJobStorageTest.kt | 67 +++++++++----- 22 files changed, 382 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/NewLinkedDeviceNotificationJob.kt delete mode 100644 app/src/main/res/drawable/ic_inactive_linked_device.xml create mode 100644 app/src/main/res/drawable/symbol_linked_device.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.kt index 73a52c7f2e..1c1d859170 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.kt @@ -65,6 +65,7 @@ class JobDatabase( const val IS_RUNNING = "is_running" const val GLOBAL_PRIORITY = "global_priority" const val QUEUE_PRIORITY = "queue_priority" + const val INITIAL_DELAY = "initial_delay" val CREATE_TABLE = """ @@ -83,7 +84,8 @@ class JobDatabase( $IS_RUNNING INTEGER, $NEXT_BACKOFF_INTERVAL INTEGER, $GLOBAL_PRIORITY INTEGER DEFAULT 0, - $QUEUE_PRIORITY INTEGER DEFAULT 0 + $QUEUE_PRIORITY INTEGER DEFAULT 0, + $INITIAL_DELAY INTEGER DEFAULT 0 ) """.trimIndent() } @@ -147,6 +149,10 @@ class JobDatabase( db.execSQL("ALTER TABLE job_spec RENAME COLUMN priority TO global_priority") db.execSQL("ALTER TABLE job_spec ADD COLUMN queue_priority INTEGER DEFAULT 0") } + + if (oldVersion < 5) { + db.execSQL("ALTER TABLE job_spec ADD COLUMN initial_delay INTEGER DEFAULT 0") + } } override fun onOpen(db: SQLiteDatabase) { @@ -232,7 +238,8 @@ class JobDatabase( Jobs.NEXT_BACKOFF_INTERVAL, Jobs.IS_RUNNING, Jobs.GLOBAL_PRIORITY, - Jobs.QUEUE_PRIORITY + Jobs.QUEUE_PRIORITY, + Jobs.INITIAL_DELAY ) return readableDatabase .query(Jobs.TABLE_NAME, columns, null, null, null, null, "${Jobs.CREATE_TIME}, ${Jobs.ID} ASC") @@ -247,7 +254,8 @@ class JobDatabase( globalPriority = cursor.requireInt(Jobs.GLOBAL_PRIORITY), queuePriority = cursor.requireInt(Jobs.QUEUE_PRIORITY), isRunning = cursor.requireBoolean(Jobs.IS_RUNNING), - isMemoryOnly = false + isMemoryOnly = false, + initialDelay = cursor.requireLong(Jobs.INITIAL_DELAY) ) } } @@ -450,7 +458,8 @@ class JobDatabase( isRunning = this.requireBoolean(Jobs.IS_RUNNING), isMemoryOnly = false, globalPriority = this.requireInt(Jobs.GLOBAL_PRIORITY), - queuePriority = this.requireInt(Jobs.QUEUE_PRIORITY) + queuePriority = this.requireInt(Jobs.QUEUE_PRIORITY), + initialDelay = this.requireLong(Jobs.INITIAL_DELAY) ) } @@ -494,13 +503,14 @@ class JobDatabase( Jobs.SERIALIZED_INPUT_DATA to this.serializedInputData, Jobs.IS_RUNNING to if (this.isRunning) 1 else 0, Jobs.GLOBAL_PRIORITY to this.globalPriority, - Jobs.QUEUE_PRIORITY to this.queuePriority + Jobs.QUEUE_PRIORITY to this.queuePriority, + Jobs.INITIAL_DELAY to this.initialDelay ) } companion object { private val TAG = Log.tag(JobDatabase::class.java) - private const val DATABASE_VERSION = 4 + private const val DATABASE_VERSION = 5 private const val DATABASE_NAME = "signal-jobmanager.db" @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java index 23b0b7afb0..657026c4e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java @@ -286,6 +286,7 @@ public abstract class Job { private final boolean memoryOnly; private final int globalPriority; private final int queuePriority; + private final long initialDelay; private Parameters(@NonNull String id, long createTime, @@ -298,7 +299,8 @@ public abstract class Job { @Nullable byte[] inputData, boolean memoryOnly, int globalPriority, - int queuePriority) + int queuePriority, + long initialDelay) { this.id = id; this.createTime = createTime; @@ -312,6 +314,7 @@ public abstract class Job { this.memoryOnly = memoryOnly; this.globalPriority = globalPriority; this.queuePriority = queuePriority; + this.initialDelay = initialDelay; } @NonNull String getId() { @@ -362,8 +365,12 @@ public abstract class Job { return queuePriority; } + long getInitialDelay() { + return initialDelay; + } + public Builder toBuilder() { - return new Builder(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly, globalPriority, queuePriority); + return new Builder(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly, globalPriority, queuePriority, initialDelay); } @@ -380,13 +387,14 @@ public abstract class Job { private boolean memoryOnly; private int globalPriority; private int queuePriority; + private long initialDelay; public Builder() { this(UUID.randomUUID().toString()); } Builder(@NonNull String id) { - this(id, System.currentTimeMillis(), IMMORTAL, 1, UNLIMITED, UNLIMITED, null, new LinkedList<>(), null, false, Parameters.PRIORITY_DEFAULT, Parameters.PRIORITY_DEFAULT); + this(id, System.currentTimeMillis(), IMMORTAL, 1, UNLIMITED, UNLIMITED, null, new LinkedList<>(), null, false, Parameters.PRIORITY_DEFAULT, Parameters.PRIORITY_DEFAULT, 0); } private Builder(@NonNull String id, @@ -400,7 +408,8 @@ public abstract class Job { @Nullable byte[] inputData, boolean memoryOnly, int globalPriority, - int queuePriority) + int queuePriority, + long initialDelay) { this.id = id; this.createTime = createTime; @@ -414,6 +423,7 @@ public abstract class Job { this.memoryOnly = memoryOnly; this.globalPriority = globalPriority; this.queuePriority = queuePriority; + this.initialDelay = initialDelay; } /** Should only be invoked by {@link JobController} */ @@ -553,8 +563,16 @@ public abstract class Job { return this; } + /** + * Specifies a delay (in milliseconds) before the job runs. Default is 0. + */ + public @NonNull Builder setInitialDelay(long initialDelay) { + this.initialDelay = initialDelay; + return this; + } + public @NonNull Parameters build() { - return new Parameters(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly, globalPriority, queuePriority); + return new Parameters(id, createTime, lifespan, maxAttempts, maxInstancesForFactory, maxInstancesForQueue, queue, constraintKeys, inputData, memoryOnly, globalPriority, queuePriority, initialDelay); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java index c26045450f..df73b710c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -449,7 +449,8 @@ class JobController { false, job.getParameters().isMemoryOnly(), job.getParameters().getGlobalPriority(), - job.getParameters().getQueuePriority()); + job.getParameters().getQueuePriority(), + job.getParameters().getInitialDelay()); List constraintSpecs = Stream.of(job.getParameters().getConstraintKeys()) .map(key -> new ConstraintSpec(jobSpec.getId(), key, jobSpec.isMemoryOnly())) @@ -476,7 +477,7 @@ class JobController { constraints.add(constraintInstantiator.instantiate(key)); } - scheduler.schedule(0, constraints); + scheduler.schedule(job.getParameters().getInitialDelay(), constraints); } } @@ -558,7 +559,8 @@ class JobController { jobSpec.isRunning(), jobSpec.isMemoryOnly(), jobSpec.getGlobalPriority(), - jobSpec.getQueuePriority()); + jobSpec.getQueuePriority(), + jobSpec.getInitialDelay()); } interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java index bb4706ea21..85d1164dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobMigrator.java @@ -73,7 +73,8 @@ public class JobMigrator { jobSpec.isRunning(), jobSpec.isMemoryOnly(), jobSpec.getGlobalPriority(), - jobSpec.getQueuePriority()); + jobSpec.getQueuePriority(), + jobSpec.getInitialDelay()); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.kt b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.kt index 491991904b..6eca262824 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.kt @@ -15,7 +15,8 @@ data class JobSpec( val isRunning: Boolean, val isMemoryOnly: Boolean, val globalPriority: Int, - val queuePriority: Int + val queuePriority: Int, + val initialDelay: Long ) { fun withNextBackoffInterval(updated: Long): JobSpec { @@ -27,7 +28,7 @@ data class JobSpec( } override fun toString(): String { - return "id: JOB::$id | factoryKey: $factoryKey | queueKey: $queueKey | createTime: $createTime | lastRunAttemptTime: $lastRunAttemptTime | nextBackoffInterval: $nextBackoffInterval | runAttempt: $runAttempt | maxAttempts: $maxAttempts | lifespan: $lifespan | isRunning: $isRunning | memoryOnly: $isMemoryOnly | globalPriority: $globalPriority | queuePriorty: $queuePriority" + return "id: JOB::$id | factoryKey: $factoryKey | queueKey: $queueKey | createTime: $createTime | lastRunAttemptTime: $lastRunAttemptTime | nextBackoffInterval: $nextBackoffInterval | runAttempt: $runAttempt | maxAttempts: $maxAttempts | lifespan: $lifespan | isRunning: $isRunning | memoryOnly: $isMemoryOnly | globalPriority: $globalPriority | queuePriorty: $queuePriority | initialDelay: $initialDelay" } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.kt index 2c6657273f..e0bc087b98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.kt @@ -499,9 +499,18 @@ class FastJobStorage(private val jobDatabase: JobDatabase) : JobStorage { /** * Whether or not the job's eligible to be run based off of it's [Job.nextBackoffInterval] and other properties. + * Jobs that were created in the future with respect to the current time are automatically eligible */ private fun MinimalJobSpec.hasEligibleRunTime(currentTime: Long): Boolean { - return this.lastRunAttemptTime > currentTime || (this.lastRunAttemptTime + this.nextBackoffInterval) < currentTime + val isTimeTravel = this.createTime > currentTime || this.lastRunAttemptTime > currentTime + if (isTimeTravel) { + return true + } + + val initialDelaySatisfied = this.createTime + this.initialDelay < currentTime + val backoffSatisfied = this.lastRunAttemptTime + this.nextBackoffInterval < currentTime + + return initialDelaySatisfied && backoffSatisfied } private fun getSingleLayerOfDependencySpecsThatDependOnJob(jobSpecId: String): List { @@ -574,6 +583,7 @@ fun JobSpec.toMinimalJobSpec(): MinimalJobSpec { globalPriority = this.globalPriority, queuePriority = this.queuePriority, isRunning = this.isRunning, - isMemoryOnly = this.isMemoryOnly + isMemoryOnly = this.isMemoryOnly, + initialDelay = this.initialDelay ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 695b61d57f..20706ed2a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -146,6 +146,7 @@ public final class JobManagerFactories { put(CopyAttachmentToArchiveJob.KEY, new CopyAttachmentToArchiveJob.Factory()); put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory()); put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory()); + put(NewLinkedDeviceNotificationJob.KEY, new NewLinkedDeviceNotificationJob.Factory()); put(DeviceNameChangeJob.KEY, new DeviceNameChangeJob.Factory()); put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MinimalJobSpec.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MinimalJobSpec.kt index 54351103c0..b31fab41e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MinimalJobSpec.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MinimalJobSpec.kt @@ -19,5 +19,6 @@ data class MinimalJobSpec( val globalPriority: Int, val queuePriority: Int, val isRunning: Boolean, - val isMemoryOnly: Boolean + val isMemoryOnly: Boolean, + val initialDelay: Long ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/NewLinkedDeviceNotificationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/NewLinkedDeviceNotificationJob.kt new file mode 100644 index 0000000000..7590a3e08e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/NewLinkedDeviceNotificationJob.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.jobs + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import org.signal.core.util.PendingIntentFlags.cancelCurrent +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.NewLinkedDeviceNotificationJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.ServiceUtil +import kotlin.random.Random +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes + +/** + * Notifies users that a device has been linked to their account at a randomly time 1 - 3 hours after it was created + */ +class NewLinkedDeviceNotificationJob private constructor( + private val data: NewLinkedDeviceNotificationJobData, + parameters: Parameters +) : Job(parameters) { + companion object { + const val KEY: String = "NewLinkedDeviceNotificationJob" + private val TAG = Log.tag(NewLinkedDeviceNotificationJob::class.java) + + @JvmStatic + fun enqueue(deviceId: Int, deviceCreatedAt: Long) { + AppDependencies.jobManager.add(NewLinkedDeviceNotificationJob(deviceId, deviceCreatedAt)) + } + + /** + * Generates a random delay between 1 - 3 hours in milliseconds + */ + private fun getRandomDelay(): Long { + val delay = Random.nextInt(60, 180) + return delay.minutes.inWholeMilliseconds + } + } + + constructor( + deviceId: Int, + deviceCreatedAt: Long + ) : this( + NewLinkedDeviceNotificationJobData(deviceId, deviceCreatedAt), + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("NewLinkedDeviceNotificationJob") + .setInitialDelay(getRandomDelay()) + .setLifespan(7.days.inWholeMilliseconds) + .setMaxAttempts(Parameters.UNLIMITED) + .build() + ) + + override fun serialize(): ByteArray = data.encode() + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + if (!Recipient.self().isRegistered) { + Log.w(TAG, "Not registered") + return Result.failure() + } + + if (NotificationChannels.getInstance().areNotificationsEnabled()) { + val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.linkedDevices(context), cancelCurrent()) + val builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().NEW_LINKED_DEVICE) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.NewLinkedDeviceNotification__you_linked_new_device)) + .setContentText(context.getString(R.string.NewLinkedDeviceNotification__a_new_device_was_linked, DateUtils.getOnlyTimeString(context, data.deviceCreatedAt))) + .setContentIntent(pendingIntent) + + ServiceUtil.getNotificationManager(context).notify(NotificationIds.NEW_LINKED_DEVICE, builder.build()) + } + SignalStore.misc.newLinkedDeviceId = data.deviceId + SignalStore.misc.newLinkedDeviceCreatedTime = data.deviceCreatedAt + return Result.success() + } + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): NewLinkedDeviceNotificationJob = NewLinkedDeviceNotificationJob(NewLinkedDeviceNotificationJobData.ADAPTER.decode(serializedData!!), parameters) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index ea5a7dd3e2..bc18296fd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -38,6 +38,8 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto private const val LAST_NETWORK_RESET_TIME = "misc.last_network_reset_time" private const val LAST_WEBSOCKET_CONNECT_TIME = "misc.last_websocket_connect_time" private const val LAST_CONNECTIVITY_WARNING_TIME = "misc.last_connectivity_warning_time" + private const val NEW_LINKED_DEVICE_ID = "misc.new_linked_device_id" + private const val NEW_LINKED_DEVICE_CREATED_TIME = "misc.new_linked_device_created_time" } public override fun onFirstEverAppLaunch() { @@ -257,4 +259,14 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto * The last time we prompted the user regarding a [org.thoughtcrime.securesms.util.ConnectivityWarning]. */ var lastConnectivityWarningTime: Long by longValue(LAST_CONNECTIVITY_WARNING_TIME, 0) + + /** + * The device id of the device that was recently linked + */ + var newLinkedDeviceId: Int by integerValue(NEW_LINKED_DEVICE_ID, 0) + + /** + * The time, in milliseconds, that the device was created at + */ + var newLinkedDeviceCreatedTime: Long by longValue(NEW_LINKED_DEVICE_CREATED_TIME, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index c1da10c389..69134de483 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -137,7 +137,7 @@ class LinkDeviceFragment : ComposeFragment() { Log.i(TAG, "Acquiring wake lock for linked device") linkDeviceWakeLock.acquire() } - DialogState.Unlinking -> Unit + DialogState.Unlinking, is DialogState.DeviceUnlinked -> Unit } } @@ -206,7 +206,8 @@ class LinkDeviceFragment : ComposeFragment() { onEditDevice = { device -> viewModel.setDeviceToEdit(device) navController.safeNavigate(R.id.action_linkDeviceFragment_to_editDeviceNameFragment) - } + }, + onDialogDismissed = { viewModel.onDialogDismissed() } ) } } @@ -257,7 +258,8 @@ fun DeviceListScreen( onSyncFailureRetryRequested: () -> Unit = {}, onSyncFailureIgnored: () -> Unit = {}, onSyncCancelled: () -> Unit = {}, - onEditDevice: (Device) -> Unit = {} + onEditDevice: (Device) -> Unit = {}, + onDialogDismissed: () -> Unit = {} ) { // If a bottom sheet is showing, we don't want the spinner underneath if (!state.bottomSheetVisible) { @@ -291,6 +293,15 @@ fun DeviceListScreen( onDeny = onSyncFailureIgnored ) } + is DialogState.DeviceUnlinked -> { + val createdAt = DateUtils.getOnlyTimeString(LocalContext.current, state.dialogState.deviceCreatedAt) + Dialogs.SimpleMessageDialog( + title = stringResource(id = R.string.LinkDeviceFragment__device_unlinked), + message = stringResource(id = R.string.LinkDeviceFragment__the_device_that_was, createdAt), + dismiss = stringResource(id = R.string.LinkDeviceFragment__ok), + onDismiss = onDialogDismissed + ) + } } } @@ -595,3 +606,17 @@ private fun DeviceListScreenSyncingFailedPreview() { ) } } + +@SignalPreview +@Composable +private fun DeviceListScreenDeviceUnlinkedPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.DeviceUnlinked(1736454440342), + seenBioAuthEducationSheet = true, + seenQrEducationSheet = true + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index 53b17ad968..ec077ff400 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -31,6 +31,7 @@ data class LinkDeviceSettingsState( data class SyncingMessages(val deviceId: Int, val deviceCreatedAt: Long) : DialogState data object SyncingTimedOut : DialogState data class SyncingFailed(val deviceId: Int, val deviceCreatedAt: Long) : DialogState + data class DeviceUnlinked(val deviceCreatedAt: Long) : DialogState } sealed interface OneTimeEvent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 36b6bae685..dd0c7cc38d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -9,14 +9,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob +import org.thoughtcrime.securesms.jobs.NewLinkedDeviceNotificationJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.getPlaintextDeviceName import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.OneTimeEvent import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.QrCodeState +import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.ServiceUtil import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.backup.MessageBackupKey import org.whispersystems.signalservice.api.link.TransferArchiveError @@ -36,7 +40,32 @@ class LinkDeviceViewModel : ViewModel() { val state = _state.asStateFlow() fun initialize() { - loadDevices() + loadDevices(initialLoad = true) + } + + /** + * Checks for the existence of a newly linked device and shows a dialog if it has since been unlinked + */ + private fun checkForNewDevice(devices: List) { + val newLinkedDeviceId = SignalStore.misc.newLinkedDeviceId + val newLinkedDeviceCreatedAt = SignalStore.misc.newLinkedDeviceCreatedTime + + val hasNewLinkedDevice = newLinkedDeviceId > 0 + if (hasNewLinkedDevice) { + ServiceUtil.getNotificationManager(AppDependencies.application).cancel(NotificationIds.NEW_LINKED_DEVICE) + SignalStore.misc.newLinkedDeviceId = 0 + SignalStore.misc.newLinkedDeviceCreatedTime = 0 + } + + val isMissingNewLinkedDevice = devices.none { device -> device.id == newLinkedDeviceId && device.createdMillis == newLinkedDeviceCreatedAt } + + val dialogState = if (hasNewLinkedDevice && isMissingNewLinkedDevice) { + DialogState.DeviceUnlinked(newLinkedDeviceCreatedAt) + } else { + DialogState.None + } + + _state.update { it.copy(dialogState = dialogState) } } fun setDeviceToRemove(device: Device?) { @@ -66,7 +95,7 @@ class LinkDeviceViewModel : ViewModel() { } } - private fun loadDevices() { + private fun loadDevices(initialLoad: Boolean = false) { _state.value = _state.value.copy( deviceListLoading = true, showFrontCamera = null @@ -80,6 +109,9 @@ class LinkDeviceViewModel : ViewModel() { deviceListLoading = false ) } else { + if (initialLoad) { + checkForNewDevice(devices) + } _state.update { it.copy( oneTimeEvent = OneTimeEvent.None, @@ -143,6 +175,10 @@ class LinkDeviceViewModel : ViewModel() { } } + fun onDialogDismissed() { + _state.update { it.copy(dialogState = DialogState.None) } + } + fun addDevice(shouldSync: Boolean) = viewModelScope.launch(Dispatchers.IO) { val linkUri: Uri = _state.value.linkUri!! @@ -243,7 +279,8 @@ class LinkDeviceViewModel : ViewModel() { return } - Log.d(TAG, "[addDeviceWithSync] Found a linked device!") + Log.d(TAG, "[addDeviceWithSync] Found a linked device! Creating notification job.") + NewLinkedDeviceNotificationJob.enqueue(waitResult.id, waitResult.created) _state.update { it.copy( @@ -319,6 +356,8 @@ class LinkDeviceViewModel : ViewModel() { if (waitResult == null) { Log.i(TAG, "No linked device found!") } else { + Log.i(TAG, "Found a linked device! Creating notification job.") + NewLinkedDeviceNotificationJob.enqueue(waitResult.id, waitResult.created) _state.update { it.copy(oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDeviceName())) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index a0c7a71a30..9afa7e026b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity; import org.thoughtcrime.securesms.profiles.username.NewWaysToConnectDialogFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; @@ -110,6 +111,7 @@ public final class Megaphones { return new LinkedHashMap<>() {{ put(Event.PINS_FOR_ALL, new PinsForAllSchedule()); put(Event.CLIENT_DEPRECATED, SignalStore.misc().isClientDeprecated() ? ALWAYS : NEVER); + put(Event.NEW_LINKED_DEVICE, shouldShowNewLinkedDeviceMegaphone() ? ALWAYS: NEVER); put(Event.NOTIFICATIONS, shouldShowNotificationsMegaphone(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(30)) : NEVER); put(Event.GRANT_FULL_SCREEN_INTENT, shouldShowGrantFullScreenIntentPermission(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER); put(Event.BACKUP_SCHEDULE_PERMISSION, shouldShowBackupSchedulePermissionMegaphone(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER); @@ -166,6 +168,8 @@ public final class Megaphones { return buildGrantFullScreenIntentPermission(context); case PNP_LAUNCH: return buildPnpLaunchMegaphone(); + case NEW_LINKED_DEVICE: + return buildNewLinkedDeviceMegaphone(context); default: throw new IllegalArgumentException("Event not handled!"); } @@ -184,7 +188,7 @@ public final class Megaphones { return new Megaphone.Builder(Event.LINKED_DEVICE_INACTIVE, Megaphone.Style.BASIC) .setTitle(R.string.LinkedDeviceInactiveMegaphone_title) .setBody(context.getResources().getQuantityString(R.plurals.LinkedDeviceInactiveMegaphone_body, expiringDays, device.name, expiringDays)) - .setImage(R.drawable.ic_inactive_linked_device) + .setImage(R.drawable.symbol_linked_device) .setActionButton(R.string.LinkedDeviceInactiveMegaphone_got_it_button_label, (megaphone, listener) -> { listener.onMegaphoneSnooze(Event.LINKED_DEVICE_INACTIVE); }) @@ -294,6 +298,23 @@ public final class Megaphones { .build(); } + private static @NonNull Megaphone buildNewLinkedDeviceMegaphone(@NonNull Context context) { + String createdAt = DateUtils.getOnlyTimeString(context, SignalStore.misc().getNewLinkedDeviceCreatedTime()); + return new Megaphone.Builder(Event.NEW_LINKED_DEVICE, Megaphone.Style.BASIC) + .setTitle(R.string.NewLinkedDeviceNotification__you_linked_new_device) + .setBody(context.getString(R.string.NewLinkedDeviceMegaphone__a_new_device_was_linked, createdAt)) + .setImage(R.drawable.symbol_linked_device) + .setActionButton(R.string.NewLinkedDeviceMegaphone__ok, (megaphone, listener) -> { + SignalStore.misc().setNewLinkedDeviceId(0); + SignalStore.misc().setNewLinkedDeviceCreatedTime(0); + listener.onMegaphoneSnooze(Event.NEW_LINKED_DEVICE); + }) + .setSecondaryButton(R.string.NewLinkedDeviceMegaphone__view_device, (megaphone, listener) -> { + listener.onMegaphoneNavigationRequested(AppSettingsActivity.linkedDevices(context)); + }) + .build(); + } + private static @NonNull Megaphone buildTurnOffCircumventionMegaphone(@NonNull Context context) { return new Megaphone.Builder(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, Megaphone.Style.BASIC) .setTitle(R.string.CensorshipCircumventionMegaphone_turn_off_censorship_circumvention) @@ -419,6 +440,10 @@ public final class Megaphones { return SignalStore.onboarding().hasOnboarding(context); } + private static boolean shouldShowNewLinkedDeviceMegaphone() { + return SignalStore.misc().getNewLinkedDeviceId() > 0 && !NotificationChannels.getInstance().areNotificationsEnabled(); + } + private static boolean shouldShowTurnOffCircumventionMegaphone() { return AppDependencies.getSignalServiceNetworkAccess().isCensored() && SignalStore.misc().isServiceReachableWithoutCircumvention(); @@ -525,7 +550,8 @@ public final class Megaphones { BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"), SET_UP_YOUR_USERNAME("set_up_your_username"), PNP_LAUNCH("pnp_launch"), - GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"); + GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"), + NEW_LINKED_DEVICE("new_linked_device"); private final String key; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index d0e3dfd42d..e81defa6e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -80,6 +80,7 @@ public class NotificationChannels { public final String CALL_STATUS = "call_status"; public final String APP_ALERTS = "app_alerts"; public final String ADDITIONAL_MESSAGE_NOTIFICATIONS = "additional_message_notifications"; + public final String NEW_LINKED_DEVICE = "new_linked_device"; private static volatile NotificationChannels instance; @@ -634,6 +635,7 @@ public class NotificationChannels { NotificationChannel callStatus = new NotificationChannel(CALL_STATUS, context.getString(R.string.NotificationChannel_call_status), NotificationManager.IMPORTANCE_LOW); NotificationChannel appAlerts = new NotificationChannel(APP_ALERTS, context.getString(R.string.NotificationChannel_critical_app_alerts), NotificationManager.IMPORTANCE_HIGH); NotificationChannel additionalMessageNotifications = new NotificationChannel(ADDITIONAL_MESSAGE_NOTIFICATIONS, context.getString(R.string.NotificationChannel_additional_message_notifications), NotificationManager.IMPORTANCE_HIGH); + NotificationChannel newLinkedDevice = new NotificationChannel(NEW_LINKED_DEVICE, context.getString(R.string.NotificationChannel_new_linked_device), NotificationManager.IMPORTANCE_HIGH); messages.setGroup(CATEGORY_MESSAGES); setVibrationEnabled(messages, SignalStore.settings().isMessageVibrateEnabled()); @@ -651,7 +653,7 @@ public class NotificationChannels { callStatus.setShowBadge(false); appAlerts.setShowBadge(false); - notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes, joinEvents, background, callStatus, appAlerts, additionalMessageNotifications)); + notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes, joinEvents, background, callStatus, appAlerts, additionalMessageNotifications, newLinkedDevice)); if (BuildConfig.MANAGES_APP_UPDATES) { NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_DEFAULT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index a0bd2d38ef..d660265518 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -31,6 +31,7 @@ public final class NotificationIds { public static final int MESSAGE_DELIVERY_FAILURE = 800000; public static final int STORY_MESSAGE_DELIVERY_FAILURE = 900000; public static final int UNREGISTERED_NOTIFICATION_ID = 20230102; + public static final int NEW_LINKED_DEVICE = 120400; private NotificationIds() { } diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index e31533e33f..8b47990259 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -151,4 +151,9 @@ message BackupMessagesJobData { string dataFile = 2; ResumableUpload uploadSpec = 3; string resumableUri = 4; +} + +message NewLinkedDeviceNotificationJobData { + uint32 deviceId = 1; + uint64 deviceCreatedAt = 2; } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_inactive_linked_device.xml b/app/src/main/res/drawable/ic_inactive_linked_device.xml deleted file mode 100644 index 82db92cf8a..0000000000 --- a/app/src/main/res/drawable/ic_inactive_linked_device.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/symbol_linked_device.xml b/app/src/main/res/drawable/symbol_linked_device.xml new file mode 100644 index 0000000000..6d1b753873 --- /dev/null +++ b/app/src/main/res/drawable/symbol_linked_device.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a82dda1227..f48671a03a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1020,6 +1020,12 @@ Linking cancelled Do not close app + + Device unlinked + + The device that was recently linked at %1$s is no longer linked. + + OK @@ -1065,6 +1071,11 @@ No old messages or media will be transferred to your linked device + + You linked a new device + + A new device was linked to your account at %1$s. Tap to view. + Unlink \"%s\"? By unlinking this device, it will no longer be able to send or receive messages. @@ -1650,6 +1661,13 @@ Chat colors Add a profile photo + + A new device was linked to your account at %1$s. + + View device + + OK + Replies @@ -2942,6 +2960,8 @@ Critical app alerts Additional message notifications + + New linked device diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.kt index b4b9e4bfa5..ec7cb0029b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.kt @@ -115,7 +115,8 @@ class JobMigratorTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) return mockk { every { debugGetJobSpecs(any()) } returns listOf(job) diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.kt index 3141510bd2..d7ec5d8e76 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.kt @@ -345,7 +345,7 @@ class FastJobStorageTest { @Test fun `getNextEligibleJob - none when next run time is after current time`() { - val currentTime = 0L + val currentTime = 1L val fullSpec = FullSpec(jobSpec(id = "1", factoryKey = "f1", queueKey = "q", lastRunAttemptTime = 0, nextBackoffInterval = 10), emptyList(), emptyList()) val subject = FastJobStorage(mockDatabase(listOf(fullSpec))) @@ -450,40 +450,40 @@ class FastJobStorageTest { val subject = FastJobStorage(mockDatabase(listOf(fullSpec1, fullSpec2, fullSpec3, fullSpec4, fullSpec5, fullSpec6, fullSpec7, fullSpec8, fullSpec9, fullSpec10, fullSpec11, fullSpec12))) subject.init() - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec2.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec2.jobSpec) subject.deleteJob(fullSpec2.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec6.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec6.jobSpec) subject.deleteJob(fullSpec6.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec11.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec11.jobSpec) subject.deleteJob(fullSpec11.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec10.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec10.jobSpec) subject.deleteJob(fullSpec10.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec12.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec12.jobSpec) subject.deleteJob(fullSpec12.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec3.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec3.jobSpec) subject.deleteJob(fullSpec3.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec5.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec5.jobSpec) subject.deleteJob(fullSpec5.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec9.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec9.jobSpec) subject.deleteJob(fullSpec9.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec1.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec1.jobSpec) subject.deleteJob(fullSpec1.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec4.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec4.jobSpec) subject.deleteJob(fullSpec4.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec7.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec7.jobSpec) subject.deleteJob(fullSpec7.jobSpec.id) - assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec8.jobSpec) + assertThat(subject.getNextEligibleJob(15, NO_PREDICATE)).isEqualTo(fullSpec8.jobSpec) } @Test @@ -799,6 +799,20 @@ class FastJobStorageTest { assertThat(subject.getNextEligibleJob(100, NO_PREDICATE)).isEqualTo(olderJob) } + @Test + fun `getNextEligibleJob - jobs with initial delay will not run until after the delay`() { + val fullSpec1 = FullSpec(jobSpec(id = "1", factoryKey = "f1", queueKey = "q1", createTime = 1, initialDelay = 10), emptyList(), emptyList()) + val fullSpec2 = FullSpec(jobSpec(id = "2", factoryKey = "f2", queueKey = "q2", createTime = 2, initialDelay = 0), emptyList(), emptyList()) + + val subject = FastJobStorage(mockDatabase(listOf(fullSpec1, fullSpec2))) + subject.init() + + assertThat(subject.getNextEligibleJob(10, NO_PREDICATE)).isEqualTo(fullSpec2.jobSpec) + subject.deleteJob(fullSpec2.jobSpec.id) + + assertThat(subject.getNextEligibleJob(20, NO_PREDICATE)).isEqualTo(fullSpec1.jobSpec) + } + @Test fun `deleteJobs - writes to database`() { val database = mockDatabase(DataSet1.FULL_SPECS) @@ -1043,7 +1057,8 @@ class FastJobStorageTest { isRunning: Boolean = false, isMemoryOnly: Boolean = false, globalPriority: Int = 0, - queuePriority: Int = 0 + queuePriority: Int = 0, + initialDelay: Long = 0 ): JobSpec { return JobSpec( id = id, @@ -1060,7 +1075,8 @@ class FastJobStorageTest { isRunning = isRunning, isMemoryOnly = isMemoryOnly, globalPriority = globalPriority, - queuePriority = queuePriority + queuePriority = queuePriority, + initialDelay = initialDelay ) } @@ -1080,7 +1096,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val JOB_2 = JobSpec( id = "id2", @@ -1097,7 +1114,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val JOB_3 = JobSpec( id = "id3", @@ -1114,7 +1132,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val CONSTRAINT_1 = ConstraintSpec(jobSpecId = "id1", factoryKey = "f1", isMemoryOnly = false) @@ -1163,7 +1182,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = true, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val CONSTRAINT_1 = ConstraintSpec(jobSpecId = "id1", factoryKey = "f1", isMemoryOnly = true) val FULL_SPEC_1 = FullSpec(JOB_1, listOf(CONSTRAINT_1), emptyList()) @@ -1186,7 +1206,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val JOB_2 = JobSpec( id = "id2", @@ -1203,7 +1224,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val JOB_3 = JobSpec( id = "id3", @@ -1220,7 +1242,8 @@ class FastJobStorageTest { isRunning = false, isMemoryOnly = false, globalPriority = 0, - queuePriority = 0 + queuePriority = 0, + initialDelay = 0 ) val DEPENDENCY_1 = DependencySpec(jobId = "id1", dependsOnJobId = "id2", isMemoryOnly = false)