mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 20:48:43 +00:00
Show a megaphone when a device is about to unlink.
This commit is contained in:
committed by
Nicholas Tinsley
parent
d7ee9639fd
commit
50149a3803
@@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
|||||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
|
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
|
||||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||||
|
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||||
@@ -215,6 +216,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||||
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
||||||
.addPostRender(GroupRingCleanupJob::enqueue)
|
.addPostRender(GroupRingCleanupJob::enqueue)
|
||||||
|
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
|||||||
import org.signal.qr.kitkat.ScanListener;
|
import org.signal.qr.kitkat.ScanListener;
|
||||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
|
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.signal.core.util.Base64;
|
import org.signal.core.util.Base64;
|
||||||
@@ -231,6 +232,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
|||||||
protected void onPostExecute(Integer result) {
|
protected void onPostExecute(Integer result) {
|
||||||
super.onPostExecute(result);
|
super.onPostExecute(result);
|
||||||
|
|
||||||
|
LinkedDeviceInactiveCheckJob.enqueue();
|
||||||
|
|
||||||
Context context = DeviceActivity.this;
|
Context context = DeviceActivity.this;
|
||||||
|
|
||||||
switch (result) {
|
switch (result) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
|
|||||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.devicelist.Device;
|
import org.thoughtcrime.securesms.devicelist.Device;
|
||||||
|
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||||
@@ -166,6 +167,7 @@ public class DeviceListFragment extends ListFragment
|
|||||||
super.onPostExecute(result);
|
super.onPostExecute(result);
|
||||||
if (result) {
|
if (result) {
|
||||||
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
|
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
|
||||||
|
LinkedDeviceInactiveCheckJob.enqueue();
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
|
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
|||||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||||
import org.thoughtcrime.securesms.devicelist.Device;
|
import org.thoughtcrime.securesms.devicelist.Device;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
|
||||||
import org.thoughtcrime.securesms.util.AsyncLoader;
|
import org.thoughtcrime.securesms.util.AsyncLoader;
|
||||||
import org.signal.core.util.Base64;
|
import org.signal.core.util.Base64;
|
||||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
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.");
|
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) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "Failed while reading the protobuf.", 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());
|
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> {
|
private static class DeviceComparator implements Comparator<Device> {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ public final class JobManagerFactories {
|
|||||||
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
|
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
|
||||||
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
|
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
|
||||||
put(LegacyAttachmentUploadJob.KEY, new LegacyAttachmentUploadJob.Factory());
|
put(LegacyAttachmentUploadJob.KEY, new LegacyAttachmentUploadJob.Factory());
|
||||||
|
put(LinkedDeviceInactiveCheckJob.KEY, new LinkedDeviceInactiveCheckJob.Factory());
|
||||||
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
|
put(LocalBackupJob.KEY, new LocalBackupJob.Factory());
|
||||||
put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory());
|
put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory());
|
||||||
put(MarkerJob.KEY, new MarkerJob.Factory());
|
put(MarkerJob.KEY, new MarkerJob.Factory());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue
|
|||||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
|
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver
|
import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice
|
||||||
|
|
||||||
internal class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
internal class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||||
companion object {
|
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 NEEDS_USERNAME_RESTORE = "misc.needs_username_restore"
|
||||||
private const val LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh"
|
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 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() {
|
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.
|
* 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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.core.app.NotificationManagerCompat;
|
|||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.bumptech.glide.Glide;
|
import com.bumptech.glide.Glide;
|
||||||
|
|
||||||
import org.checkerframework.checker.units.qual.A;
|
|
||||||
import org.signal.core.util.MapUtil;
|
import org.signal.core.util.MapUtil;
|
||||||
import org.signal.core.util.SetUtil;
|
import org.signal.core.util.SetUtil;
|
||||||
import org.signal.core.util.TranslationDetection;
|
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.database.model.RemoteMegaphoneRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
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.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice;
|
||||||
import org.thoughtcrime.securesms.lock.SignalPinReminderDialog;
|
import org.thoughtcrime.securesms.lock.SignalPinReminderDialog;
|
||||||
import org.thoughtcrime.securesms.lock.SignalPinReminders;
|
import org.thoughtcrime.securesms.lock.SignalPinReminders;
|
||||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
||||||
@@ -116,6 +115,7 @@ public final class Megaphones {
|
|||||||
put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER);
|
put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER);
|
||||||
put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : 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.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.PIN_REMINDER, new SignalPinReminderSchedule());
|
||||||
put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER);
|
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) {
|
private static @NonNull Megaphone forRecord(@NonNull Context context, @NonNull MegaphoneRecord record) {
|
||||||
switch (record.getEvent()) {
|
switch (record.getEvent()) {
|
||||||
case PINS_FOR_ALL:
|
case PINS_FOR_ALL:
|
||||||
@@ -141,6 +153,8 @@ public final class Megaphones {
|
|||||||
return buildAddAProfilePhotoMegaphone(context);
|
return buildAddAProfilePhotoMegaphone(context);
|
||||||
case TURN_OFF_CENSORSHIP_CIRCUMVENTION:
|
case TURN_OFF_CENSORSHIP_CIRCUMVENTION:
|
||||||
return buildTurnOffCircumventionMegaphone(context);
|
return buildTurnOffCircumventionMegaphone(context);
|
||||||
|
case LINKED_DEVICE_INACTIVE:
|
||||||
|
return buildLinkedDeviceInactiveMegaphone(context);
|
||||||
case REMOTE_MEGAPHONE:
|
case REMOTE_MEGAPHONE:
|
||||||
return buildRemoteMegaphone(context);
|
return buildRemoteMegaphone(context);
|
||||||
case BACKUP_SCHEDULE_PERMISSION:
|
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) {
|
private static @NonNull Megaphone buildPinsForAllMegaphone(@NonNull MegaphoneRecord record) {
|
||||||
if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) {
|
if (PinsForAllSchedule.shouldDisplayFullScreen(record.getFirstVisible(), System.currentTimeMillis())) {
|
||||||
return new Megaphone.Builder(Event.PINS_FOR_ALL, Megaphone.Style.FULLSCREEN)
|
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"),
|
DONATE_Q2_2022("donate_q2_2022"),
|
||||||
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"),
|
TURN_OFF_CENSORSHIP_CIRCUMVENTION("turn_off_censorship_circumvention"),
|
||||||
REMOTE_MEGAPHONE("remote_megaphone"),
|
REMOTE_MEGAPHONE("remote_megaphone"),
|
||||||
|
LINKED_DEVICE_INACTIVE("linked_device_inactive"),
|
||||||
BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"),
|
BACKUP_SCHEDULE_PERMISSION("backup_schedule_permission"),
|
||||||
SET_UP_YOUR_USERNAME("set_up_your_username"),
|
SET_UP_YOUR_USERNAME("set_up_your_username"),
|
||||||
PNP_LAUNCH("pnp_launch"),
|
PNP_LAUNCH("pnp_launch"),
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package org.thoughtcrime.securesms.registration.secondary
|
package org.thoughtcrime.securesms.registration.secondary
|
||||||
|
|
||||||
import okio.ByteString.Companion.toByteString
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
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.Curve
|
||||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
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 org.thoughtcrime.securesms.devicelist.protos.DeviceName
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import java.security.MessageDigest
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.IvParameterSpec
|
||||||
@@ -16,6 +22,8 @@ import javax.crypto.spec.SecretKeySpec
|
|||||||
*/
|
*/
|
||||||
object DeviceNameCipher {
|
object DeviceNameCipher {
|
||||||
|
|
||||||
|
private val TAG = Log.tag(DeviceNameCipher::class.java)
|
||||||
|
|
||||||
private const val SYNTHETIC_IV_LENGTH = 16
|
private const val SYNTHETIC_IV_LENGTH = 16
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@@ -37,6 +45,54 @@ object DeviceNameCipher {
|
|||||||
).encode()
|
).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 {
|
private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray {
|
||||||
val input = "cipher".toByteArray(Charset.forName("UTF-8"))
|
val input = "cipher".toByteArray(Charset.forName("UTF-8"))
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ public final class FeatureFlags {
|
|||||||
private static final String PREKEY_FORCE_REFRESH_INTERVAL = "android.prekeyForceRefreshInterval";
|
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 CDSI_LIBSIGNAL_NET = "android.cds.libsignal.2";
|
||||||
private static final String RX_MESSAGE_SEND = "android.rxMessageSend";
|
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
|
* 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,
|
RETRY_RECEIPT_MAX_COUNT_RESET_AGE,
|
||||||
PREKEY_FORCE_REFRESH_INTERVAL,
|
PREKEY_FORCE_REFRESH_INTERVAL,
|
||||||
CDSI_LIBSIGNAL_NET,
|
CDSI_LIBSIGNAL_NET,
|
||||||
RX_MESSAGE_SEND
|
RX_MESSAGE_SEND,
|
||||||
|
LINKED_DEVICE_LIFESPAN_SECONDS
|
||||||
);
|
);
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@@ -277,7 +279,8 @@ public final class FeatureFlags {
|
|||||||
RETRY_RECEIPT_MAX_COUNT_RESET_AGE,
|
RETRY_RECEIPT_MAX_COUNT_RESET_AGE,
|
||||||
PREKEY_FORCE_REFRESH_INTERVAL,
|
PREKEY_FORCE_REFRESH_INTERVAL,
|
||||||
CDSI_LIBSIGNAL_NET,
|
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);
|
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. */
|
/** Only for rendering debug info. */
|
||||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||||
return new TreeMap<>(REMOTE_VALUES);
|
return new TreeMap<>(REMOTE_VALUES);
|
||||||
|
|||||||
17
app/src/main/protowire/KeyValue.proto
Normal file
17
app/src/main/protowire/KeyValue.proto
Normal 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;
|
||||||
|
}
|
||||||
39
app/src/main/res/drawable/ic_inactive_linked_device.xml
Normal file
39
app/src/main/res/drawable/ic_inactive_linked_device.xml
Normal 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>
|
||||||
@@ -6664,5 +6664,17 @@
|
|||||||
<!-- Content of a dialog indicating that we could not perform the requested action because we encountered a network error. -->
|
<!-- 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>
|
<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 -->
|
<!-- EOF -->
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import org.hamcrest.MatcherAssert.assertThat
|
|||||||
import org.hamcrest.Matchers.`is`
|
import org.hamcrest.Matchers.`is`
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader
|
|
||||||
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
|
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ class DeviceNameCipherTest {
|
|||||||
|
|
||||||
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(Charset.forName("UTF-8")), identityKeyPair)
|
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))
|
assertThat(String(plaintext, Charset.forName("UTF-8")), `is`(deviceName))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user