Remove plaintext device creation timestamp.

This commit is contained in:
Michelle Tang
2025-08-05 17:01:04 -04:00
committed by Cody Henthorne
parent 9dcc704a9e
commit eb7012b7ae
11 changed files with 98 additions and 49 deletions

View File

@@ -3,4 +3,4 @@ package org.thoughtcrime.securesms.linkdevice
/**
* Class that represents a linked device
*/
data class Device(val id: Int, val name: String?, val createdMillis: Long, val lastSeenMillis: Long)
data class Device(val id: Int, val name: String?, val createdMillis: Long?, val lastSeenMillis: Long, val registrationId: Int)

View File

@@ -149,7 +149,7 @@ private fun DeviceListScreenLinkingPreview() {
Previews.Preview {
EditNameScreen(
state = LinkDeviceSettingsState(
deviceToEdit = Device(1, "Laptop", 0, 0)
deviceToEdit = Device(1, "Laptop", 0, 0, 0)
)
)
}

View File

@@ -495,7 +495,7 @@ fun DeviceListScreen(
@Composable
fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit, onEditDevice: (Device) -> Unit) {
val titleString = if (device.name.isNullOrEmpty()) stringResource(R.string.DeviceListItem_unnamed_device) else device.name
val linkedDate = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.createdMillis)
val linkedDate = device.createdMillis?.let { DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.createdMillis) }
val lastActive = DateUtils.getDayPrecisionTimeSpanString(LocalContext.current, Locale.getDefault(), device.lastSeenMillis)
val menuController = remember { DropdownMenus.MenuController() }
Row(
@@ -524,7 +524,9 @@ fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit, onEditDevice:
.weight(1f)
) {
Text(text = titleString, style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.DeviceListItem_linked_s, linkedDate), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (linkedDate != null) {
Text(stringResource(R.string.DeviceListItem_linked_s, linkedDate), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(stringResource(R.string.DeviceListItem_last_active_s, lastActive), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -599,8 +601,8 @@ private fun DeviceListScreenPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
devices = listOf(
Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000),
Device(1, "Sam's iPad", 1715793182000, 1716053122000)
Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000, 0),
Device(1, "Sam's iPad", 1715793182000, 1716053122000, 0)
),
seenQrEducationSheet = true
)
@@ -653,7 +655,7 @@ private fun DeviceListScreenSyncingMessagesPreview() {
Previews.Preview {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.SyncingMessages(1, 1),
dialogState = DialogState.SyncingMessages(1),
seenQrEducationSheet = true
)
)
@@ -681,7 +683,7 @@ private fun DeviceListScreenSyncingFailedPreview() {
state = LinkDeviceSettingsState(
dialogState = DialogState.SyncingFailed(
deviceId = 1,
deviceCreatedAt = 1,
deviceRegistrationId = 1,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_RETRYABLE
),
seenQrEducationSheet = true
@@ -724,7 +726,7 @@ private fun DeviceListScreenNotEnoughStoragePreview() {
state = LinkDeviceSettingsState(
dialogState = DialogState.SyncingFailed(
deviceId = 1,
deviceCreatedAt = 1,
deviceRegistrationId = 1,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_ENOUGH_SPACE
),
seenQrEducationSheet = true

View File

@@ -8,6 +8,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logD
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW
import org.signal.core.util.toByteArray
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.thoughtcrime.securesms.backup.BackupFileIOError
@@ -34,6 +35,7 @@ import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@@ -44,6 +46,7 @@ import kotlin.time.Duration.Companion.milliseconds
object LinkDeviceRepository {
private val TAG = Log.tag(LinkDeviceRepository::class)
private const val DECRYPTION_INFO = "deviceCreatedAt"
fun removeDevice(deviceId: Int): Boolean {
return when (val result = AppDependencies.linkDeviceApi.removeDevice(deviceId)) {
@@ -75,18 +78,20 @@ object LinkDeviceRepository {
}
}
fun WaitForLinkedDeviceResponse.getPlaintextDeviceName(): String {
fun WaitForLinkedDeviceResponse.getPlaintextDevice(): Device {
val response = this
return DeviceInfo().apply {
id = response.id
name = response.name
created = response.created
lastSeen = response.lastSeen
}.toDevice().name ?: ""
registrationId = response.registrationId
createdAtCiphertext = response.createdAtCiphertext
}.toDevice()
}
private fun DeviceInfo.toDevice(): Device {
val defaultDevice = Device(getId(), getName(), getCreated(), getLastSeen())
val createdAt = this.getPlaintextCreatedAt()
val defaultDevice = Device(getId(), getName(), createdAt, getLastSeen(), getRegistrationId())
try {
if (getName().isNullOrEmpty() || getName().length < 4) {
Log.w(TAG, "Invalid DeviceInfo name.")
@@ -105,13 +110,28 @@ object LinkDeviceRepository {
return defaultDevice
}
return Device(getId(), String(plaintext), getCreated(), getLastSeen())
return Device(getId(), String(plaintext), createdAt, getLastSeen(), getRegistrationId())
} catch (e: Exception) {
Log.w(TAG, "Failed while reading the protobuf.", e)
}
return defaultDevice
}
private fun DeviceInfo.getPlaintextCreatedAt(): Long? {
return try {
val associatedData = byteArrayOf(getId().toByte()) + getRegistrationId().toByteArray()
val createdAtPlaintext = SignalStore.account.aciIdentityKey.privateKey.open(
ciphertext = Base64.decode(getCreatedAtCiphertext().toByteArray()),
info = DECRYPTION_INFO,
associatedData = associatedData
)
ByteBuffer.wrap(createdAtPlaintext).getLong()
} catch (e: Exception) {
Log.w(TAG, "Failed while reading the protobuf.", e)
null
}
}
fun isValidQr(uri: Uri): Boolean {
if (!uri.isHierarchical) {
return false
@@ -254,7 +274,7 @@ object LinkDeviceRepository {
/**
* Performs the entire process of creating and uploading an archive for a newly-linked device.
*/
fun createAndUploadArchive(ephemeralMessageBackupKey: MessageBackupKey, deviceId: Int, deviceCreatedAt: Long, cancellationSignal: () -> Boolean): LinkUploadArchiveResult {
fun createAndUploadArchive(ephemeralMessageBackupKey: MessageBackupKey, deviceId: Int, deviceRegistrationId: Int, cancellationSignal: () -> Boolean): LinkUploadArchiveResult {
Log.d(TAG, "[createAndUploadArchive] Beginning process.")
val stopwatch = Stopwatch("link-archive")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
@@ -283,7 +303,7 @@ object LinkDeviceRepository {
if (cancellationSignal()) {
Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.")
sendTransferArchiveError(deviceId, deviceCreatedAt, TransferArchiveError.RELINK_REQUESTED)
sendTransferArchiveError(deviceId, deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
return LinkUploadArchiveResult.BackupCreationCancelled
}
@@ -308,7 +328,7 @@ object LinkDeviceRepository {
if (cancellationSignal()) {
Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.")
sendTransferArchiveError(deviceId, deviceCreatedAt, TransferArchiveError.RELINK_REQUESTED)
sendTransferArchiveError(deviceId, deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
return LinkUploadArchiveResult.BackupCreationCancelled
}
@@ -322,7 +342,7 @@ object LinkDeviceRepository {
if (cancellationSignal()) {
Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.")
sendTransferArchiveError(deviceId, deviceCreatedAt, TransferArchiveError.RELINK_REQUESTED)
sendTransferArchiveError(deviceId, deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
return LinkUploadArchiveResult.BackupCreationCancelled
}
@@ -336,7 +356,7 @@ object LinkDeviceRepository {
if (cancellationSignal()) {
Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.")
sendTransferArchiveError(deviceId, deviceCreatedAt, TransferArchiveError.RELINK_REQUESTED)
sendTransferArchiveError(deviceId, deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
return LinkUploadArchiveResult.BackupCreationCancelled
}
@@ -344,7 +364,7 @@ object LinkDeviceRepository {
val transferSetResult = NetworkResult.withRetry {
SignalNetwork.linkDevice.setTransferArchive(
destinationDeviceId = deviceId,
destinationDeviceCreated = deviceCreatedAt,
destinationDeviceRegistrationId = deviceRegistrationId,
cdn = uploadForm.cdn,
cdnKey = uploadForm.key
)
@@ -402,10 +422,10 @@ object LinkDeviceRepository {
/**
* If [createAndUploadArchive] is cancelled or fails to upload an archive, alert the linked device of the failure and if the user will try again
*/
fun sendTransferArchiveError(deviceId: Int, deviceCreatedAt: Long, error: TransferArchiveError) {
fun sendTransferArchiveError(deviceId: Int, deviceRegistrationId: Int, error: TransferArchiveError) {
val archiveErrorResult = SignalNetwork.linkDevice.setTransferArchiveError(
destinationDeviceId = deviceId,
destinationDeviceCreated = deviceCreatedAt,
destinationDeviceRegistrationId = deviceRegistrationId,
error = error
)

View File

@@ -27,9 +27,9 @@ data class LinkDeviceSettingsState(
data object None : DialogState
data object Linking : DialogState
data object Unlinking : DialogState
data class SyncingMessages(val deviceId: Int, val deviceCreatedAt: Long) : DialogState
data class SyncingMessages(val deviceId: Int) : DialogState
data object SyncingTimedOut : DialogState
data class SyncingFailed(val deviceId: Int, val deviceCreatedAt: Long, val syncFailType: SyncFailType) : DialogState
data class SyncingFailed(val deviceId: Int, val deviceRegistrationId: Int, val syncFailType: SyncFailType) : DialogState
data class DeviceUnlinked(val deviceCreatedAt: Long) : DialogState
data object LoadingDebugLog : DialogState
data object ContactSupport : DialogState

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.NewLinkedDeviceNotificationJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.getPlaintextDeviceName
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.getPlaintextDevice
import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState
import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.OneTimeEvent
import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.QrCodeState
@@ -300,12 +300,12 @@ class LinkDeviceViewModel : ViewModel() {
}
Log.d(TAG, "[addDeviceWithSync] Found a linked device! Creating notification job.")
NewLinkedDeviceNotificationJob.enqueue(waitResult.id, waitResult.created)
NewLinkedDeviceNotificationJob.enqueue(waitResult.id, waitResult.getPlaintextDevice().createdMillis ?: System.currentTimeMillis())
_state.update {
it.copy(
linkDeviceResult = result,
dialogState = DialogState.SyncingMessages(waitResult.id, waitResult.created)
dialogState = DialogState.SyncingMessages(waitResult.id)
)
}
@@ -313,7 +313,7 @@ class LinkDeviceViewModel : ViewModel() {
val uploadResult = LinkDeviceRepository.createAndUploadArchive(
ephemeralMessageBackupKey = ephemeralMessageBackupKey,
deviceId = waitResult.id,
deviceCreatedAt = waitResult.created,
deviceRegistrationId = waitResult.registrationId,
cancellationSignal = { _state.value.shouldCancelArchiveUpload }
)
@@ -323,7 +323,7 @@ class LinkDeviceViewModel : ViewModel() {
Log.i(TAG, "[addDeviceWithSync] Successfully uploaded archive.")
_state.update {
it.copy(
oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDeviceName()),
oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDevice().name ?: ""),
dialogState = DialogState.None
)
}
@@ -335,7 +335,7 @@ class LinkDeviceViewModel : ViewModel() {
it.copy(
dialogState = DialogState.SyncingFailed(
deviceId = waitResult.id,
deviceCreatedAt = waitResult.created,
deviceRegistrationId = waitResult.registrationId,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_ENOUGH_SPACE
)
)
@@ -347,7 +347,7 @@ class LinkDeviceViewModel : ViewModel() {
it.copy(
dialogState = DialogState.SyncingFailed(
deviceId = waitResult.id,
deviceCreatedAt = waitResult.created,
deviceRegistrationId = waitResult.registrationId,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_RETRYABLE
)
)
@@ -360,7 +360,7 @@ class LinkDeviceViewModel : ViewModel() {
it.copy(
dialogState = DialogState.SyncingFailed(
deviceId = waitResult.id,
deviceCreatedAt = waitResult.created,
deviceRegistrationId = waitResult.registrationId,
syncFailType = LinkDeviceSettingsState.SyncFailType.RETRYABLE
)
)
@@ -404,9 +404,10 @@ class LinkDeviceViewModel : ViewModel() {
Log.i(TAG, "No linked device found!")
} else {
Log.i(TAG, "Found a linked device! Creating notification job.")
NewLinkedDeviceNotificationJob.enqueue(waitResult.id, waitResult.created)
val device = waitResult.getPlaintextDevice()
NewLinkedDeviceNotificationJob.enqueue(waitResult.id, device.createdMillis ?: System.currentTimeMillis())
_state.update {
it.copy(oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDeviceName()))
it.copy(oneTimeEvent = OneTimeEvent.ToastLinked(device.name ?: ""))
}
}
@@ -438,7 +439,7 @@ class LinkDeviceViewModel : ViewModel() {
val dialogState = _state.value.dialogState
if (dialogState is DialogState.SyncingFailed) {
Log.i(TAG, "Alerting linked device of sync failure - will not retry")
LinkDeviceRepository.sendTransferArchiveError(dialogState.deviceId, dialogState.deviceCreatedAt, TransferArchiveError.CONTINUE_WITHOUT_UPLOAD)
LinkDeviceRepository.sendTransferArchiveError(dialogState.deviceId, dialogState.deviceRegistrationId, TransferArchiveError.CONTINUE_WITHOUT_UPLOAD)
}
loadDevices()
@@ -462,7 +463,7 @@ class LinkDeviceViewModel : ViewModel() {
val dialogState = _state.value.dialogState
if (dialogState is DialogState.SyncingFailed) {
Log.i(TAG, "Alerting linked device of sync failure - will retry")
LinkDeviceRepository.sendTransferArchiveError(dialogState.deviceId, dialogState.deviceCreatedAt, TransferArchiveError.RELINK_REQUESTED)
LinkDeviceRepository.sendTransferArchiveError(dialogState.deviceId, dialogState.deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
Log.i(TAG, "Need to unlink device first...")
val success = LinkDeviceRepository.removeDevice(dialogState.deviceId)

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import java.nio.ByteBuffer
/**
* Converts the integer into [ByteArray].
*/
fun Int.toByteArray(): ByteArray {
return ByteBuffer
.allocate(Int.SIZE_BYTES)
.putInt(this)
.array()
}

View File

@@ -166,10 +166,10 @@ class LinkDeviceApi(
* - 422: Bad inputs.
* - 429: Rate-limited.
*/
fun setTransferArchive(destinationDeviceId: Int, destinationDeviceCreated: Long, cdn: Int, cdnKey: String): NetworkResult<Unit> {
fun setTransferArchive(destinationDeviceId: Int, destinationDeviceRegistrationId: Int, cdn: Int, cdnKey: String): NetworkResult<Unit> {
val body = SetLinkedDeviceTransferArchiveRequest(
destinationDeviceId = destinationDeviceId,
destinationDeviceCreated = destinationDeviceCreated,
destinationDeviceRegistrationId = destinationDeviceRegistrationId,
transferArchive = SetLinkedDeviceTransferArchiveRequest.TransferArchive.CdnInfo(
cdn = cdn,
key = cdnKey
@@ -189,10 +189,10 @@ class LinkDeviceApi(
* - 422: Bad inputs.
* - 429: Rate-limited.
*/
fun setTransferArchiveError(destinationDeviceId: Int, destinationDeviceCreated: Long, error: TransferArchiveError): NetworkResult<Unit> {
fun setTransferArchiveError(destinationDeviceId: Int, destinationDeviceRegistrationId: Int, error: TransferArchiveError): NetworkResult<Unit> {
val body = SetLinkedDeviceTransferArchiveRequest(
destinationDeviceId = destinationDeviceId,
destinationDeviceCreated = destinationDeviceCreated,
destinationDeviceRegistrationId = destinationDeviceRegistrationId,
transferArchive = SetLinkedDeviceTransferArchiveRequest.TransferArchive.Error(error)
)
val request = WebSocketRequestMessage.put("/v1/devices/transfer_archive", body)

View File

@@ -12,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
*/
data class SetLinkedDeviceTransferArchiveRequest(
@JsonProperty val destinationDeviceId: Int,
@JsonProperty val destinationDeviceCreated: Long,
@JsonProperty val destinationDeviceRegistrationId: Int,
@JsonProperty val transferArchive: TransferArchive
) {
sealed class TransferArchive {

View File

@@ -13,6 +13,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class WaitForLinkedDeviceResponse(
@JsonProperty val id: Int,
@JsonProperty val name: String,
@JsonProperty val created: Long,
@JsonProperty val lastSeen: Long
@JsonProperty val lastSeen: Long,
@JsonProperty val registrationId: Int,
@JsonProperty val createdAtCiphertext: String?
)

View File

@@ -17,10 +17,13 @@ public class DeviceInfo {
public String name;
@JsonProperty
public long created;
public long lastSeen;
@JsonProperty
public long lastSeen;
public int registrationId;
@JsonProperty
public String createdAtCiphertext;
public DeviceInfo() {}
@@ -32,11 +35,15 @@ public class DeviceInfo {
return name;
}
public long getCreated() {
return created;
}
public long getLastSeen() {
return lastSeen;
}
public int getRegistrationId() {
return registrationId;
}
public String getCreatedAtCiphertext() {
return createdAtCiphertext;
}
}