Show a megaphone when a device is about to unlink.

This commit is contained in:
Greyson Parrelli
2024-03-19 12:18:27 -04:00
committed by Nicholas Tinsley
parent d7ee9639fd
commit 50149a3803
14 changed files with 320 additions and 42 deletions

View File

@@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
@@ -215,6 +216,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
.addPostRender(GroupRingCleanupJob::enqueue)
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");

View File

@@ -29,6 +29,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.signal.core.util.Base64;
@@ -231,6 +232,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
protected void onPostExecute(Integer result) {
super.onPostExecute(result);
LinkedDeviceInactiveCheckJob.enqueue();
Context context = DeviceActivity.this;
switch (result) {

View File

@@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
@@ -166,6 +167,7 @@ public class DeviceListFragment extends ListFragment
super.onPostExecute(result);
if (result) {
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
LinkedDeviceInactiveCheckJob.enqueue();
} else {
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
}

View File

@@ -17,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
import org.thoughtcrime.securesms.util.AsyncLoader;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@@ -78,50 +79,19 @@ public class DeviceListLoader extends AsyncLoader<List<Device>> {
throw new IOException("Got a DeviceName that wasn't properly populated.");
}
return new Device(deviceInfo.getId(), new String(decryptName(deviceName, SignalStore.account().getAciIdentityKey())), deviceInfo.getCreated(), deviceInfo.getLastSeen());
byte[] plaintext = DeviceNameCipher.decryptDeviceName(deviceName, SignalStore.account().getAciIdentityKey());
if (plaintext == null) {
throw new IOException("Failed to decrypt device name.");
}
return new Device(deviceInfo.getId(), new String(plaintext), deviceInfo.getCreated(), deviceInfo.getLastSeen());
} catch (IOException e) {
Log.w(TAG, "Failed while reading the protobuf.", e);
} catch (GeneralSecurityException | InvalidKeyException e) {
Log.w(TAG, "Failed during decryption.", e);
}
return new Device(deviceInfo.getId(), deviceInfo.getName(), deviceInfo.getCreated(), deviceInfo.getLastSeen());
}
@VisibleForTesting
public static byte[] decryptName(DeviceName deviceName, IdentityKeyPair identityKeyPair) throws InvalidKeyException, GeneralSecurityException {
byte[] syntheticIv = Objects.requireNonNull(deviceName.syntheticIv).toByteArray();
byte[] cipherText = Objects.requireNonNull(deviceName.ciphertext).toByteArray();
ECPrivateKey identityKey = identityKeyPair.getPrivateKey();
ECPublicKey ephemeralPublic = Curve.decodePoint(Objects.requireNonNull(deviceName.ephemeralPublic).toByteArray(), 0);
byte[] masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] cipherKeyPart1 = mac.doFinal("cipher".getBytes());
mac.init(new SecretKeySpec(cipherKeyPart1, "HmacSHA256"));
byte[] cipherKey = mac.doFinal(syntheticIv);
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(new byte[16]));
final byte[] plaintext = cipher.doFinal(cipherText);
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] verificationPart1 = mac.doFinal("auth".getBytes());
mac.init(new SecretKeySpec(verificationPart1, "HmacSHA256"));
byte[] verificationPart2 = mac.doFinal(plaintext);
byte[] ourSyntheticIv = ByteUtil.trim(verificationPart2, 16);
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw new GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.");
}
return plaintext;
}
private static class DeviceComparator implements Comparator<Device> {
@Override

View File

@@ -138,6 +138,7 @@ public final class JobManagerFactories {
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
put(LegacyAttachmentUploadJob.KEY, new LegacyAttachmentUploadJob.Factory());
put(LinkedDeviceInactiveCheckJob.KEY, new LinkedDeviceInactiveCheckJob.Factory());
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory());
put(MarkerJob.KEY, new MarkerJob.Factory());

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
/**
* Designed as a routine check to keep an eye on how active our linked devices are.
*/
class LinkedDeviceInactiveCheckJob private constructor(
parameters: Parameters = Parameters.Builder()
.setQueue("LinkedDeviceInactiveCheckJob")
.setMaxInstancesForFactory(2)
.setLifespan(30.days.inWholeMilliseconds)
.setMaxAttempts(Parameters.UNLIMITED)
.addConstraint(NetworkConstraint.KEY)
.build()
) : Job(parameters) {
companion object {
private val TAG = Log.tag(LinkedDeviceInactiveCheckJob::class.java)
const val KEY = "LinkedDeviceInactiveCheckJob"
@JvmStatic
fun enqueue() {
ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob())
}
@JvmStatic
fun enqueueIfNecessary() {
val timeSinceLastCheck = System.currentTimeMillis() - SignalStore.misc().linkedDeviceLastActiveCheckTime
if (timeSinceLastCheck > 1.days.inWholeMilliseconds || timeSinceLastCheck < 0) {
ApplicationDependencies.getJobManager().add(LinkedDeviceInactiveCheckJob())
}
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result {
val devices = try {
ApplicationDependencies.getSignalServiceAccountManager().devices
} catch (e: IOException) {
return Result.retry(defaultBackoff())
}
if (devices.isEmpty()) {
Log.i(TAG, "No linked devices found.")
SignalStore.misc().hasLinkedDevices = false
SignalStore.misc().leastActiveLinkedDevice = null
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
val leastActiveDevice: LeastActiveLinkedDevice? = devices
.filter { it.id != SignalServiceAddress.DEFAULT_DEVICE_ID }
.filter { it.name != null }
.minBy { it.lastSeen }
.let {
val nameProto = DeviceName.ADAPTER.decode(Base64.decode(it.getName()))
val decryptedBytes = DeviceNameCipher.decryptDeviceName(nameProto, ApplicationDependencies.getProtocolStore().aci().identityKeyPair) ?: return@let null
val name = String(decryptedBytes)
LeastActiveLinkedDevice(
name = name,
lastActiveTimestamp = it.lastSeen
)
}
if (leastActiveDevice == null) {
Log.w(TAG, "Failed to decrypt linked device name.")
SignalStore.misc().hasLinkedDevices = true
SignalStore.misc().leastActiveLinkedDevice = null
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
val timeSinceActive = System.currentTimeMillis() - leastActiveDevice.lastActiveTimestamp
Log.i(TAG, "Least active linked device was last active ${timeSinceActive.milliseconds.toDouble(DurationUnit.DAYS).roundedString(2)} days ago ($timeSinceActive ms).")
SignalStore.misc().hasLinkedDevices = true
SignalStore.misc().leastActiveLinkedDevice = leastActiveDevice
SignalStore.misc().linkedDeviceLastActiveCheckTime = System.currentTimeMillis()
return Result.success()
}
override fun onFailure() {
}
class Factory : Job.Factory<LinkedDeviceInactiveCheckJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): LinkedDeviceInactiveCheckJob {
return LinkedDeviceInactiveCheckJob(parameters)
}
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
internal class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
@@ -32,6 +33,8 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) :
private const val NEEDS_USERNAME_RESTORE = "misc.needs_username_restore"
private const val LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh"
private const val LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync"
private const val LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME = "misc.linked_device.last_active_check_time"
private const val LEAST_ACTIVE_LINKED_DEVICE = "misc.linked_device.least_active"
}
public override fun onFirstEverAppLaunch() {
@@ -223,4 +226,14 @@ internal class MiscellaneousValues internal constructor(store: KeyValueStore) :
* How long it's been since the last foreground CDS sync, which we do in response to new threads being created.
*/
var lastCdsForegroundSyncTime by longValue(LAST_CDS_FOREGROUND_SYNC, 0)
/**
* The last time we checked for linked device activity.
*/
var linkedDeviceLastActiveCheckTime by longValue(LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME, 0)
/**
* Details about the least-active linked device.
*/
var leastActiveLinkedDevice: LeastActiveLinkedDevice? by protoValue(LEAST_ACTIVE_LINKED_DEVICE, LeastActiveLinkedDevice.ADAPTER)
}

View File

@@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat;
import com.annimon.stream.Stream;
import com.bumptech.glide.Glide;
import org.checkerframework.checker.units.qual.A;
import org.signal.core.util.MapUtil;
import org.signal.core.util.SetUtil;
import org.signal.core.util.TranslationDetection;
@@ -26,9 +25,9 @@ import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice;
import org.thoughtcrime.securesms.lock.SignalPinReminderDialog;
import org.thoughtcrime.securesms.lock.SignalPinReminders;
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
@@ -116,6 +115,7 @@ public final class Megaphones {
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.PIN_REMINDER, new SignalPinReminderSchedule());
put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER);
@@ -125,6 +125,18 @@ public final class Megaphones {
}};
}
private static boolean shouldShowLinkedDeviceInactiveMegaphone() {
LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice();
if (device == null) {
return false;
}
long expiringAt = device.lastActiveTimestamp + FeatureFlags.linkedDeviceLifespan();
long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0);
return expiringIn < TimeUnit.DAYS.toMillis(7) && expiringIn > 0;
}
private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) {
switch (record.getEvent()) {
case PINS_FOR_ALL:
@@ -141,6 +153,8 @@ public final class Megaphones {
return buildAddAProfilePhotoMegaphone(context);
case TURN_OFF_CENSORSHIP_CIRCUMVENTION:
return buildTurnOffCircumventionMegaphone(context);
case LINKED_DEVICE_INACTIVE:
return buildLinkedDeviceInactiveMegaphone(context);
case REMOTE_MEGAPHONE:
return buildRemoteMegaphone(context);
case BACKUP_SCHEDULE_PERMISSION:
@@ -156,6 +170,29 @@ public final class Megaphones {
}
}
private static Megaphone buildLinkedDeviceInactiveMegaphone(Context context) {
LeastActiveLinkedDevice device = SignalStore.misc().getLeastActiveLinkedDevice();
if (device == null) {
throw new IllegalStateException("No linked device to show");
}
long expiringAt = device.lastActiveTimestamp + TimeUnit.DAYS.toMillis(30);
long expiringIn = Math.max(expiringAt - System.currentTimeMillis(), 0);
int expiringDays = (int) TimeUnit.MILLISECONDS.toDays(expiringIn);
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)
.setActionButton(R.string.LinkedDeviceInactiveMegaphone_got_it_button_label, (megaphone, listener) -> {
listener.onMegaphoneSnooze(Event.LINKED_DEVICE_INACTIVE);
})
.setSecondaryButton(R.string.LinkedDeviceInactiveMegaphone_dont_remind_button_label, (megaphone, listener) -> {
listener.onMegaphoneCompleted(Event.LINKED_DEVICE_INACTIVE);
})
.build();
}
private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull MegaphoneRecord record) {
if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) {
return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN)
@@ -481,6 +518,7 @@ public final class Megaphones {
DONATE_Q2_2022("donate_q2_2022"),
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"),
REMOTE_MEGAPHONE("remote_megaphone"),
LINKED_DEVICE_INACTIVE("linked_device_inactive"),
BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"),
SET_UP_YOUR_USERNAME("set_up_your_username"),
PNP_LAUNCH("pnp_launch"),

View File

@@ -1,11 +1,17 @@
package org.thoughtcrime.securesms.registration.secondary
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.util.ByteUtil
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import java.nio.charset.Charset
import java.security.GeneralSecurityException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
@@ -16,6 +22,8 @@ import javax.crypto.spec.SecretKeySpec
*/
object DeviceNameCipher {
private val TAG = Log.tag(DeviceNameCipher::class.java)
private const val SYNTHETIC_IV_LENGTH = 16
@JvmStatic
@@ -37,6 +45,54 @@ object DeviceNameCipher {
).encode()
}
/**
* Decrypts a [DeviceName]. Returns null if data is invalid/undecryptable.
*/
@JvmStatic
fun decryptDeviceName(deviceName: DeviceName, identityKeyPair: IdentityKeyPair): ByteArray? {
if (deviceName.ephemeralPublic == null || deviceName.syntheticIv == null || deviceName.ciphertext == null) {
return null
}
return try {
val syntheticIv = deviceName.syntheticIv.toByteArray()
val cipherText = deviceName.ciphertext.toByteArray()
val identityKey: ECPrivateKey = identityKeyPair.privateKey
val ephemeralPublic = Curve.decodePoint(deviceName.ephemeralPublic.toByteArray(), 0)
val masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val cipherKeyPart1 = mac.doFinal("cipher".toByteArray())
mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256"))
val cipherKey = mac.doFinal(syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16)))
val plaintext = cipher.doFinal(cipherText)
mac.init(SecretKeySpec(masterSecret, "HmacSHA256"))
val verificationPart1 = mac.doFinal("auth".toByteArray())
mac.init(SecretKeySpec(verificationPart1, "HmacSHA256"))
val verificationPart2 = mac.doFinal(plaintext)
val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16)
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.")
}
plaintext
} catch (e: GeneralSecurityException) {
Log.w(TAG, "Failed to decrypt device name.", e)
null
} catch (e: InvalidKeyException) {
Log.w(TAG, "Failed to decrypt device name.", e)
null
}
}
private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray {
val input = "cipher".toByteArray(Charset.forName("UTF-8"))

View File

@@ -125,6 +125,7 @@ public final class FeatureFlags {
private static final String PREKEY_FORCE_REFRESH_INTERVAL = "android.prekeyForceRefreshInterval";
private static final String CDSI_LIBSIGNAL_NET = "android.cds.libsignal.2";
private static final String RX_MESSAGE_SEND = "android.rxMessageSend";
private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -202,7 +203,8 @@ public final class FeatureFlags {
RETRY_RECEIPT_MAX_COUNT_RESET_AGE,
PREKEY_FORCE_REFRESH_INTERVAL,
CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND
RX_MESSAGE_SEND,
LINKED_DEVICE_LIFESPAN_SECONDS
);
@VisibleForTesting
@@ -277,7 +279,8 @@ public final class FeatureFlags {
RETRY_RECEIPT_MAX_COUNT_RESET_AGE,
PREKEY_FORCE_REFRESH_INTERVAL,
CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND
RX_MESSAGE_SEND,
LINKED_DEVICE_LIFESPAN_SECONDS
);
/**
@@ -722,6 +725,12 @@ public final class FeatureFlags {
return getBoolean(RX_MESSAGE_SEND, false);
}
/** The lifespan of a linked device (i.e. the time it can be inactive for before it expires), in milliseconds. */
public static long linkedDeviceLifespan() {
long seconds = getLong(LINKED_DEVICE_LIFESPAN_SECONDS, TimeUnit.DAYS.toSeconds(30));
return TimeUnit.SECONDS.toMillis(seconds);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);