mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 10:20:25 +01:00
Notify a user when they link a device.
This commit is contained in:
committed by
Greyson Parrelli
parent
919648b94b
commit
d4c8c16df3
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +449,8 @@ class JobController {
|
||||
false,
|
||||
job.getParameters().isMemoryOnly(),
|
||||
job.getParameters().getGlobalPriority(),
|
||||
job.getParameters().getQueuePriority());
|
||||
job.getParameters().getQueuePriority(),
|
||||
job.getParameters().getInitialDelay());
|
||||
|
||||
List<ConstraintSpec> 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 {
|
||||
|
||||
@@ -73,7 +73,8 @@ public class JobMigrator {
|
||||
jobSpec.isRunning(),
|
||||
jobSpec.isMemoryOnly(),
|
||||
jobSpec.getGlobalPriority(),
|
||||
jobSpec.getQueuePriority());
|
||||
jobSpec.getQueuePriority(),
|
||||
jobSpec.getInitialDelay());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<DependencySpec> {
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<NewLinkedDeviceNotificationJob?> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NewLinkedDeviceNotificationJob = NewLinkedDeviceNotificationJob(NewLinkedDeviceNotificationJobData.ADAPTER.decode(serializedData!!), parameters)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Device>) {
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() { }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user