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

View File

@@ -0,0 +1,17 @@
/**
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
syntax = "proto3";
package signal;
option java_package = "org.thoughtcrime.securesms.keyvalue.protos";
message LeastActiveLinkedDevice {
string name = 1;
uint64 lastActiveTimestamp = 2;
}

View File

@@ -0,0 +1,39 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:fillColor="#FFFCAF68"
android:pathData="M13 19c0-2.2 1.8-4 4-4h28c2.2 0 4 1.8 4 4v24c0 2.2-1.8 4-4 4H17c-2.2 0-4-1.8-4-4V19Z"/>
<path
android:fillColor="#FF617092"
android:fillType="evenOdd"
android:pathData="M12 19c0-2.76 2.24-5 5-5h28c2.76 0 5 2.24 5 5v24c0 2.76-2.24 5-5 5H17c-2.76 0-5-2.24-5-5V19Zm5-3c-1.66 0-3 1.34-3 3v24c0 1.66 1.34 3 3 3h28c1.66 0 3-1.34 3-3V19c0-1.66-1.34-3-3-3H17Z"/>
<path
android:fillColor="#FFFFCF71"
android:fillType="evenOdd"
android:pathData="M47.9 22.07v21.75c0 1.66-1.33 3-3 3H16.83c-1.66 0-3-1.34-3-3v-1.55c4.75-11.98 16.44-20.45 30.11-20.45 1.35 0 2.68 0.09 3.98 0.25Z"/>
<path
android:fillColor="#FFFFE3A5"
android:fillType="evenOdd"
android:pathData="M47.9 28.95v17.87H19.46c3.17-10.52 12.93-18.18 24.48-18.18 1.35 0 2.68 0.1 3.98 0.3Z"/>
<path
android:fillColor="#FFC1C5E1"
android:pathData="M7 44.5C7 43.12 8.12 42 9.5 42h43c1.38 0 2.5 1.12 2.5 2.5S53.88 47 52.5 47h-43C8.12 47 7 45.88 7 44.5Z"/>
<path
android:fillColor="#FF617092"
android:fillType="evenOdd"
android:pathData="M6 44.5C6 42.57 7.57 41 9.5 41h43c1.93 0 3.5 1.57 3.5 3.5S54.43 48 52.5 48h-43C7.57 48 6 46.43 6 44.5ZM9.5 43C8.67 43 8 43.67 8 44.5S8.67 46 9.5 46h43c0.83 0 1.5-0.67 1.5-1.5S53.33 43 52.5 43h-43Z"/>
<path
android:fillColor="#FFC2D5F0"
android:pathData="M43 29c0-1.66 1.34-3 3-3h8c1.66 0 3 1.34 3 3v17c0 1.66-1.34 3-3 3h-8c-1.66 0-3-1.34-3-3V29Z"/>
<path
android:fillColor="#FFAEC8E8"
android:fillType="evenOdd"
android:pathData="M57 37.48v8.9c0 1.66-1.34 3-3 3h-7.64c-1.65 0-3-1.34-3-3v-8.9c1.96-1.28 4.3-2.02 6.82-2.02 2.52 0 4.86 0.74 6.82 2.02Z"/>
<path
android:fillColor="#FF617092"
android:fillType="evenOdd"
android:pathData="M42 29c0-2.2 1.8-4 4-4h8c2.2 0 4 1.8 4 4v17c0 2.2-1.8 4-4 4h-8c-2.2 0-4-1.8-4-4V29Zm4-2c-1.1 0-2 0.9-2 2v17c0 1.1 0.9 2 2 2h8c1.1 0 2-0.9 2-2V29c0-1.1-0.9-2-2-2h-8Z"/>
</vector>

View File

@@ -6664,5 +6664,17 @@
<!-- Content of a dialog indicating that we could not perform the requested action because we encountered a network error. -->
<string name="FindByActivity__network_error_dialog">Encountered a network error. Try again later.</string>
<!-- Title for an alert letting someone know that one of their linked devices is inactive. -->
<string name="LinkedDeviceInactiveMegaphone_title">Inactive linked device</string>
<!-- Body for an alert letting someone know that one of their linked devices is inactive. The string placeholder is the name of the device, and the number placeholder is the number of days before device is unlinked. -->
<plurals name="LinkedDeviceInactiveMegaphone_body">
<item quantity="one">To keep \"%1$s\" linked, open Signal on that device within %2$d day.</item>
<item quantity="other">To keep \"%1$s\" linked, open Signal on that device within %2$d days.</item>
</plurals>
<!-- Button label for an alert letting someone know that one of their linked devices is inactive. When clicked, the user will opt out of all future alerts. -->
<string name="LinkedDeviceInactiveMegaphone_dont_remind_button_label">Don\'t remind me</string>
<!-- Button label for an alert letting someone know that one of their linked devices is inactive. When clicked, the alert will be dismissed. -->
<string name="LinkedDeviceInactiveMegaphone_got_it_button_label">Got it</string>
<!-- EOF -->
</resources>

View File

@@ -4,7 +4,6 @@ import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.Test
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import java.nio.charset.Charset
@@ -17,7 +16,7 @@ class DeviceNameCipherTest {
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(Charset.forName("UTF-8")), identityKeyPair)
val plaintext = DeviceListLoader.decryptName(DeviceName.ADAPTER.decode(encryptedDeviceName), identityKeyPair)
val plaintext = DeviceNameCipher.decryptDeviceName(DeviceName.ADAPTER.decode(encryptedDeviceName), identityKeyPair)!!
assertThat(String(plaintext, Charset.forName("UTF-8")), `is`(deviceName))
}