From f1b231ca38d31dccaa23706e99ff610251efb337 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 7 May 2026 11:27:33 -0300 Subject: [PATCH] Wire in inactive-primary websocket alert. --- .../securesms/keyvalue/AccountValues.kt | 4 ++ .../securesms/megaphone/Megaphones.java | 42 +++++++++++--- .../net/SignalWebSocketHealthMonitor.kt | 11 ++++ .../drawable/megaphone_inactive_primary.xml | 57 +++++++++++++++++++ app/src/main/res/values/strings.xml | 10 ++++ .../dependencies/DemoNetworkController.kt | 1 + .../api/websocket/HealthMonitor.kt | 2 + .../websocket/LibSignalChatConnection.kt | 1 + .../websocket/LibSignalChatConnectionTest.kt | 1 + 9 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/megaphone_inactive_primary.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 5eae3ffe85..98be79057a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -84,6 +84,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) private const val KEY_ACCOUNT_REGISTERED_AT = "account.registered_at" private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices" + private const val KEY_HAS_INACTIVE_PRIMARY_DEVICE_ALERT = "account.has_inactive_primary_device_alert" private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool" private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool" @@ -487,6 +488,9 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) val isLinkedDevice: Boolean get() = !isPrimaryDevice + @get:JvmName("hasInactivePrimaryDeviceAlert") + var hasInactivePrimaryDeviceAlert: Boolean by booleanValue(KEY_HAS_INACTIVE_PRIMARY_DEVICE_ALERT, false) + /** The local user's full username (nickname.discriminator), if set. */ var username: String? get() { 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 a630ec1e46..3228686999 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -11,7 +11,6 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.core.app.NotificationManagerCompat; -import java.util.stream.Collectors; import com.bumptech.glide.Glide; import org.signal.core.util.DiskUtil; @@ -44,6 +43,7 @@ import org.thoughtcrime.securesms.profiles.username.NewWaysToConnectDialogFragme import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.ByteUnit; +import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.RemoteConfig; @@ -59,6 +59,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * Creating a new megaphone: @@ -91,11 +92,11 @@ public final class Megaphones { List megaphones = buildDisplayOrder(context, records).entrySet().stream() .filter(e -> { - MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey())); - MegaphoneSchedule schedule = e.getValue(); + MegaphoneRecord record = Objects.requireNonNull(records.get(e.getKey())); + MegaphoneSchedule schedule = e.getValue(); - return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), record.getFirstVisible(), currentTime); - }) + return !record.isFinished() && schedule.shouldDisplay(record.getSeenCount(), record.getLastSeen(), record.getFirstVisible(), currentTime); + }) .map(Map.Entry::getKey) .map(records::get) .map(record -> Megaphones.forRecord(context, record)) @@ -118,14 +119,14 @@ 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.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); put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER); put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER); put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER); - put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)): NEVER); + put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER); // Specifically putting backup reminders here, above PIN reminders put(Event.BACKUP_LOW_STORAGE_UPSELL, shouldShowBackupLowStorageUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER); @@ -142,6 +143,7 @@ public final class Megaphones { put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER); put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER); put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER); + put(Event.INACTIVE_PRIMARY, shouldShowInactivePrimaryMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER); }}; } @@ -203,6 +205,8 @@ public final class Megaphones { return buildVerifyBackupKeyMegaphone(); case USE_NEW_ON_DEVICE_BACKUPS: return buildUseNewOnDeviceBackupsMegaphone(); + case INACTIVE_PRIMARY: + return buildInactivePrimaryMegaphone(); default: throw new IllegalArgumentException("Event not handled!"); } @@ -527,6 +531,23 @@ public final class Megaphones { .build(); } + private static @NonNull Megaphone buildInactivePrimaryMegaphone() { + return new Megaphone.Builder(Event.INACTIVE_PRIMARY, Megaphone.Style.BASIC) + .setImage(R.drawable.megaphone_inactive_primary) + .setTitle(R.string.InactivePrimary__title) + .setBody(R.string.InactivePrimary__body) + .setActionButton(R.string.InactivePrimary__got_it, (megaphone, controller) -> { + controller.onMegaphoneSnooze(Event.INACTIVE_PRIMARY); + }) + .setSecondaryButton(R.string.InactivePrimary__learn_more, ((megaphone, controller) -> { + CommunicationActions.openBrowserLink( + controller.getMegaphoneActivity(), + controller.getMegaphoneActivity().getString(R.string.inactive_primary_support) + ); + })) + .build(); + } + private static boolean shouldShowOnboardingMegaphone(@NonNull Context context) { return SignalStore.account().isPrimaryDevice() && SignalStore.onboarding().hasOnboarding(context); } @@ -595,6 +616,10 @@ public final class Megaphones { return SignalStore.account().isPrimaryDevice() && TextUtils.isEmpty(SignalStore.account().getUsername()) && !SignalStore.uiHints().hasCompletedUsernameOnboarding(); } + private static boolean shouldShowInactivePrimaryMegaphone() { + return SignalStore.account().isLinkedDevice() && SignalStore.account().hasInactivePrimaryDeviceAlert(); + } + private static boolean shouldShowGenericBackupsMegaphone(@NonNull Context context) { if (!RemoteConfig.backupsMegaphone()) { return false; @@ -756,7 +781,8 @@ public final class Megaphones { BACKUP_MEDIA_SIZE_UPSELL("backup_media_upsell"), BACKUP_LOW_STORAGE_UPSELL("backup_storage_upsell"), VERIFY_BACKUP_KEY("verify_backup_key"), - USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups"); + USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups"), + INACTIVE_PRIMARY("inactive_primary"); private final String key; diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt index 913992870a..aa3350e4ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalWebSocketHealthMonitor.kt @@ -39,6 +39,8 @@ class SignalWebSocketHealthMonitor( private val KEEP_ALIVE_SEND_CADENCE: Duration = OkHttpWebSocketConnection.KEEPALIVE_FREQUENCY_SECONDS.seconds private val KEEP_ALIVE_SEND_CADENCE_BACKGROUND: Duration = 60.seconds + + private const val ALERT_IDLE_PRIMARY_DEVICE = "idle-primary-device" } private val executor: Executor = Executors.newSingleThreadExecutor() @@ -136,6 +138,15 @@ class SignalWebSocketHealthMonitor( } } + override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) { + if (!isIdentifiedWebSocket) { + return + } + executor.execute { + SignalStore.account.hasInactivePrimaryDeviceAlert = SignalStore.account.isLinkedDevice && alerts.contains(ALERT_IDLE_PRIMARY_DEVICE) + } + } + private fun onConnectingTimeout() { executor.execute { webSocket?.forceNewWebSocket() diff --git a/app/src/main/res/drawable/megaphone_inactive_primary.xml b/app/src/main/res/drawable/megaphone_inactive_primary.xml new file mode 100644 index 0000000000..c756578d27 --- /dev/null +++ b/app/src/main/res/drawable/megaphone_inactive_primary.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 260cf4e59c..3e51c1ddec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ https://support.signal.org/hc/articles/5538911756954 https://support.signal.org/hc/articles/360031949872#pending https://support.signal.org/hc/articles/360031949872#donate + https://support.signal.org/hc/articles/9021007554074 https://play.google.com/store/account/subscriptions?sku=%1$s&package=%2$s @@ -8241,6 +8242,15 @@ Not now + + Open Signal on your phone + + To keep your account active, open Signal on your primary device. + + Learn more + + Got it + To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file. diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index 9b4870e43f..d79151a38e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -550,6 +550,7 @@ class DemoNetworkController( val healthMonitor = object : HealthMonitor { override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {} override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {} + override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) {} } val libSignalConnection = LibSignalChatConnection( diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt index 2135d4cb83..ec7d3c20e4 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/HealthMonitor.kt @@ -7,4 +7,6 @@ interface HealthMonitor { fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) + + fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt index 01099f23e3..e1ac4db1af 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnection.kt @@ -674,6 +674,7 @@ class LibSignalChatConnection( if (alerts.isNotEmpty()) { Log.i(TAG, "$name Received ${alerts.size} alerts from the server") } + healthMonitor.onReceivedAlerts(alerts, isIdentifiedWebSocket = chat is AuthenticatedChatConnection) } } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt index f786154a30..f171e9d89f 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/websocket/LibSignalChatConnectionTest.kt @@ -54,6 +54,7 @@ class LibSignalChatConnectionTest { clearAllMocks() every { healthMonitor.onMessageError(any(), any()) } every { healthMonitor.onKeepAliveResponse(any(), any()) } + every { healthMonitor.onReceivedAlerts(any(), any()) } // NB: We provide default success behavior mocks here to cut down on boilerplate later, but it is // expected that some tests will override some of these to test failures.