Notify a user when they link a device.

This commit is contained in:
Michelle Tang
2025-01-10 17:27:32 -05:00
committed by Greyson Parrelli
parent 919648b94b
commit d4c8c16df3
22 changed files with 382 additions and 91 deletions

View File

@@ -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")

View File

@@ -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);
}
}
}

View File

@@ -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 {

View File

@@ -73,7 +73,8 @@ public class JobMigrator {
jobSpec.isRunning(),
jobSpec.isMemoryOnly(),
jobSpec.getGlobalPriority(),
jobSpec.getQueuePriority());
jobSpec.getQueuePriority(),
jobSpec.getInitialDelay());
});
}

View File

@@ -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 {

View File

@@ -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
)
}

View File

@@ -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());

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
)
)
}
}

View File

@@ -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 {

View File

@@ -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()))
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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() { }