Wire in inactive-primary websocket alert.

This commit is contained in:
Alex Hart
2026-05-07 11:27:33 -03:00
committed by Michelle Tang
parent dca4351b8b
commit f1b231ca38
9 changed files with 121 additions and 8 deletions
@@ -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() {
@@ -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<Megaphone> 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;
@@ -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<out String>, 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()
@@ -0,0 +1,57 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<group>
<clip-path
android:pathData="M23.5,3.651L40.5,3.651A8.5,8.5 0,0 1,49 12.151L49,53.376A8.5,8.5 0,0 1,40.5 61.876L23.5,61.876A8.5,8.5 0,0 1,15 53.376L15,12.151A8.5,8.5 0,0 1,23.5 3.651z"/>
<path
android:pathData="M23.5,3.651L40.5,3.651A8.5,8.5 0,0 1,49 12.151L49,52.101A8.5,8.5 0,0 1,40.5 60.601L23.5,60.601A8.5,8.5 0,0 1,15 52.101L15,12.151A8.5,8.5 0,0 1,23.5 3.651z"
android:fillColor="#C0CBE2"/>
<path
android:pathData="M16.275,11.726C16.275,7.971 19.319,4.927 23.075,4.927H39.65C42.467,4.927 44.75,7.21 44.75,10.026V52.951C44.75,55.768 42.467,58.051 39.65,58.051H21.375C18.558,58.051 16.275,55.768 16.275,52.951V11.726Z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M18.4,4.926m-10.2,0a10.2,10.2 0,1 1,20.4 0a10.2,10.2 0,1 1,-20.4 0"
android:fillColor="#ffffff"
android:fillAlpha="0.45"/>
<path
android:pathData="M28.5,7L35.5,7A1.5,1.5 0,0 1,37 8.5L37,8.5A1.5,1.5 0,0 1,35.5 10L28.5,10A1.5,1.5 0,0 1,27 8.5L27,8.5A1.5,1.5 0,0 1,28.5 7z"
android:fillColor="#61739A"/>
<path
android:pathData="M22,37L31,22H33.5L42,36L42.5,38L40.5,40H23.5L22,39V37Z"
android:fillColor="#FDDDD7"/>
<path
android:pathData="M32,26.375C31.231,26.375 30.625,27.03 30.685,27.797L31.107,33.175C31.144,33.64 31.533,34 32,34C32.467,34 32.856,33.64 32.893,33.175L33.314,27.797C33.375,27.03 32.769,26.375 32,26.375Z"
android:fillColor="#CB4116"/>
<path
android:pathData="M30.625,36.5C30.625,35.741 31.241,35.125 32,35.125C32.759,35.125 33.375,35.741 33.375,36.5C33.375,37.259 32.759,37.875 32,37.875C31.241,37.875 30.625,37.259 30.625,36.5Z"
android:fillColor="#CB4116"/>
<path
android:pathData="M28.979,22.681C30.331,20.371 33.669,20.371 35.021,22.681L42.66,35.732C44.026,38.065 42.343,41 39.64,41H24.36C21.657,41 19.974,38.065 21.34,35.732L28.979,22.681ZM33.295,23.691C32.715,22.701 31.285,22.701 30.705,23.691L23.066,36.742C22.48,37.742 23.202,39 24.36,39H39.64C40.799,39 41.52,37.742 40.934,36.742L33.295,23.691Z"
android:fillColor="#CB4116"
android:fillType="evenOdd"/>
</group>
<path
android:pathData="M23.5,4.651L40.5,4.651A7.5,7.5 0,0 1,48 12.151L48,53.376A7.5,7.5 0,0 1,40.5 60.876L23.5,60.876A7.5,7.5 0,0 1,16 53.376L16,12.151A7.5,7.5 0,0 1,23.5 4.651z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#61739A"/>
</vector>
+10
View File
@@ -24,6 +24,7 @@
<string name="export_account_data_url" translatable="false">https://support.signal.org/hc/articles/5538911756954</string>
<string name="pending_transfer_url" translatable="false">https://support.signal.org/hc/articles/360031949872#pending</string>
<string name="donate_faq_url" translatable="false">https://support.signal.org/hc/articles/360031949872#donate</string>
<string name="inactive_primary_support" translatable="false">https://support.signal.org/hc/articles/9021007554074</string>
<!-- First placeholder is productId, second placeholder is app package -->
<string name="backup_subscription_management_url">https://play.google.com/store/account/subscriptions?sku=%1$s&amp;package=%2$s</string>
@@ -8241,6 +8242,15 @@
<!-- Button of a megaphone that will snooze the reminder to upgrade to the new local backups type -->
<string name="UseNewOnDeviceBackups__not_now">Not now</string>
<!-- Title of a megaphone shown to prompt the user to open Signal on their primary device -->
<string name="InactivePrimary__title">Open Signal on your phone</string>
<!-- Body of a megaphone shown to prompt the user to open Signal on their primary device -->
<string name="InactivePrimary__body">To keep your account active, open Signal on your primary device.</string>
<!-- Button of a megaphone shown to prompt the user to open Signal on their primary device that will navigate to a support page -->
<string name="InactivePrimary__learn_more">Learn more</string>
<!-- Button of a megaphone shown to prompt the user to open Signal on their primary device that will snooze the megaphone -->
<string name="InactivePrimary__got_it">Got it</string>
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file.</string>
@@ -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<out String>, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
@@ -7,4 +7,6 @@ interface HealthMonitor {
fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean)
fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean)
fun onReceivedAlerts(alerts: Array<out String>, isIdentifiedWebSocket: Boolean)
}
@@ -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)
}
}
}
@@ -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.