Add device linking support to RegV5.

This commit is contained in:
Cody Henthorne
2026-06-29 12:52:37 -04:00
committed by Michelle Tang
parent 55b5997a4e
commit 91965d205a
44 changed files with 2111 additions and 182 deletions
@@ -2322,7 +2322,7 @@ object BackupRepository {
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2354,9 +2354,16 @@ object BackupRepository {
override fun shouldCancel() = cancellationSignal()
}
val cdn = response.cdn
val key = response.key
if (cdn == null || key == null) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
return RemoteRestoreResult.Failure
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -6,15 +6,15 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.core.util.roundedString
import org.thoughtcrime.securesms.dependencies.AppDependencies
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
@@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.Base64;
import org.signal.core.util.crypto.DeviceNameCipher;
import org.signal.core.util.logging.Log;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.thoughtcrime.securesms.AppCapabilities;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -16,13 +18,11 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.keyvalue.SvrValues;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
import org.thoughtcrime.securesms.registration.data.RegistrationRepository;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -86,7 +86,7 @@ class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob
return
}
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.")
return
}
@@ -4,6 +4,8 @@ import android.net.Uri
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
import org.signal.core.util.crypto.DeviceName
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logD
@@ -20,14 +22,12 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob
import org.thoughtcrime.securesms.jobs.E164FormattingJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.createAndUploadArchive
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse
import org.whispersystems.signalservice.api.link.TransferArchiveError
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse
@@ -26,6 +26,7 @@ import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.util.KeyHelper
@@ -70,13 +71,13 @@ import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionC
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.PinHashUtil
@@ -97,6 +98,7 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
/**
@@ -488,23 +490,46 @@ object RegistrationRepository {
val aci = message.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(message.aci)
val pni = message.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(message.pni)
val universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
return registerAsLinkedDevice(
context = context,
deviceName = deviceName,
number = message.number!!,
provisioningCode = message.provisioningCode!!,
aci = aci,
pni = pni,
accountEntropyPool = AccountEntropyPool(message.accountEntropyPool!!),
registrationData = registrationData,
aciIdentityKeyPair = aciIdentityKeyPair,
pniIdentityKeyPair = pniIdentityKeyPair
)
}
/**
* Registers this device as a linked (secondary) device using discrete fields rather than a raw
* [ProvisionMessage]. This is the form used by the regv5 registration module, which works with a
* decoupled provisioning message.
*/
@WorkerThread
fun registerAsLinkedDevice(
context: Context,
deviceName: String,
number: String,
provisioningCode: String,
aci: ACI,
pni: PNI,
accountEntropyPool: AccountEntropyPool,
registrationData: RegistrationData,
aciIdentityKeyPair: IdentityKeyPair,
pniIdentityKeyPair: IdentityKeyPair
): NetworkResult<RegisterAsLinkedDeviceResponse> {
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair)
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = getRegistrationId(),
val deviceAttributes = DeviceAttributes(
fetchesMessages = registrationData.fcmToken == null,
registrationLock = null,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(false),
discoverableByPhoneNumber = false,
name = Base64.encodeWithPadding(encryptedDeviceName),
registrationId = getRegistrationId(),
pniRegistrationId = getPniRegistrationId(),
recoveryPassword = null
name = Base64.encodeWithPadding(encryptedDeviceName),
capabilities = AppCapabilities.getCapabilities(false)
)
val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys)
@@ -512,20 +537,18 @@ object RegistrationRepository {
return AccountManagerFactory
.getInstance()
.createUnauthenticated(context, message.number!!, -1, registrationData.password)
.createUnauthenticated(context, number, -1, registrationData.password)
.registrationApi
.registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { respone ->
val aep = AccountEntropyPool(message.accountEntropyPool!!)
.registerAsSecondaryDevice(provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { response ->
RegisterAsLinkedDeviceResponse(
deviceId = respone.deviceId.toInt(),
deviceId = response.deviceId.toInt(),
accountRegistrationResult = AccountRegistrationResult(
uuid = aci.toString(),
pni = pni.toString(),
storageCapable = false,
number = message.number!!,
masterKey = aep.deriveMasterKey(),
number = number,
masterKey = accountEntropyPool.deriveMasterKey(),
pin = null,
aciPreKeyCollection = aciPreKeys,
pniPreKeyCollection = pniPreKeys,
@@ -674,7 +697,7 @@ object RegistrationRepository {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? {
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
@@ -683,7 +706,8 @@ object RegistrationRepository {
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary")
// The primary has responded: either with an archive location, or an error telling us not to expect one.
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})")
return result.result
}
is NetworkResult.ApplicationError -> {
@@ -6,8 +6,10 @@
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
@@ -16,25 +18,33 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.network.NetworkResult
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CheckSvrCredentialsError
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.DeviceAttributes
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.LinkDeviceProvisioningEvent
import org.signal.registration.NetworkController.LinkDeviceProvisioningMessage
import org.signal.registration.NetworkController.LinkDeviceResponse
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.ProvisioningMessage
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegisterAsLinkedDeviceError
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreAccountRecordError
@@ -68,21 +78,36 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.GcmRegistrationId
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegisterAsSecondaryDeviceRequest
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.Closeable
import java.io.IOException
import java.util.Locale
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes as ServiceDeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
import org.whispersystems.signalservice.api.provisioning.RestoreMethod as ServiceRestoreMethod
@@ -97,6 +122,7 @@ class AppRegistrationNetworkController(
companion object {
private val TAG = Log.tag(AppRegistrationNetworkController::class)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
private val RETRY_BACKOFF = 5.seconds
}
private val json = Json { ignoreUnknownKeys = true }
@@ -740,7 +766,7 @@ class AppRegistrationNetworkController(
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val socketHandles = mutableListOf<Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
@@ -804,7 +830,7 @@ class AppRegistrationNetworkController(
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
kotlinx.coroutines.delay(ProvisioningSocket.LIFESPAN / 2)
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
@@ -822,6 +848,250 @@ class AppRegistrationNetworkController(
}
}
override fun startLinkDeviceProvisioning(): Flow<LinkDeviceProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
val handle = ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
identityKeyPair = IdentityKeyPair.generate(),
configuration = configuration,
handler = { id, t ->
Log.w(TAG, "[startLinkDeviceProvisioning] Socket [$id] failed", t)
trySend(LinkDeviceProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(LinkDeviceProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
val aci = msg.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(msg.aci)
val pni = msg.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(msg.pni)
trySend(
LinkDeviceProvisioningEvent.MessageReceived(
LinkDeviceProvisioningMessage(
e164 = msg.number!!,
provisioningCode = msg.provisioningCode!!,
aci = aci.toString(),
pni = pni.toString(),
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())),
profileKey = msg.profileKey!!.toByteArray(),
ephemeralBackupKey = msg.ephemeralBackupKey,
accountEntropyPool = msg.accountEntropyPool,
mediaRootBackupKey = msg.mediaRootBackupKey,
readReceipts = msg.readReceipts
)
)
)
channel.close()
} else {
Log.w(TAG, "[startLinkDeviceProvisioning] Failed to decrypt provisioning message")
trySend(LinkDeviceProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startLinkDeviceProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
override suspend fun registerAsLinkedDevice(
e164: String,
password: String,
provisioningCode: String,
deviceAttributes: DeviceAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?
): RequestResult<LinkDeviceResponse, RegisterAsLinkedDeviceError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.registerAsSecondaryDevice(
e164,
password,
RegisterAsSecondaryDeviceRequest(
verificationCode = provisioningCode,
accountAttributes = deviceAttributes.toServiceDeviceAttributes(),
aciSignedPreKey = SignedPreKeyEntity(aciPreKeys.signedPreKey.id.toLong(), aciPreKeys.signedPreKey.keyPair.publicKey, aciPreKeys.signedPreKey.signature),
pniSignedPreKey = SignedPreKeyEntity(pniPreKeys.signedPreKey.id.toLong(), pniPreKeys.signedPreKey.keyPair.publicKey, pniPreKeys.signedPreKey.signature),
aciPqLastResortPreKey = KyberPreKeyEntity(aciPreKeys.lastResortKyberPreKey.id.toLong(), aciPreKeys.lastResortKyberPreKey.keyPair.publicKey, aciPreKeys.lastResortKyberPreKey.signature),
pniPqLastResortPreKey = KyberPreKeyEntity(pniPreKeys.lastResortKyberPreKey.id.toLong(), pniPreKeys.lastResortKyberPreKey.keyPair.publicKey, pniPreKeys.lastResortKyberPreKey.signature),
gcmToken = fcmToken?.let { GcmRegistrationId(it, true) }
)
).use { response ->
when (response.code) {
200 -> RequestResult.Success(json.decodeFromString<LinkDeviceResponse>(response.body.string()))
403 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.IncorrectVerification)
409 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MissingCapability)
411 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MaxLinkedDevices)
422 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.InvalidRequest(response.body.string()))
429 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.RateLimited(response.retryAfter()))
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun onLinkedDeviceRegistered() = withContext(Dispatchers.IO) {
try {
RemoteConfig.refreshSync()
} catch (e: IOException) {
Log.w(TAG, "[onLinkedDeviceRegistered] Failed to refresh remote config.", e)
}
for (type in SyncMessage.Request.Type.entries) {
if (type == SyncMessage.Request.Type.UNKNOWN) {
continue
}
Log.i(TAG, "[onLinkedDeviceRegistered] Sending sync request for $type")
try {
retryWithBackoff {
AppDependencies.signalServiceMessageSender.sendSyncMessage(
SignalServiceSyncMessage.forRequest(RequestMessage(SyncMessage.Request(type = type)))
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "[onLinkedDeviceRegistered] Failed to send sync request for $type after retries; continuing.", e)
}
}
}
private suspend fun <T> retryWithBackoff(maxAttempts: Int = 3, initialDelay: Duration = 1.seconds, block: suspend () -> T): T {
var attempt = 0
while (true) {
try {
return block()
} catch (e: IOException) {
attempt++
if (attempt >= maxAttempts) {
throw e
}
val backoff = initialDelay * attempt
Log.w(TAG, "[retryWithBackoff] Attempt $attempt failed; retrying in $backoff.", e)
delay(backoff)
}
}
}
override suspend fun restoreLinkedDeviceFromStorageService() = withContext(Dispatchers.IO) {
if (SignalStore.account.restoredAccountEntropyPoolFromPrimary) {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account data from storage service.")
try {
StorageServiceRestore.restore()
} catch (e: CancellationException) {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account cancelled.", e)
throw e
} catch (e: Exception) {
Log.w(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore failed.", e)
}
} else {
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] No account entropy pool from primary; skipping storage service restore.")
}
}
override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) {
val response = awaitTransferArchiveFromPrimary()
val result = when {
response == null -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.error == TransferArchiveResponse.ERROR_RELINK_REQUESTED -> LinkAndSyncWaitResult.RelinkRequired
response.error == TransferArchiveResponse.ERROR_CONTINUE_WITHOUT_UPLOAD -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.hasArchive -> LinkAndSyncWaitResult.ArchiveAvailable(cdn = response.cdn!!, key = response.key!!)
else -> LinkAndSyncWaitResult.ContinueWithoutBackup
}
Log.i(TAG, "[awaitLinkAndSyncArchive] Result: $result")
result
}
/**
* Waits for the primary device to make a link-and-sync transfer archive available, long-polling
* [org.signal.network.api.LinkDeviceApi.waitForPrimaryDevice] and retrying transient errors until
* [maxWaitTime] elapses. Returns null if no archive becomes available.
*/
private suspend fun awaitTransferArchiveFromPrimary(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
while (timeRemaining > 0 && coroutineContext.isActive) {
Log.d(TAG, "[awaitTransferArchiveFromPrimary] Willing to wait for $timeRemaining ms...")
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[awaitTransferArchiveFromPrimary] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})")
return result.result
}
is NetworkResult.ApplicationError -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Error processing response", result.throwable)
return null
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Network error while waiting; will retry after $RETRY_BACKOFF.", result.exception)
delay(RETRY_BACKOFF)
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
400 -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Invalid timeout.")
return null
}
429 -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Rate-limited; will retry after ${result.retryAfter()}.")
result.retryAfter()?.let { delay(it) }
}
else -> {
Log.w(TAG, "[awaitTransferArchiveFromPrimary] Unexpected status ${result.code}; will retry after $RETRY_BACKOFF.")
delay(RETRY_BACKOFF)
}
}
}
}
timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime)
}
Log.w(TAG, "[awaitTransferArchiveFromPrimary] No transfer archive from primary within $maxWaitTime.")
return null
}
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,
@@ -832,7 +1102,7 @@ class AppRegistrationNetworkController(
unrestrictedUnidentifiedAccess,
capabilities?.toServiceCapabilities(),
discoverableByPhoneNumber,
name,
null,
pniRegistrationId,
recoveryPassword
)
@@ -848,6 +1118,16 @@ class AppRegistrationNetworkController(
)
}
private fun DeviceAttributes.toServiceDeviceAttributes(): ServiceDeviceAttributes {
return ServiceDeviceAttributes(
fetchesMessages = fetchesMessages,
registrationId = registrationId,
pniRegistrationId = pniRegistrationId,
name = name,
capabilities = capabilities?.toServiceCapabilities()
)
}
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
return ServicePreKeyCollection(
identityKey = identityKey,
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -23,6 +24,9 @@ import org.greenrobot.eventbus.ThreadMode
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.AppUtil
import org.signal.core.util.Result
import org.signal.core.util.StreamUtil
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.logging.Log
@@ -32,6 +36,7 @@ import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -42,10 +47,12 @@ import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
@@ -55,6 +62,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
@@ -105,6 +113,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
Unit
}
override suspend fun clearLocalDataAndRestart() = withContext(Dispatchers.Main) {
Log.w(TAG, "[clearLocalDataAndRestart] Wiping all local app data and attempting to relaunch.")
AppUtil.clearAllDataAndRestart(context)
}
override suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
return@withContext StoredProfileData()
@@ -125,9 +138,9 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
val discoverable: Boolean? = when (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode) {
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false
PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null
}
StoredProfileData(
@@ -173,7 +186,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
// than lazily generating a new AEP.
val accountEntropyPool: AccountEntropyPool? = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) }
if (accountEntropyPool != null) {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
if (data.linkedDeviceData != null) {
SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(accountEntropyPool)
} else {
SignalStore.account.restoreAccountEntropyPool(accountEntropyPool)
}
}
val masterKey: MasterKey? = accountEntropyPool?.deriveMasterKey()
@@ -215,15 +232,27 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
fcmEnabled = SignalStore.account.fcmEnabled
fcmToken = SignalStore.account.fcmToken ?: ""
reglockEnabled = data.registrationLockEnabled
data.linkedDeviceData?.let { linkData ->
linkedDeviceInfo = LinkedDeviceInfo(
deviceId = linkData.deviceId,
deviceName = linkData.deviceName,
ephemeralBackupKey = linkData.ephemeralBackupKey,
accountEntropyPool = data.accountEntropyPool,
mediaRootBackupKey = linkData.mediaRootBackupKey
)
}
}.build()
// TODO [greyson] Should probably move this stuff into this file as we get closer to being done
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.localRegistrationMetadata = metadata
data.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) }
}
// Handle PIN/master key
if (data.pin.isNotEmpty() && masterKey != null) {
if (data.pin.isNotEmpty() && masterKey != null && data.linkedDeviceData == null) {
SvrRepository.onRegistrationComplete(
masterKey,
data.pin,
@@ -321,11 +350,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is org.signal.core.util.Result.Success -> {
is Result.Success -> {
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
is org.signal.core.util.Result.Failure -> {
is Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
}
@@ -451,6 +480,56 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
}
}
override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress> = callbackFlow {
val ephemeralBackupKeyBytes = SignalStore.registration.localRegistrationMetadata?.linkedDeviceInfo?.ephemeralBackupKey?.toByteArray()
if (ephemeralBackupKeyBytes == null) {
Log.i(TAG, "[restoreLinkAndSyncBackup] No ephemeral backup key present; nothing to restore.")
trySend(LinkAndSyncProgress.Complete)
channel.close()
return@callbackFlow
}
val subscriber = object {
@Subscribe(threadMode = ThreadMode.POSTING)
fun onRestoreEvent(event: RestoreV2Event) {
val progress = when (event.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> LinkAndSyncProgress.Downloading(event.count, event.estimatedTotalCount)
RestoreV2Event.Type.PROGRESS_RESTORE -> LinkAndSyncProgress.Restoring
RestoreV2Event.Type.PROGRESS_FINALIZING -> LinkAndSyncProgress.Restoring
}
trySend(progress)
}
}
EventBus.getDefault().register(subscriber)
launch(Dispatchers.IO) {
try {
when (val result = BackupRepository.restoreLinkAndSyncBackup(TransferArchiveResponse(cdn = cdn, key = key), MessageBackupKey(ephemeralBackupKeyBytes))) {
RemoteRestoreResult.Success -> send(LinkAndSyncProgress.Complete)
RemoteRestoreResult.Canceled -> Log.i(TAG, "[restoreLinkAndSyncBackup] Restore canceled.")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore did not succeed: $result")
send(LinkAndSyncProgress.Failed())
}
}
} catch (e: CancellationException) {
Log.d(TAG, "[restoreLinkAndSyncBackup] Restore cancelled, aborting.")
throw e
} catch (e: Exception) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore failed.", e)
send(LinkAndSyncProgress.Failed(e))
} finally {
channel.close()
}
}
awaitClose {
EventBus.getDefault().unregister(subscriber)
}
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.registration.secondary
package org.signal.core.util.crypto
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
@@ -7,8 +7,6 @@ import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
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
@@ -22,7 +20,7 @@ import javax.crypto.spec.SecretKeySpec
*/
object DeviceNameCipher {
private val TAG = Log.tag(DeviceNameCipher::class.java)
private val TAG = Log.tag(DeviceNameCipher::class)
private const val SYNTHETIC_IV_LENGTH = 16
@@ -35,7 +33,7 @@ object DeviceNameCipher {
val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(createEmptyByteArray(16)))
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16)))
val cipherText = cipher.doFinal(plaintext)
return DeviceName(
@@ -61,23 +59,13 @@ object DeviceNameCipher {
val ephemeralPublic = ECPublicKey(deviceName.ephemeralPublic.toByteArray())
val masterSecret = identityKey.calculateAgreement(ephemeralPublic)
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 cipherKey = computeCipherKey(masterSecret, 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)
val ourSyntheticIv = computeSyntheticIv(masterSecret, plaintext)
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.")
@@ -116,6 +104,4 @@ object DeviceNameCipher {
ivMac.init(SecretKeySpec(syntheticIvKey, "HmacSHA256"))
return ivMac.doFinal(plaintext).sliceArray(0 until SYNTHETIC_IV_LENGTH)
}
private fun createEmptyByteArray(length: Int): ByteArray = ByteArray(length)
}
@@ -8,7 +8,7 @@ syntax = "proto2";
package signalservice;
option java_package = "org.thoughtcrime.securesms.devicelist.protos";
option java_package = "org.signal.core.util.crypto";
message DeviceName {
optional bytes ephemeralPublic = 1;
@@ -1,10 +1,9 @@
package org.thoughtcrime.securesms.registration.secondary
package org.signal.core.util.crypto
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
import org.signal.libsignal.protocol.IdentityKeyPair
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
import java.nio.charset.Charset
class DeviceNameCipherTest {
@@ -1,26 +0,0 @@
package org.signal.core.util;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
public final class AppUtil {
private AppUtil() {}
/**
* Restarts the application. Should generally only be used for internal tools.
*/
public static void restart(@NonNull Context context) {
String packageName = context.getPackageName();
Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName);
defaultIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(defaultIntent);
Runtime.getRuntime().exit(0);
}
}
@@ -0,0 +1,73 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import org.signal.core.util.logging.Log
object AppUtil {
private val TAG = Log.tag(AppUtil::class)
private const val RESTART_REQUEST_CODE = 1
private const val RESTART_DELAY_MS = 250L
/**
* Restarts the application. Should generally only be used for internal tools.
*/
@JvmStatic
fun restart(context: Context) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (launchIntent != null) {
context.startActivity(launchIntent)
}
Runtime.getRuntime().exit(0)
}
/**
* DANGER! Wipes ALL local app data (databases, key-value store, shared prefs, files) and attempts to relaunch the app
* into a fresh state.
*/
@JvmStatic
fun clearAllDataAndRestart(context: Context) {
scheduleRestart(context)
val activityManager = ContextCompat.getSystemService(context, ActivityManager::class.java)
if (activityManager == null || !activityManager.clearApplicationUserData()) {
Log.w(TAG, "Could not wipe app data; falling back to a plain restart.")
restart(context)
}
}
private fun scheduleRestart(context: Context) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
if (launchIntent == null) {
Log.w(TAG, "No launch intent available; cannot schedule a relaunch.")
return
}
val alarmManager = ContextCompat.getSystemService(context, AlarmManager::class.java)
if (alarmManager == null) {
Log.w(TAG, "No AlarmManager available; cannot schedule a relaunch.")
return
}
val pendingIntent = PendingIntent.getActivity(context, RESTART_REQUEST_CODE, launchIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT)
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + RESTART_DELAY_MS, pendingIntent)
}
}
@@ -136,7 +136,7 @@ private fun SampleNavHost(
context = context.applicationContext,
networkController = registrationDependencies.networkController,
storageController = registrationDependencies.storageController,
isLinkAndSyncAvailable = false
isLinkAndSyncAvailable = registrationDependencies.isLinkAndSyncAvailable
)
}
@@ -69,7 +69,7 @@ class RegistrationApplication : Application() {
.setPositiveButton(android.R.string.ok, null)
.show()
},
isLinkAndSyncAvailable = false
isLinkAndSyncAvailable = true
)
)
@@ -10,12 +10,14 @@ import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CheckSvrCredentialsError
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.DeviceAttributes
import org.signal.registration.NetworkController.GetBackupInfoError
import org.signal.registration.NetworkController.GetBackupInfoResponse
import org.signal.registration.NetworkController.GetSessionStatusError
@@ -246,7 +248,35 @@ class DebugNetworkController(
return delegate.startProvisioning()
}
override fun startNewDeviceTransferServer(context: android.content.Context, aep: org.signal.core.models.AccountEntropyPool) {
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> {
return delegate.startLinkDeviceProvisioning()
}
override suspend fun registerAsLinkedDevice(
e164: String,
password: String,
provisioningCode: String,
deviceAttributes: DeviceAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?
): RequestResult<NetworkController.LinkDeviceResponse, NetworkController.RegisterAsLinkedDeviceError> {
return delegate.registerAsLinkedDevice(e164, password, provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, fcmToken)
}
override suspend fun onLinkedDeviceRegistered() {
delegate.onLinkedDeviceRegistered()
}
override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult {
return delegate.awaitLinkAndSyncArchive()
}
override suspend fun restoreLinkedDeviceFromStorageService() {
delegate.restoreLinkedDeviceFromStorageService()
}
override fun startNewDeviceTransferServer(context: android.content.Context, aep: AccountEntropyPool) {
if (NetworkDebugState.fakeDeviceTransfer.value) {
Log.d(TAG, "[startNewDeviceTransferServer] Fake device transfer enabled (debug override)")
org.signal.registration.sample.dependencies.FakeDeviceTransferRunner.start()
@@ -39,12 +39,16 @@ import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.signal.network.NetworkResult
import org.signal.network.api.LinkDeviceApi
import org.signal.network.service.StorageServiceService
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CheckSvrCredentialsRequest
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.DeviceAttributes
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
@@ -63,7 +67,9 @@ import org.signal.registration.sample.MainActivity
import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.signal.registration.sample.storage.RegistrationPreferences
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
@@ -74,17 +80,21 @@ import org.whispersystems.signalservice.api.websocket.WebSocketFactory
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection
import java.io.Closeable
import java.io.IOException
import java.time.Instant
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes as ServiceDeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
class DemoNetworkController(
@@ -98,6 +108,7 @@ class DemoNetworkController(
private val TAG = Log.tag(DemoNetworkController::class)
const val DEVICE_TRANSFER_NOTIFICATION_CHANNEL_ID = "device_transfer"
private const val DEVICE_TRANSFER_NOTIFICATION_ID = 4321
private const val USER_AGENT = "Signal-Android-Registration-Sample"
}
private val json = Json { ignoreUnknownKeys = true }
@@ -424,8 +435,227 @@ class DemoNetworkController(
)
}
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<Closeable>()
fun startSocket() {
val handle = ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
identityKeyPair = IdentityKeyPair.generate(),
configuration = serviceConfiguration,
handler = { id, t ->
Log.w(TAG, "[startLinkDeviceProvisioning] Socket [$id] failed", t)
trySend(NetworkController.LinkDeviceProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(NetworkController.LinkDeviceProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
val aci = msg.aciBinary?.let { ServiceId.ACI.parseOrThrow(it) } ?: ServiceId.ACI.parseOrThrow(msg.aci)
val pni = msg.pniBinary?.let { ServiceId.PNI.parseOrThrow(it) } ?: ServiceId.PNI.parseOrThrow(msg.pni)
trySend(
NetworkController.LinkDeviceProvisioningEvent.MessageReceived(
NetworkController.LinkDeviceProvisioningMessage(
e164 = msg.number!!,
provisioningCode = msg.provisioningCode!!,
aci = aci.toString(),
pni = pni.toString(),
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())),
profileKey = msg.profileKey!!.toByteArray(),
ephemeralBackupKey = msg.ephemeralBackupKey,
accountEntropyPool = msg.accountEntropyPool,
mediaRootBackupKey = msg.mediaRootBackupKey,
readReceipts = msg.readReceipts
)
)
)
channel.close()
} else {
Log.w(TAG, "[startLinkDeviceProvisioning] Failed to decrypt provisioning message")
trySend(NetworkController.LinkDeviceProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startLinkDeviceProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
override suspend fun registerAsLinkedDevice(
e164: String,
password: String,
provisioningCode: String,
deviceAttributes: DeviceAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?
): RequestResult<NetworkController.LinkDeviceResponse, NetworkController.RegisterAsLinkedDeviceError> = withContext(Dispatchers.IO) {
try {
// The link endpoint authenticates via basic auth (e164:password) since this device has no ACI yet.
val credentialsProvider = StaticCredentialsProvider(null, null, e164, 1, password)
val linkSocket = PushServiceSocket(serviceConfiguration, credentialsProvider, USER_AGENT, true)
val result = RegistrationApi(linkSocket).registerAsSecondaryDevice(
provisioningCode,
deviceAttributes.toServiceDeviceAttributes(),
aciPreKeys.toServicePreKeyCollection(),
pniPreKeys.toServicePreKeyCollection(),
fcmToken
)
when (result) {
is NetworkResult.Success -> {
Log.i(TAG, "[registerAsLinkedDevice] Linked successfully (deviceId=${result.result.deviceId}).")
RequestResult.Success(NetworkController.LinkDeviceResponse(deviceId = result.result.deviceId.toInt()))
}
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
is NetworkResult.NetworkError<*> -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.StatusCodeError -> {
Log.w(TAG, "[registerAsLinkedDevice] Status code error: ${result.code}")
val error = when (result.code) {
403 -> NetworkController.RegisterAsLinkedDeviceError.IncorrectVerification
409 -> NetworkController.RegisterAsLinkedDeviceError.MissingCapability
411 -> NetworkController.RegisterAsLinkedDeviceError.MaxLinkedDevices
422 -> NetworkController.RegisterAsLinkedDeviceError.InvalidRequest(result.exception.message)
429 -> NetworkController.RegisterAsLinkedDeviceError.RateLimited(result.retryAfter())
else -> return@withContext RequestResult.ApplicationError(result.exception)
}
RequestResult.NonSuccess(error)
}
}
} catch (e: Exception) {
Log.w(TAG, "[registerAsLinkedDevice] Failed to register as linked device.", e)
RequestResult.ApplicationError(e)
}
}
override suspend fun onLinkedDeviceRegistered() {
// The demo stops before importing the backup proto / setting up app state, so there is no real
// post-registration housekeeping to do here. The account data is persisted via commitRegistrationData().
Log.i(TAG, "[onLinkedDeviceRegistered] No-op in demo.")
}
override suspend fun restoreLinkedDeviceFromStorageService() {
// The demo can do a real storage-service restore -- reuse the same account-record restore the
// normal flow uses. It no-ops gracefully if credentials/master key aren't available.
Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account record from storage service...")
when (val result = restoreAccountRecord(timeout = 30.seconds)) {
is RequestResult.Success -> Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore complete.")
else -> Log.w(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore did not complete: $result")
}
}
override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) {
val response = awaitTransferArchive()
val result = when {
response == null -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.error == TransferArchiveResponse.ERROR_RELINK_REQUESTED -> LinkAndSyncWaitResult.RelinkRequired
response.error == TransferArchiveResponse.ERROR_CONTINUE_WITHOUT_UPLOAD -> LinkAndSyncWaitResult.ContinueWithoutBackup
response.hasArchive -> LinkAndSyncWaitResult.ArchiveAvailable(cdn = response.cdn!!, key = response.key!!)
else -> LinkAndSyncWaitResult.ContinueWithoutBackup
}
Log.i(TAG, "[awaitLinkAndSyncArchive] Result: $result")
result
}
/**
* Connects an authenticated websocket as the linked device and long-polls (retrying up to ~1 hour, mirroring
* Desktop) for the primary to make a transfer archive available. Returns the primary's response (which may carry
* a [TransferArchiveResponse.error] instead of an archive), or null if the primary never responded in time.
*/
private suspend fun awaitTransferArchive(): TransferArchiveResponse? {
val aci = RegistrationPreferences.aci
val pni = RegistrationPreferences.pni
val e164 = RegistrationPreferences.e164
val password = RegistrationPreferences.servicePassword
val deviceId = RegistrationPreferences.linkedDeviceId
if (aci == null || e164 == null || password == null || deviceId <= 0) {
Log.w(TAG, "[awaitTransferArchive] Missing linked-device credentials.")
return null
}
val network = Network(Network.Environment.STAGING, USER_AGENT, emptyMap(), Network.BuildVariant.PRODUCTION)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, deviceId, password)
val healthMonitor = object : HealthMonitor {
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
override fun onReceivedAlerts(alerts: Array<out String>, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
name = "LinkAndSync",
network = network,
credentialsProvider = credentialsProvider,
receiveStories = false,
healthMonitor = healthMonitor
)
val authWebSocket = SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { libSignalConnection },
canConnect = { true },
sleepTimer = { millis -> Thread.sleep(millis) },
disconnectTimeoutMs = 60.seconds.inWholeMilliseconds
)
return try {
authWebSocket.connect()
val deadline = System.currentTimeMillis() + 1.hours.inWholeMilliseconds
var archive: TransferArchiveResponse? = null
while (archive == null && System.currentTimeMillis() < deadline) {
Log.i(TAG, "[awaitTransferArchive] Waiting for primary to provide a transfer archive...")
when (val result = LinkDeviceApi(authWebSocket).waitForPrimaryDevice(timeout = 30.seconds)) {
is NetworkResult.Success -> archive = result.result
else -> Log.d(TAG, "[awaitTransferArchive] No archive yet; continuing to wait.")
}
}
archive
} finally {
authWebSocket.disconnect()
}
}
/** The device id for authenticated calls: the linked-device id if this is a secondary device, else 1 (primary). */
private fun currentDeviceId(): Int = RegistrationPreferences.linkedDeviceId.takeIf { it > 0 } ?: 1
/** Basic-auth username for authenticated REST calls. A secondary device must authenticate as "<aci>.<deviceId>". */
private fun authUsername(aci: ServiceId.ACI): String {
val deviceId = currentDeviceId()
return if (deviceId != 1) "$aci.$deviceId" else aci.toString()
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val socketHandles = mutableListOf<Closeable>()
fun startSocket() {
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
@@ -577,7 +807,7 @@ class DemoNetworkController(
}
val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, currentDeviceId(), password)
val healthMonitor = object : HealthMonitor {
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
@@ -679,7 +909,7 @@ class DemoNetworkController(
val registrationLockToken = masterKey.deriveRegistrationLock()
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val requestBody = """{"registrationLock":"$registrationLockToken"}"""
.toRequestBody("application/json".toMediaType())
@@ -726,7 +956,7 @@ class DemoNetworkController(
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val request = okhttp3.Request.Builder()
@@ -770,7 +1000,7 @@ class DemoNetworkController(
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val requestBody = json.encodeToString(AccountAttributes.serializer(), attributes)
.toRequestBody("application/json".toMediaType())
@@ -817,7 +1047,7 @@ class DemoNetworkController(
}
try {
val credentials = okhttp3.Credentials.basic(aci.toString(), password)
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val request = okhttp3.Request.Builder()
@@ -935,7 +1165,7 @@ class DemoNetworkController(
val storageKey = masterKey.deriveStorageServiceKey()
val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, currentDeviceId(), password)
val healthMonitor = object : HealthMonitor {
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
@@ -1075,7 +1305,6 @@ class DemoNetworkController(
spqr = true,
usernameChangeSyncMessage = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
recoveryPassword = recoveryPassword
)
@@ -1307,7 +1536,7 @@ class DemoNetworkController(
unrestrictedUnidentifiedAccess,
capabilities?.toServiceCapabilities(),
discoverableByPhoneNumber,
name,
null,
pniRegistrationId,
recoveryPassword
)
@@ -1323,6 +1552,16 @@ class DemoNetworkController(
)
}
private fun DeviceAttributes.toServiceDeviceAttributes(): ServiceDeviceAttributes {
return ServiceDeviceAttributes(
fetchesMessages = fetchesMessages,
registrationId = registrationId,
pniRegistrationId = pniRegistrationId,
name = name,
capabilities = capabilities?.toServiceCapabilities()
)
}
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
return ServicePreKeyCollection(
identityKey = identityKey,
@@ -8,22 +8,31 @@ package org.signal.registration.sample.dependencies
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.archive.stream.EncryptedBackupReader
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.AppUtil
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.registration.NetworkController
import org.signal.registration.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
@@ -32,10 +41,17 @@ import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.ProvisioningData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.sample.RegistrationApplication
import org.signal.registration.sample.storage.RegistrationDatabase
import org.signal.registration.sample.storage.RegistrationPreferences
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
import java.io.File
import java.time.LocalDateTime
@@ -49,6 +65,7 @@ class DemoStorageController(private val context: Context) : StorageController {
private val TAG = Log.tag(DemoStorageController::class)
private const val TEMP_PROTO_FILENAME = "registration_data.pb"
private const val SIMULATED_STAGE_DELAY_MS = 500L
private const val USER_AGENT = "Signal-Android-Registration-Sample"
private val MODERN_BACKUP_PATTERN = Regex("^signal-backup-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})$")
private val LEGACY_BACKUP_PATTERN = Regex("^signal-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})\\.backup$")
}
@@ -75,6 +92,14 @@ class DemoStorageController(private val context: Context) : StorageController {
db.clearAllPreKeys()
}
override suspend fun clearLocalDataAndRestart() {
Log.w(TAG, "[clearLocalDataAndRestart] Relink requested; clearing demo data and restarting.")
clearAllData()
withContext(Dispatchers.Main) {
AppUtil.restart(context)
}
}
override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) {
val file = File(context.filesDir, TEMP_PROTO_FILENAME)
if (file.exists()) {
@@ -150,6 +175,13 @@ class DemoStorageController(private val context: Context) : StorageController {
)
}
// Linked-device data (persisted so the link-and-sync step can authenticate as this device and the
// home screen can show the linked account).
data.linkedDeviceData?.let { linkData ->
RegistrationPreferences.linkedDeviceId = linkData.deviceId
RegistrationPreferences.ephemeralBackupKey = linkData.ephemeralBackupKey?.toByteArray()
}
// PIN data
if (data.pin.isNotEmpty()) {
RegistrationPreferences.pin = data.pin
@@ -324,6 +356,89 @@ class DemoStorageController(private val context: Context) : StorageController {
Log.d(TAG, "Simulated remote restore complete.")
}.flowOn(Dispatchers.IO)
/**
* Performs a real link-and-sync restore against the (staging) service. The demo
* stops short of actually persisting any sync data but does read through the frames.
*/
override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress> = callbackFlow {
val aci = RegistrationPreferences.aci
val pni = RegistrationPreferences.pni
val e164 = RegistrationPreferences.e164
val password = RegistrationPreferences.servicePassword
val deviceId = RegistrationPreferences.linkedDeviceId
val ephemeralBackupKeyBytes = RegistrationPreferences.ephemeralBackupKey
if (aci == null || e164 == null || password == null || deviceId <= 0 || ephemeralBackupKeyBytes == null) {
Log.i(TAG, "[restoreLinkAndSyncBackup] No link-and-sync backup expected; nothing to restore.")
trySend(LinkAndSyncProgress.Complete)
close()
return@callbackFlow
}
val job = launch(Dispatchers.IO) {
val tempFile = File.createTempFile("link-and-sync", ".backup", context.cacheDir)
try {
val configuration = RegistrationApplication.serviceConfiguration
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, deviceId, password)
// Download the encrypted backup file from the CDN (cdn/key obtained from awaitLinkAndSyncArchive on the
// network side), reporting progress.
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup from CDN $cdn...")
val messageReceiver = SignalServiceMessageReceiver(PushServiceSocket(configuration, credentialsProvider, USER_AGENT, true))
val progressListener = object : SignalServiceAttachment.ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
trySend(LinkAndSyncProgress.Downloading(progress.transmitted, progress.total))
}
override fun shouldCancel(): Boolean = !isActive
}
val download = messageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempFile, progressListener)
if (download !is NetworkResult.Success) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file.")
trySend(LinkAndSyncProgress.Failed())
return@launch
}
// Decrypt + parse the backup proto to prove the round-trip worked. We intentionally do NOT
// import the frames into a database -- that is the app's full backup-restore pipeline.
trySend(LinkAndSyncProgress.Restoring)
val downloadedBytes = tempFile.length()
var frameCount = 0
EncryptedBackupReader.createForLocalOrLinking(
key = MessageBackupKey(ephemeralBackupKeyBytes),
aci = aci,
length = downloadedBytes,
dataStream = { tempFile.inputStream() }
).use { reader ->
val hasHeader = reader.getHeader() != null
while (reader.hasNext()) {
reader.next()
frameCount++
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Decrypted backup proto (header=$hasHeader, frames=$frameCount). Not importing to a database (out of scope for the demo).")
}
// Persist a summary so the demo's home screen can display the link-and-sync result.
RegistrationPreferences.linkAndSyncFrameCount = frameCount
RegistrationPreferences.linkAndSyncDownloadedBytes = downloadedBytes
trySend(LinkAndSyncProgress.Complete)
} catch (e: CancellationException) {
Log.d(TAG, "[restoreLinkAndSyncBackup] Restore cancelled, aborting.")
throw e
} catch (e: Exception) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore failed.", e)
trySend(LinkAndSyncProgress.Failed(e))
} finally {
tempFile.delete()
close()
}
}
awaitClose { job.cancel() }
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.filesDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))
@@ -151,6 +151,11 @@ fun MainScreen(
RegistrationInfo(state.existingRegistrationState)
if (state.linkedDeviceState != null) {
Spacer(modifier = Modifier.height(16.dp))
LinkedDeviceInfo(state.linkedDeviceState)
}
if (state.profileState != null) {
Spacer(modifier = Modifier.height(16.dp))
ProfileInfo(state.profileState)
@@ -236,6 +241,40 @@ private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) {
}
}
@Composable
private fun LinkedDeviceInfo(data: MainScreenState.LinkedDeviceState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Linked Device",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
RegistrationField(label = "Device Type", value = "Linked (secondary)")
RegistrationField(label = "Device ID", value = data.deviceId.toString())
RegistrationField(label = "Link & Sync Offered", value = if (data.linkAndSyncOffered) "Yes" else "No")
RegistrationField(
label = "Link & Sync Backup",
value = when {
!data.linkAndSyncOffered -> "Not offered by primary"
data.linkAndSyncFrameCount >= 0 -> "Downloaded ${data.linkAndSyncDownloadedBytes} bytes, ${data.linkAndSyncFrameCount} frames (not imported)"
else -> "Offered, not completed"
}
)
}
}
}
@Composable
private fun ProfileInfo(profile: MainScreenState.ProfileState) {
Card(
@@ -9,8 +9,20 @@ data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null,
val registrationExpired: Boolean = false,
val pendingFlowState: PendingFlowState? = null,
val profileState: ProfileState? = null
val profileState: ProfileState? = null,
val linkedDeviceState: LinkedDeviceState? = null
) {
/**
* Details specific to this device having been registered as a linked (secondary) device.
* Null when this is a primary device.
*/
data class LinkedDeviceState(
val deviceId: Int,
val linkAndSyncOffered: Boolean,
val linkAndSyncFrameCount: Int,
val linkAndSyncDownloadedBytes: Long
)
data class PendingFlowState(
val e164: String?,
val backstackSize: Int,
@@ -89,6 +89,16 @@ class MainScreenViewModel(
)
},
pendingFlowState = loadPendingFlowState(),
linkedDeviceState = if (RegistrationPreferences.linkedDeviceId > 0) {
MainScreenState.LinkedDeviceState(
deviceId = RegistrationPreferences.linkedDeviceId,
linkAndSyncOffered = RegistrationPreferences.ephemeralBackupKey != null,
linkAndSyncFrameCount = RegistrationPreferences.linkAndSyncFrameCount,
linkAndSyncDownloadedBytes = RegistrationPreferences.linkAndSyncDownloadedBytes
)
} else {
null
},
registrationExpired = false
)
@@ -185,7 +185,6 @@ class PinSettingsViewModel(
spqr = true,
usernameChangeSyncMessage = true
),
name = null,
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
recoveryPassword = recoveryPassword
)
@@ -60,6 +60,10 @@ object RegistrationPreferences {
private const val KEY_PROFILE_AVATAR = "profile_avatar"
private const val KEY_PROFILE_DISCOVERABLE = "profile_discoverable"
private const val KEY_RESTORE_DECISION = "restore_decision"
private const val KEY_LINKED_DEVICE_ID = "linked_device_id"
private const val KEY_EPHEMERAL_BACKUP_KEY = "ephemeral_backup_key"
private const val KEY_LINK_AND_SYNC_FRAME_COUNT = "link_and_sync_frame_count"
private const val KEY_LINK_AND_SYNC_DOWNLOADED_BYTES = "link_and_sync_downloaded_bytes"
fun init(context: Application) {
this.context = context
@@ -87,7 +91,7 @@ object RegistrationPreferences {
var aep: AccountEntropyPool?
get() = prefs.getString(KEY_AEP, null)?.let { AccountEntropyPool(it) }
set(value) = prefs.edit { putString(KEY_AEP, value?.toString()) }
set(value) = prefs.edit { putString(KEY_AEP, value?.value) }
var profileKey: ProfileKey?
get() = prefs.getString(KEY_PROFILE_KEY, null)?.let { ProfileKey(Base64.decode(it)) }
@@ -165,6 +169,22 @@ object RegistrationPreferences {
if (value == null) remove(KEY_PROFILE_DISCOVERABLE) else putBoolean(KEY_PROFILE_DISCOVERABLE, value)
}
var linkedDeviceId: Int
get() = prefs.getInt(KEY_LINKED_DEVICE_ID, -1)
set(value) = prefs.edit { putInt(KEY_LINKED_DEVICE_ID, value) }
var ephemeralBackupKey: ByteArray?
get() = prefs.getString(KEY_EPHEMERAL_BACKUP_KEY, null)?.let { Base64.decode(it) }
set(value) = prefs.edit { putString(KEY_EPHEMERAL_BACKUP_KEY, value?.let { Base64.encodeWithPadding(it) }) }
var linkAndSyncFrameCount: Int
get() = prefs.getInt(KEY_LINK_AND_SYNC_FRAME_COUNT, -1)
set(value) = prefs.edit { putInt(KEY_LINK_AND_SYNC_FRAME_COUNT, value) }
var linkAndSyncDownloadedBytes: Long
get() = prefs.getLong(KEY_LINK_AND_SYNC_DOWNLOADED_BYTES, -1L)
set(value) = prefs.edit { putLong(KEY_LINK_AND_SYNC_DOWNLOADED_BYTES, value) }
var restoredSvr2Credentials: List<NetworkController.SvrCredentials>
get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList()
set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) }
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okio.ByteString
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
@@ -229,6 +230,68 @@ interface NetworkController {
*/
fun startProvisioning(): Flow<ProvisioningEvent>
/**
* Starts a provisioning session for QR-based device linking (registering this device as a secondary
* device on a pre-existing account).
*
* The returned flow emits [LinkDeviceProvisioningEvent]s:
* - [LinkDeviceProvisioningEvent.QrCodeReady] whenever a new QR code URL is available (e.g. due to socket rotation).
* - [LinkDeviceProvisioningEvent.MessageReceived] when the primary device scans the QR code and sends provisioning data.
* - [LinkDeviceProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error.
*
* The flow manages socket lifecycle (rotation, keep-alive) internally. Cancel the collecting coroutine to stop provisioning.
*/
fun startLinkDeviceProvisioning(): Flow<LinkDeviceProvisioningEvent>
/**
* Performs the network call to register this device as a linked (secondary) device on a pre-existing
* account (`PUT /v1/devices/link`), authenticated via basic auth with [e164] and [password].
*
* This only performs the network request and returns the assigned device id. The caller is responsible
* for committing the account locally (via [StorageController.commitRegistrationData]) and performing the
* post-registration housekeeping (via [onLinkedDeviceRegistered]) and any restores.
*/
suspend fun registerAsLinkedDevice(
e164: String,
password: String,
provisioningCode: String,
deviceAttributes: DeviceAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?
): RequestResult<LinkDeviceResponse, RegisterAsLinkedDeviceError>
/**
* Performs the network-side post-registration work for a freshly linked device, after the account has been
* committed locally via [StorageController.commitRegistrationData]: refreshes remote config and requests the
* initial sync messages from the primary.
*
* Intentionally does *not* include the link-and-sync backup restore (see [StorageController.restoreLinkAndSyncBackup])
* or the storage-service restore (see [restoreLinkedDeviceFromStorageService]); the registration module
* sequences those separately so progress can be surfaced and timing controlled. Local-state finalization (e.g.
* the read-receipts preference) is applied as part of [StorageController.commitRegistrationData].
*/
suspend fun onLinkedDeviceRegistered()
/**
* Waits for the primary device to make a decision on a link-and-sync transfer (a long-poll that may be
* retried internally up to ~1 hour).
*
* Intended to be called while showing a spinner before navigating to the message-sync screen.
*/
suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult
/**
* Restores account data from the storage service after this device has been linked as a secondary device.
*
* The registration module decides *when* to call this: immediately after [registerAsLinkedDevice] when there
* is no link-and-sync backup, or only after the link-and-sync backup has been applied when there is one.
*
* Implementations should be best-effort and may no-op when there is nothing to restore (e.g. when the primary
* did not share an account entropy pool).
*/
suspend fun restoreLinkedDeviceFromStorageService()
/**
* Starts `DeviceToDeviceTransferService` in server mode on the new device. The concrete
* [org.signal.devicetransfer.ServerTask] that receives and imports the backup lives in the app
@@ -283,19 +346,6 @@ interface NetworkController {
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, SetProfileError>
// /**
// * Registers a device as a linked device on a pre-existing account.
// *
// * `PUT /v1/devices/link`
// *
// * - 403: Incorrect account verification
// * - 409: Device missing required account capability
// * - 411: Account reached max number of linked devices
// * - 422: Request is invalid
// * - 429: Rate limited
// */
// suspend fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?)
sealed class CreateSessionError : BadRequestError {
data class InvalidRequest(val message: String) : CreateSessionError()
data class RateLimited(val retryAfter: Duration) : CreateSessionError()
@@ -424,7 +474,6 @@ interface NetworkController {
val unrestrictedUnidentifiedAccess: Boolean,
val discoverableByPhoneNumber: Boolean,
val capabilities: Capabilities?,
val name: String?,
val pniRegistrationId: Int,
val recoveryPassword: String?
) {
@@ -439,6 +488,15 @@ interface NetworkController {
)
}
@Serializable
class DeviceAttributes(
val fetchesMessages: Boolean,
val registrationId: Int,
val pniRegistrationId: Int,
val name: String?,
val capabilities: AccountAttributes.Capabilities?
)
@Serializable
@Parcelize
data class RegisterAccountResponse(
@@ -589,4 +647,66 @@ interface NetworkController {
/** The provisioning session encountered an error. */
data class Error(val cause: Throwable?) : ProvisioningEvent
}
/**
* Data received from the primary device during QR-based device linking.
*
* The ACI/PNI are resolved to their canonical string form by the implementation. Identity keys are
* provided by the primary so this device shares the account's identity.
*/
class LinkDeviceProvisioningMessage(
val e164: String,
val provisioningCode: String,
val aci: String,
val pni: String,
val aciIdentityKeyPair: IdentityKeyPair,
val pniIdentityKeyPair: IdentityKeyPair,
val profileKey: ByteArray,
val ephemeralBackupKey: ByteString?,
val accountEntropyPool: String?,
val mediaRootBackupKey: ByteString?,
val readReceipts: Boolean?
)
/**
* Events emitted during a device-linking provisioning session.
*/
sealed interface LinkDeviceProvisioningEvent {
/** A new QR code URL is available for display. */
data class QrCodeReady(val url: String) : LinkDeviceProvisioningEvent
/** The primary device has scanned the QR code and sent provisioning data. */
data class MessageReceived(val message: LinkDeviceProvisioningMessage) : LinkDeviceProvisioningEvent
/** The provisioning session encountered an error. */
data class Error(val cause: Throwable?) : LinkDeviceProvisioningEvent
}
/** Minimal view of the `PUT /v1/devices/link` success body; we only need the assigned device id. */
@Serializable
data class LinkDeviceResponse(
val deviceId: Int
)
sealed interface RegisterAsLinkedDeviceError : BadRequestError {
data object IncorrectVerification : RegisterAsLinkedDeviceError
data object MissingCapability : RegisterAsLinkedDeviceError
data object MaxLinkedDevices : RegisterAsLinkedDeviceError
data class InvalidRequest(val message: String? = null) : RegisterAsLinkedDeviceError
data class RateLimited(val retryAfter: Duration?) : RegisterAsLinkedDeviceError
}
}
/**
* Result of waiting for the primary's link-and-sync transfer archive.
*/
sealed interface LinkAndSyncWaitResult {
/** The primary made a backup available at the given CDN location; proceed to download + apply it. */
data class ArchiveAvailable(val cdn: Int, val key: String) : LinkAndSyncWaitResult
/** The primary declined to sync, or never delivered an archive in time. */
data object ContinueWithoutBackup : LinkAndSyncWaitResult
/** The primary asked this device to re-link. Registration should be reset. */
data object RelinkRequired : LinkAndSyncWaitResult
}
@@ -11,14 +11,20 @@ import android.net.Uri
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.Util
import org.signal.core.util.crypto.DeviceNameCipher
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKeyPair
@@ -30,6 +36,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.DeviceAttributes
import org.signal.registration.NetworkController.MasterKeyResponse
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
@@ -40,11 +47,14 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.proto.LinkedDeviceData
import org.signal.registration.proto.ProvisioningData
import org.signal.registration.proto.SvrCredential
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.signal.registration.util.SensitiveLog
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.util.Locale
import javax.crypto.Cipher
@@ -250,6 +260,160 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return networkController.startProvisioning()
}
/**
* Starts a provisioning session for QR-based device linking.
* See [NetworkController.startLinkDeviceProvisioning].
*/
fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> {
return networkController.startLinkDeviceProvisioning()
}
/**
* Registers this device as a linked (secondary) device using data from the primary.
* See [NetworkController.registerAsLinkedDevice].
*/
suspend fun registerAsLinkedDevice(
message: NetworkController.LinkDeviceProvisioningMessage,
deviceName: String
): RequestResult<LinkedDeviceResult, NetworkController.RegisterAsLinkedDeviceError> = withContext(Dispatchers.IO) {
checkNotNull(message.accountEntropyPool) { "Link provisioning message missing account entropy pool" }
val e164 = message.e164
val accountEntropyPool = AccountEntropyPool(message.accountEntropyPool)
val aci = ACI.parseOrThrow(message.aci)
val pni = PNI.parseOrThrow(message.pni)
val aciIdentityKeyPair = message.aciIdentityKeyPair
val pniIdentityKeyPair = message.pniIdentityKeyPair
val profileKey = ProfileKey(message.profileKey)
val provisioningCode = message.provisioningCode
val keyMaterial = generateKeyMaterial(
existingAccountEntropyPool = accountEntropyPool,
existingAciIdentityKeyPair = aciIdentityKeyPair,
existingPniIdentityKeyPair = pniIdentityKeyPair,
profileKey = profileKey
)
storageController.updateInProgressRegistrationData {
this.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair.serialize().toByteString()
this.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair.serialize().toByteString()
this.aciSignedPreKey = keyMaterial.aciSignedPreKey.serialize().toByteString()
this.pniSignedPreKey = keyMaterial.pniSignedPreKey.serialize().toByteString()
this.aciLastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey.serialize().toByteString()
this.pniLastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey.serialize().toByteString()
this.aciRegistrationId = keyMaterial.aciRegistrationId
this.pniRegistrationId = keyMaterial.pniRegistrationId
this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString()
this.profileKey = keyMaterial.profileKey.toByteString()
this.servicePassword = keyMaterial.servicePassword
this.accountEntropyPool = keyMaterial.accountEntropyPool.value
}
val fcmToken = networkController.getFcmToken()
storageController.updateInProgressRegistrationData {
this.fetchesMessages = fcmToken == null
}
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair)
val deviceAttributes = DeviceAttributes(
fetchesMessages = fcmToken == null,
registrationId = keyMaterial.aciRegistrationId,
pniRegistrationId = keyMaterial.pniRegistrationId,
name = Base64.encodeWithPadding(encryptedDeviceName),
capabilities = getAccountCapabilities()
)
val aciPreKeys = PreKeyCollection(
identityKey = keyMaterial.aciIdentityKeyPair.publicKey,
signedPreKey = keyMaterial.aciSignedPreKey,
lastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey
)
val pniPreKeys = PreKeyCollection(
identityKey = keyMaterial.pniIdentityKeyPair.publicKey,
signedPreKey = keyMaterial.pniSignedPreKey,
lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey
)
val result = networkController.registerAsLinkedDevice(
e164 = e164,
password = keyMaterial.servicePassword,
provisioningCode = provisioningCode,
deviceAttributes = deviceAttributes,
aciPreKeys = aciPreKeys,
pniPreKeys = pniPreKeys,
fcmToken = fcmToken
)
if (result is RequestResult.Success) {
storageController.updateInProgressRegistrationData {
this.e164 = e164
this.aci = aci.toString()
this.pni = pni.toString()
this.linkedDeviceData = LinkedDeviceData(
deviceId = result.result.deviceId.toInt(),
deviceName = deviceName,
ephemeralBackupKey = message.ephemeralBackupKey,
mediaRootBackupKey = message.mediaRootBackupKey,
readReceipts = message.readReceipts
)
}
// Commits the account locally (writes keys, identity, the LinkedDeviceInfo and read-receipts pref built from linkedDeviceData).
storageController.commitRegistrationData()
// Network post-registration work: refresh remote config and request the initial sync from the primary.
// The link-and-sync backup restore and storage-service restore are sequenced separately by the caller.
networkController.onLinkedDeviceRegistered()
}
result.map { LinkedDeviceResult(hasLinkAndSyncBackup = message.ephemeralBackupKey != null) }
}
/**
* Restores the link-and-sync message backup made available by the primary device, surfacing progress.
* See [StorageController.restoreLinkAndSyncBackup].
*/
fun restoreLinkAndSyncBackup(): Flow<LinkAndSyncProgress> = flow {
emit(LinkAndSyncProgress.Waiting)
when (val result = networkController.awaitLinkAndSyncArchive()) {
is LinkAndSyncWaitResult.ArchiveAvailable -> emitAll(storageController.restoreLinkAndSyncBackup(result.cdn, result.key))
LinkAndSyncWaitResult.ContinueWithoutBackup -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Primary declined to provide a backup; continuing without one.")
emit(LinkAndSyncProgress.Complete)
}
LinkAndSyncWaitResult.RelinkRequired -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Primary requested re-link; local registration is invalid.")
emit(LinkAndSyncProgress.RelinkRequired)
}
}
}
/**
* Waits for the primary to make a link-and-sync archive available.
*/
suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) {
val ephemeralBackupKey = storageController.readInProgressRegistrationData().linkedDeviceData?.ephemeralBackupKey
if (ephemeralBackupKey == null) {
Log.i(TAG, "[awaitLinkAndSyncArchive] No ephemeral backup key in registration data; no archive expected.")
return@withContext LinkAndSyncWaitResult.ContinueWithoutBackup
}
networkController.awaitLinkAndSyncArchive()
}
/**
* Restores account data from the storage service after linking. Call immediately after linking when there
* is no link-and-sync backup, or only after the backup has been applied when there is one.
*
* See [NetworkController.restoreLinkedDeviceFromStorageService].
*/
suspend fun restoreLinkedDeviceFromStorageService() = withContext(Dispatchers.IO) {
networkController.restoreLinkedDeviceFromStorageService()
setRestoreDecision(RestoreDecision.NEW_ACCOUNT)
}
/**
* Reports the user's chosen restore method to the server so the old (quick-restore) device's UI can update.
* See [NetworkController.setRestoreMethod].
@@ -370,7 +534,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
val newMasterKey = keyMaterial.accountEntropyPool.deriveMasterKey()
val newRecoveryPassword = newMasterKey.deriveRegistrationRecoveryPassword()
SensitiveLog.d(TAG, "[registerAccount] Using master key [${org.signal.core.util.Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]")
SensitiveLog.d(TAG, "[registerAccount] Using master key [${Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]")
val accountAttributes = AccountAttributes(
signalingKey = null,
@@ -382,14 +546,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess,
discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later
capabilities = AccountAttributes.Capabilities(
storage = true, // True initially -- can turn off later if users opt-out
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true,
usernameChangeSyncMessage = true
),
name = null,
capabilities = getAccountCapabilities(),
pniRegistrationId = keyMaterial.pniRegistrationId,
recoveryPassword = newRecoveryPassword
)
@@ -594,6 +751,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ
}
}
/**
* Wipes all local app data and relaunches the app. Used when the primary asks a freshly-linked device to
* re-link, which leaves this device's partial local registration invalid.
*/
suspend fun clearLocalDataAndRestart() {
storageController.clearLocalDataAndRestart()
}
/**
* Clears any persisted flow state JSON from the in-progress registration data.
*/
@@ -666,7 +831,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ
private fun generateKeyMaterial(
existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
existingPniIdentityKeyPair: IdentityKeyPair? = null
existingPniIdentityKeyPair: IdentityKeyPair? = null,
profileKey: ProfileKey? = null
): KeyMaterial {
val accountEntropyPool = existingAccountEntropyPool ?: AccountEntropyPool.generate()
val aciIdentityKeyPair = existingAciIdentityKeyPair ?: IdentityKeyPair.generate()
@@ -679,7 +845,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
val aciLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair)
val pniLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair)
val profileKey = generateProfileKey()
val profileKey = profileKey ?: generateProfileKey()
return KeyMaterial(
aciIdentityKeyPair = aciIdentityKeyPair,
@@ -729,6 +895,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return Base64.encodeWithPadding(passwordBytes)
}
fun getAccountCapabilities(): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
storage = true, // True initially -- can turn off later if users opt-out
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true,
usernameChangeSyncMessage = true
)
}
private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray {
val nonce = ByteArray(12)
val input = ByteArray(16)
@@ -740,3 +916,13 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return ciphertext.copyOf(16)
}
}
/**
* Result of registering as a linked (secondary) device.
*
* @param hasLinkAndSyncBackup Whether the primary provided an ephemeral backup key, meaning the caller should
* wait for and apply a link-and-sync message backup before finishing.
*/
data class LinkedDeviceResult(
val hasLinkAndSyncBackup: Boolean
)
@@ -19,6 +19,7 @@ import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
import org.signal.registration.util.ACIParceler
import org.signal.registration.util.AccountEntropyPoolParceler
@@ -55,6 +56,12 @@ interface StorageController {
*/
suspend fun clearAllData()
/**
* Wipes **all** local app data and attempts to relaunch the app into a fresh state. Used when the primary
* asks a freshly-linked device to re-link.
*/
suspend fun clearLocalDataAndRestart()
/**
* Reads the persisted [RegistrationData] proto that is currently in the process of being worked on.
* Returns a default empty [RegistrationData] if nothing has been written yet.
@@ -117,6 +124,15 @@ interface StorageController {
*/
fun restoreRemoteBackup(aep: AccountEntropyPool): Flow<RemoteBackupRestoreProgress>
/**
* Downloads and imports the link-and-sync message backup from the given CDN location ([cdn]/[key]). The ephemeral
* backup key needed to decrypt the backup is read from the locally persisted registration metadata committed
* during registration.
*
* @return A [Flow] of [LinkAndSyncProgress] reporting progress through completion or error.
*/
fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow<LinkAndSyncProgress>
/**
* Scans the given folder URI for local backup files, checking for both modern
* folder-based backups and legacy .backup files.
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.LocalAnimateVisibilityScope
import org.signal.core.ui.compose.LocalSharedTransitionScope
@@ -105,6 +107,32 @@ fun LinkAccountScreen(
}
}
}
StateDialogs(state = state, onEvent = onEvent)
}
}
@Composable
private fun StateDialogs(
state: LinkAccountScreenState,
onEvent: (LinkAccountScreenEvent) -> Unit
) {
if (state.isRegistering) {
Dialogs.IndeterminateProgressDialog(
message = stringResource(R.string.LinkAccountScreen__linking_device)
)
} else if (state.isWaitingForPrimary) {
Dialogs.IndeterminateProgressDialog(
message = stringResource(R.string.LinkAccountScreen__waiting_for_your_other_device)
)
}
if (state.showError) {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.LinkAccountScreen__error_linking_device),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(LinkAccountScreenEvent.DismissError) }
)
}
}
@@ -299,7 +327,7 @@ private fun QrCodeContent(
modifier = Modifier.fillMaxSize()
) {
when (target) {
QrState.Failed -> QrCodeFailed()
QrState.Failed -> QrCodeFailed(onEvent)
is QrState.Loaded -> QrCodeDisplay(target.qrCodeData, state.displayQrOverlay, sharedTransitionScope, animatedVisibilityScope)
QrState.Loading -> QrCodeLoading()
QrState.Scanned -> QrCodeScanned()
@@ -383,7 +411,9 @@ private fun QrCodeScanned() {
}
@Composable
private fun QrCodeFailed() {
private fun QrCodeFailed(
onEvent: (LinkAccountScreenEvent) -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(8.dp)
@@ -399,6 +429,9 @@ private fun QrCodeFailed() {
style = MaterialTheme.typography.bodyMedium,
color = colorResource(org.signal.core.ui.R.color.signal_light_colorError)
)
Buttons.Small(onClick = { onEvent(LinkAccountScreenEvent.RetryQrCode) }) {
Text(text = stringResource(R.string.LinkAccountScreen__retry))
}
}
}
@@ -421,7 +454,10 @@ fun QrCodeOverlay(
onClick = { onEvent(LinkAccountScreenEvent.HideOverlayClick) },
modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_HIDE_OVERLAY_BUTTON)
) {
Icon(imageVector = SignalIcons.X.imageVector, contentDescription = null) // TODO 'close'
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = stringResource(R.string.LinkAccountScreen__close_qr_code)
)
}
}
}
@@ -574,6 +610,8 @@ private fun LinkAccountScreenPreview() {
LinkAccountScreenEvent.DisplayOverlayClick -> displayQrOverlay = true
LinkAccountScreenEvent.GetHelpClick -> Unit
LinkAccountScreenEvent.HideOverlayClick -> displayQrOverlay = false
LinkAccountScreenEvent.RetryQrCode -> Unit
LinkAccountScreenEvent.DismissError -> Unit
}
}
)
@@ -10,4 +10,6 @@ sealed class LinkAccountScreenEvent {
data object CreateAccountClick : LinkAccountScreenEvent()
data object DisplayOverlayClick : LinkAccountScreenEvent()
data object HideOverlayClick : LinkAccountScreenEvent()
data object RetryQrCode : LinkAccountScreenEvent()
data object DismissError : LinkAccountScreenEvent()
}
@@ -9,5 +9,8 @@ import org.signal.registration.screens.quickrestore.QrState
data class LinkAccountScreenState(
val qrCodeState: QrState = QrState.Loading,
val displayQrOverlay: Boolean = false
val displayQrOverlay: Boolean = false,
val isRegistering: Boolean = false,
val isWaitingForPrimary: Boolean = false,
val showError: Boolean = false
)
@@ -5,20 +5,35 @@
package org.signal.registration.screens.linkaccount
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.QrCodeData
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.quickrestore.QrState
import org.signal.registration.screens.util.navigateTo
/**
* Handles creating and maintaining a provisioning websocket in pursuit of adding this device as a
* linked (secondary) device, then registering it once the primary scans the QR code.
*/
class LinkAccountViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
@@ -27,20 +42,123 @@ class LinkAccountViewModel(
companion object {
private val TAG = Log.tag(LinkAccountViewModel::class)
private const val DEVICE_NAME = "Android"
}
private val _state = MutableStateFlow(LinkAccountScreenState())
val state: StateFlow<LinkAccountScreenState> = _state.asStateFlow()
val state: StateFlow<LinkAccountScreenState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LinkAccountScreenState())
// TODO [regv5] - load qr code data
private var provisioningJob: Job? = null
init {
startProvisioning()
}
override suspend fun processEvent(event: LinkAccountScreenEvent) {
when (event) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
fun applyEvent(state: LinkAccountScreenState, event: LinkAccountScreenEvent, stateEmitter: (LinkAccountScreenState) -> Unit) {
val result = when (event) {
LinkAccountScreenEvent.GetHelpClick -> error("This event is handled in the nav-entry.")
LinkAccountScreenEvent.CreateAccountClick -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry))
LinkAccountScreenEvent.DisplayOverlayClick -> _state.update { it.copy(displayQrOverlay = true) }
LinkAccountScreenEvent.HideOverlayClick -> _state.update { it.copy(displayQrOverlay = false) }
LinkAccountScreenEvent.CreateAccountClick -> {
parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry))
state
}
LinkAccountScreenEvent.DisplayOverlayClick -> state.copy(displayQrOverlay = true)
LinkAccountScreenEvent.HideOverlayClick -> state.copy(displayQrOverlay = false)
LinkAccountScreenEvent.RetryQrCode, LinkAccountScreenEvent.DismissError -> {
startProvisioning()
state.copy(qrCodeState = QrState.Loading, showError = false)
}
}
stateEmitter(result)
}
private fun startProvisioning() {
provisioningJob?.cancel()
provisioningJob = viewModelScope.launch {
repository.startLinkDeviceProvisioning().collect { event ->
when (event) {
is NetworkController.LinkDeviceProvisioningEvent.QrCodeReady -> {
Log.d(TAG, "[Provisioning] QR code ready")
_state.update {
it.copy(qrCodeState = QrState.Loaded(qrCodeData = QrCodeData.forData(data = event.url, supportIconOverlay = false)))
}
}
is NetworkController.LinkDeviceProvisioningEvent.MessageReceived -> {
Log.i(TAG, "[Provisioning] Message received from primary device")
handleProvisioningMessage(event.message)
}
is NetworkController.LinkDeviceProvisioningEvent.Error -> {
Log.w(TAG, "[Provisioning] Error", event.cause)
_state.update { it.copy(qrCodeState = QrState.Failed) }
}
}
}
}
}
private suspend fun handleProvisioningMessage(message: NetworkController.LinkDeviceProvisioningMessage) {
_state.update { it.copy(isRegistering = true, qrCodeState = QrState.Scanned) }
when (val result = repository.registerAsLinkedDevice(message, DEVICE_NAME)) {
is RequestResult.Success -> {
Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}")
if (result.result.hasLinkAndSyncBackup) {
// Wait here until the primary actually makes the backup available or tells us not to expect one
_state.update { it.copy(isRegistering = false, isWaitingForPrimary = true) }
val waitResult = repository.awaitLinkAndSyncArchive()
_state.update { it.copy(isWaitingForPrimary = false) }
when (waitResult) {
is LinkAndSyncWaitResult.ArchiveAvailable -> {
// Backup is ready: hand off to the MessageSync screen to download + apply it
parentEventEmitter.navigateTo(RegistrationRoute.MessageSync)
}
LinkAndSyncWaitResult.ContinueWithoutBackup -> {
// The primary declined to sync or never delivered a backup. The device is still linked, so restore from storage service and finish.
Log.w(TAG, "[Register] Link-and-sync offered but continuing without a backup.")
repository.restoreLinkedDeviceFromStorageService()
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
}
LinkAndSyncWaitResult.RelinkRequired -> {
// The primary asked us to re-link, which leaves our partial local registration invalid. Wipe everything and restart.
Log.w(TAG, "[Register] Primary requested re-link; wiping local data and restarting.")
repository.clearLocalDataAndRestart()
}
}
} else {
// No link-and-sync backup, restore from storage service immediately, then finish
repository.restoreLinkedDeviceFromStorageService()
_state.update { it.copy(isRegistering = false) }
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
}
}
is RequestResult.NonSuccess -> {
Log.w(TAG, "[Register] Failed: ${result.error::class.simpleName}")
showRegistrationError()
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[Register] Network error.", result.networkError)
showRegistrationError()
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[Register] Application error.", result.cause)
showRegistrationError()
}
}
}
private fun showRegistrationError() {
_state.update { it.copy(isRegistering = false, showError = true) }
}
override fun onCleared() {
provisioningJob?.cancel()
}
class Factory(
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.messagesync
import org.signal.core.util.ByteSize
/**
* Progress events emitted while restoring a link-and-sync message backup from the primary device.
* Each value maps to a UI state for the [MessageSyncScreen].
*/
sealed interface LinkAndSyncProgress {
/** Waiting for the primary device to make the backup available (no byte progress yet). */
data object Waiting : LinkAndSyncProgress
/** Downloading the backup from the primary/CDN. */
data class Downloading(val bytesDownloaded: ByteSize, val totalBytes: ByteSize) : LinkAndSyncProgress
/** Importing/restoring messages from the downloaded backup. */
data object Restoring : LinkAndSyncProgress
/** The link-and-sync restore completed (or there was nothing to restore). */
data object Complete : LinkAndSyncProgress
/** The link-and-sync restore failed. Linking still succeeded, so callers may choose to continue. */
data class Failed(val cause: Throwable? = null) : LinkAndSyncProgress
/**
* The primary asked this device to re-link, which invalidates the partial local registration. Callers must
* wipe local data and restart rather than finishing registration.
*/
data object RelinkRequired : LinkAndSyncProgress
}
@@ -91,6 +91,7 @@ private fun OnePane(params: RegistrationScaffold.Params.OnePane, state: MessageS
FooterContent(
params = params,
isElevated = scrollState.canScrollForward,
canCancel = !state.isFinishing,
onEvent = onEvent
)
}
@@ -126,6 +127,7 @@ private fun TwoPane(params: RegistrationScaffold.Params.TwoPane, state: MessageS
FooterContent(
params = params,
isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward,
canCancel = !state.isFinishing,
onEvent = onEvent
)
}
@@ -153,28 +155,33 @@ private fun FirstPaneContent(
modifier = Modifier.padding(top = 16.dp)
)
LinearProgressIndicator(
progress = {
if (state.totalBytes.bytes > 0) {
state.downloadedBytes.bytes.toFloat() / state.totalBytes.bytes.toFloat()
} else {
0f
}
},
drawStopIndicator = {},
gapSize = 0.dp,
modifier = Modifier
.padding(top = 48.dp, bottom = 16.dp)
.widthIn(max = 415.dp)
.fillMaxWidth()
)
val showDownloadProgress = state.totalBytes.bytes > 0 && !state.isFinishing
val progressModifier = Modifier
.padding(top = 48.dp, bottom = 16.dp)
.widthIn(max = 415.dp)
.fillMaxWidth()
if (showDownloadProgress) {
LinearProgressIndicator(
progress = { state.downloadedBytes.percentageOf(state.totalBytes) },
drawStopIndicator = {},
gapSize = 0.dp,
modifier = progressModifier
)
} else {
LinearProgressIndicator(modifier = progressModifier)
}
Text(
text = stringResource(
R.string.MessageSyncScreen__downloading_s_of_s,
state.downloadedBytes.toUnitString(),
state.totalBytes.toUnitString()
),
text = when {
state.isFinishing -> stringResource(R.string.MessageSyncScreen__finishing)
showDownloadProgress -> stringResource(
R.string.MessageSyncScreen__downloading_s_of_s,
state.downloadedBytes.toUnitString(),
state.totalBytes.toUnitString()
)
else -> stringResource(R.string.MessageSyncScreen__preparing)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -197,6 +204,7 @@ private fun SecondPaneContent(
private fun FooterContent(
params: RegistrationScaffold.Params,
isElevated: Boolean,
canCancel: Boolean,
onEvent: (MessageSyncScreenEvent) -> Unit,
modifier: Modifier = Modifier
) {
@@ -206,8 +214,8 @@ private fun FooterContent(
isElevated = isElevated
) {
when (breakpoint) {
is WindowBreakpoint.Small, is WindowBreakpoint.Medium -> StackedFooter(params, modifier, onEvent)
is WindowBreakpoint.Large -> InlineFooter(params, modifier, onEvent)
is WindowBreakpoint.Small, is WindowBreakpoint.Medium -> StackedFooter(params, canCancel, modifier, onEvent)
is WindowBreakpoint.Large -> InlineFooter(params, canCancel, modifier, onEvent)
}
}
}
@@ -215,6 +223,7 @@ private fun FooterContent(
@Composable
private fun StackedFooter(
params: RegistrationScaffold.Params,
canCancel: Boolean,
modifier: Modifier,
onEvent: (MessageSyncScreenEvent) -> Unit
) {
@@ -229,6 +238,7 @@ private fun StackedFooter(
modifier = Modifier.padding(bottom = 16.dp)
)
Cancel(
enabled = canCancel,
onEvent = onEvent,
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
@@ -240,6 +250,7 @@ private fun StackedFooter(
@Composable
private fun InlineFooter(
params: RegistrationScaffold.Params,
canCancel: Boolean,
modifier: Modifier,
onEvent: (MessageSyncScreenEvent) -> Unit
) {
@@ -257,6 +268,7 @@ private fun InlineFooter(
contentAlignment = Alignment.CenterEnd
) {
Cancel(
enabled = canCancel,
onEvent = onEvent,
modifier = Modifier
.widthIn(max = params.maxButtonWidth)
@@ -316,9 +328,10 @@ private fun Notice(
}
@Composable
private fun Cancel(onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier) {
private fun Cancel(onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Buttons.LargeTonal(
onClick = { onEvent(MessageSyncScreenEvent.CancelClick) },
enabled = enabled,
modifier = modifier.testTag(TestTags.MESSAGE_SYNC_CANCEL_BUTTON)
) {
Text(text = stringResource(R.string.MessageSyncScreen__cancel))
@@ -10,5 +10,6 @@ import org.signal.core.util.bytes
data class MessageSyncScreenState(
val downloadedBytes: ByteSize = 0.bytes,
val totalBytes: ByteSize = 0.bytes
val totalBytes: ByteSize = 0.bytes,
val isFinishing: Boolean = false
)
@@ -5,17 +5,31 @@
package org.signal.registration.screens.messagesync
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateTo
/**
* Drives the link-and-sync message backup restore that runs after this device is registered as a
* linked device, surfacing download progress and completing registration when finished.
*/
class MessageSyncViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
@@ -27,15 +41,79 @@ class MessageSyncViewModel(
}
private val _state = MutableStateFlow(MessageSyncScreenState())
val state: StateFlow<MessageSyncScreenState> = _state.asStateFlow()
val state: StateFlow<MessageSyncScreenState> = _state
.onEach { Log.d(TAG, "[State] $it") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MessageSyncScreenState())
// TODO [regv5] wire in message restoration state
private var restoreJob: Job? = null
private var finishJob: Job? = null
init {
startRestore()
}
private fun startRestore() {
restoreJob?.cancel()
restoreJob = viewModelScope.launch {
repository.restoreLinkAndSyncBackup().collect { progress ->
when (progress) {
is LinkAndSyncProgress.Waiting -> Unit
is LinkAndSyncProgress.Downloading -> _state.update {
it.copy(downloadedBytes = progress.bytesDownloaded, totalBytes = progress.totalBytes)
}
is LinkAndSyncProgress.Restoring -> _state.update { it.copy(isFinishing = true) }
is LinkAndSyncProgress.Complete -> {
Log.i(TAG, "[MessageSync] Link-and-sync complete; restoring from storage service then completing.")
finish()
}
is LinkAndSyncProgress.Failed -> {
Log.w(TAG, "[MessageSync] Link-and-sync failed; restoring from storage service then completing (still linked).", progress.cause)
finish()
}
is LinkAndSyncProgress.RelinkRequired -> {
Log.w(TAG, "[MessageSync] Primary requested re-link; wiping local data and restarting.")
repository.clearLocalDataAndRestart()
}
}
}
}
}
private fun finish(cancelDownload: Boolean = false) {
if (finishJob != null) {
return
}
finishJob = viewModelScope.launch {
_state.update { it.copy(isFinishing = true) }
if (cancelDownload) {
restoreJob?.cancelAndJoin()
}
repository.restoreLinkedDeviceFromStorageService()
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
}
}
override suspend fun processEvent(event: MessageSyncScreenEvent) {
when (event) {
applyEvent(_state.value, event) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(state: MessageSyncScreenState, event: MessageSyncScreenEvent, stateEmitter: (MessageSyncScreenState) -> Unit) {
val result = when (event) {
MessageSyncScreenEvent.LearnMoreClick -> error("This event is handled in the nav-entry.")
MessageSyncScreenEvent.CancelClick -> Unit // TODO [regv5] wire in to actually cancel the download.
MessageSyncScreenEvent.CancelClick -> {
Log.i(TAG, "[MessageSync] User cancelled message sync; awaiting restore cancellation, then restoring from storage service and completing.")
stateEmitter(state.copy(isFinishing = true))
finish(cancelDownload = true)
state.copy(isFinishing = true)
}
}
stateEmitter(result)
}
override fun onCleared() {
restoreJob?.cancel()
}
class Factory(
@@ -52,6 +52,11 @@ message RegistrationData {
// JSON-serialized flow state snapshot (from saveFlowState/restoreFlowState)
string flowStateJson = 21;
// Registration data provided by the primary+server to register as a linked device
LinkedDeviceData linkedDeviceData = 25;
// Next: 26
}
message SvrCredential {
@@ -79,3 +84,11 @@ message ProvisioningData {
int64 backupSizeBytes = 5;
int64 backupVersion = 6;
}
message LinkedDeviceData {
uint32 deviceId = 1;
string deviceName = 2;
optional bytes ephemeralBackupKey = 3;
optional bytes mediaRootBackupKey = 4;
optional bool readReceipts = 5;
}
@@ -393,6 +393,10 @@
<string name="MessageSyncScreen__this_may_take_a_few_minutes">This may take a few minutes…</string>
<!-- Progress label shown under the sync progress bar. %1$s is the amount downloaded so far (e.g. "1 MB"), %2$s is the total size to download (e.g. "3.2 MB") -->
<string name="MessageSyncScreen__downloading_s_of_s">Downloading %1$s of %2$s</string>
<!-- Progress label shown under the sync progress bar before the download size is known -->
<string name="MessageSyncScreen__preparing">Preparing…</string>
<!-- Progress label shown under the sync progress bar while messages are being imported and the rest of the account is restored, after the download finishes -->
<string name="MessageSyncScreen__finishing">Finishing…</string>
<!-- Informational notice in the sync screen footer, indicating the sync is end-to-end encrypted. Precedes a separate "Learn more" link. -->
<string name="MessageSyncScreen__messages_and_chat_info_are_protected_by_e2ee">Messages and chat info are protected by end-to-end encryption, including the syncing process.</string>
<!-- Inline link in the sync screen footer that opens documentation about end-to-end encryption during sync -->
@@ -422,6 +426,16 @@
<string name="LinkAccountScreen__dont_have_signal_on_another_device">Don\'t have Signal on another device?</string>
<!-- Inline link in the link-account screen footer that takes the user to the new-account creation flow instead of linking -->
<string name="LinkAccountScreen__create_account">Create account</string>
<!-- Button to retry generating the QR code after it failed to load -->
<string name="LinkAccountScreen__retry">Retry</string>
<!-- Progress dialog message shown while this device is being registered as a linked device -->
<string name="LinkAccountScreen__linking_device">Linking device…</string>
<!-- Progress dialog message shown while waiting for the primary device to prepare the message backup -->
<string name="LinkAccountScreen__waiting_for_your_other_device">Waiting for your other device…</string>
<!-- Error dialog message shown when linking this device fails -->
<string name="LinkAccountScreen__error_linking_device">An error occurred while linking this device</string>
<!-- Content description for the button that closes the maximized QR code overlay -->
<string name="LinkAccountScreen__close_qr_code">Close QR code</string>
<!-- Device transfer: Instructions screen -->
<string name="DeviceTransferInstructions__transfer_your_account">Transfer your account</string>
@@ -0,0 +1,229 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.linkaccount
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.libsignal.net.RequestResult
import org.signal.registration.LinkAndSyncWaitResult
import org.signal.registration.LinkedDeviceResult
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.quickrestore.QrState
@OptIn(ExperimentalCoroutinesApi::class)
class LinkAccountViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var emittedParentEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<LinkAccountScreenState>
private lateinit var stateEmitter: (LinkAccountScreenState) -> Unit
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
every { mockRepository.startLinkDeviceProvisioning() } returns emptyFlow()
emittedParentEvents = mutableListOf()
parentEventEmitter = { event -> emittedParentEvents.add(event) }
emittedStates = mutableListOf()
stateEmitter = { state -> emittedStates.add(state) }
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun TestScope.createViewModel(): LinkAccountViewModel {
val viewModel = LinkAccountViewModel(
repository = mockRepository,
parentState = MutableStateFlow(RegistrationFlowState()),
parentEventEmitter = parentEventEmitter
)
// Keep the WhileSubscribed state flow hot so state.value reflects updates during the test.
backgroundScope.launch { viewModel.state.collect {} }
return viewModel
}
@Test
fun `initial state has qrState Loading`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.qrCodeState).isEqualTo(QrState.Loading)
assertThat(viewModel.state.value.isRegistering).isFalse()
assertThat(viewModel.state.value.showError).isFalse()
}
@Test
fun `QrCodeReady provisioning event updates state to Loaded`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.QrCodeReady("sgnl://linkdevice"))
assertThat(viewModel.state.value.qrCodeState).isInstanceOf<QrState.Loaded>()
}
@Test
fun `Error provisioning event updates state to Failed`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.Error(RuntimeException("boom")))
assertThat(viewModel.state.value.qrCodeState).isEqualTo(QrState.Failed)
}
@Test
fun `successful registration without link-and-sync navigates to FullyComplete`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = false))
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `successful registration with link-and-sync navigates to MessageSync`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true))
coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.ArchiveAvailable(cdn = 3, key = "archive-key")
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.MessageSync))
}
@Test
fun `link-and-sync offered but archive never arrives navigates to FullyComplete`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true))
coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.ContinueWithoutBackup
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `link-and-sync relink requested wipes local data and restarts`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true))
coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.RelinkRequired
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
coVerify { mockRepository.clearLocalDataAndRestart() }
}
@Test
fun `failed registration shows error`() = runTest(testDispatcher) {
val flow = givenProvisioningFlow()
val message = mockk<NetworkController.LinkDeviceProvisioningMessage>(relaxed = true)
coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.NonSuccess(NetworkController.RegisterAsLinkedDeviceError.MaxLinkedDevices)
val viewModel = createViewModel()
flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message))
assertThat(viewModel.state.value.showError).isTrue()
assertThat(viewModel.state.value.isRegistering).isFalse()
}
@Test
fun `applyEvent DisplayOverlayClick shows the QR overlay`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.DisplayOverlayClick, stateEmitter)
assertThat(emittedStates.last().displayQrOverlay).isTrue()
}
@Test
fun `applyEvent HideOverlayClick hides the QR overlay`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.applyEvent(LinkAccountScreenState(displayQrOverlay = true), LinkAccountScreenEvent.HideOverlayClick, stateEmitter)
assertThat(emittedStates.last().displayQrOverlay).isFalse()
}
@Test
fun `applyEvent RetryQrCode resets to Loading and clears error`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val errored = LinkAccountScreenState(qrCodeState = QrState.Failed, showError = true)
viewModel.applyEvent(errored, LinkAccountScreenEvent.RetryQrCode, stateEmitter)
assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading)
assertThat(emittedStates.last().showError).isFalse()
}
@Test
fun `applyEvent DismissError resets to Loading and clears error`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val errored = LinkAccountScreenState(qrCodeState = QrState.Failed, showError = true)
viewModel.applyEvent(errored, LinkAccountScreenEvent.DismissError, stateEmitter)
assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading)
assertThat(emittedStates.last().showError).isFalse()
}
@Test
fun `applyEvent CreateAccountClick navigates to Permissions`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter)
assertThat(emittedParentEvents).contains(
RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry))
)
}
private fun givenProvisioningFlow(): MutableSharedFlow<NetworkController.LinkDeviceProvisioningEvent> {
val flow = MutableSharedFlow<NetworkController.LinkDeviceProvisioningEvent>(replay = 1)
every { mockRepository.startLinkDeviceProvisioning() } returns flow
return flow
}
}
@@ -0,0 +1,133 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.messagesync
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.doesNotContain
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.core.util.bytes
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
@OptIn(ExperimentalCoroutinesApi::class)
class MessageSyncViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var emittedParentEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
every { mockRepository.restoreLinkAndSyncBackup() } returns MutableSharedFlow()
emittedParentEvents = mutableListOf()
parentEventEmitter = { event -> emittedParentEvents.add(event) }
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `restore Complete restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Complete)
createViewModel()
coVerify { mockRepository.restoreLinkedDeviceFromStorageService() }
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `restore Failed still restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
createViewModel()
coVerify { mockRepository.restoreLinkedDeviceFromStorageService() }
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `restore RelinkRequired wipes local data and does not finish registration`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.RelinkRequired)
createViewModel()
coVerify { mockRepository.clearLocalDataAndRestart() }
coVerify(exactly = 0) { mockRepository.restoreLinkedDeviceFromStorageService() }
assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `applyEvent CancelClick restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) {
// A never-emitting flow keeps the restore job active so cancelAndJoin has something to wait on.
every { mockRepository.restoreLinkAndSyncBackup() } returns MutableSharedFlow()
val viewModel = createViewModel()
viewModel.applyEvent(MessageSyncScreenState(), MessageSyncScreenEvent.CancelClick) {}
coVerify { mockRepository.restoreLinkedDeviceFromStorageService() }
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `restore Downloading shows byte progress and is not finishing`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Downloading(bytesDownloaded = 5.bytes, totalBytes = 10.bytes))
val viewModel = createViewModel()
assertThat(viewModel.state.value.isFinishing).isFalse()
}
@Test
fun `restore Restoring switches to indeterminate finishing state`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(
LinkAndSyncProgress.Downloading(bytesDownloaded = 5.bytes, totalBytes = 10.bytes),
LinkAndSyncProgress.Restoring
)
val viewModel = createViewModel()
assertThat(viewModel.state.value.isFinishing).isTrue()
}
private fun TestScope.createViewModel(): MessageSyncViewModel {
val viewModel = MessageSyncViewModel(
repository = mockRepository,
parentState = MutableStateFlow(RegistrationFlowState()),
parentEventEmitter = parentEventEmitter
)
// Keep the WhileSubscribed state flow hot so state.value reflects updates during the test.
backgroundScope.launch { viewModel.state.collect {} }
return viewModel
}
}
@@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.account
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Attributes sent when registering as a linked (secondary) device via `PUT /v1/devices/link`.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
class DeviceAttributes @JsonCreator constructor(
@JsonProperty val fetchesMessages: Boolean,
@JsonProperty val registrationId: Int,
@JsonProperty val pniRegistrationId: Int,
@JsonProperty val name: String?,
@JsonProperty val capabilities: AccountAttributes.Capabilities?
)
@@ -9,9 +9,23 @@ import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Data from primary on where to find link+sync backup file.
* Response from the `transfer_archive` long-poll. The primary either provides where to find the link+sync
* backup file ([cdn] + [key]), or reports an [error] indicating it will not provide one.
*/
data class TransferArchiveResponse @JsonCreator constructor(
@JsonProperty val cdn: Int,
@JsonProperty val key: String
)
@JsonProperty("cdn") val cdn: Int? = null,
@JsonProperty("key") val key: String? = null,
@JsonProperty("error") val error: String? = null
) {
companion object {
/** The primary is requesting that this device re-link; no backup will be provided. */
const val ERROR_RELINK_REQUESTED = "RELINK_REQUESTED"
/** The primary has decided to abort the sync; continue linking without a backup. */
const val ERROR_CONTINUE_WITHOUT_UPLOAD = "CONTINUE_WITHOUT_UPLOAD"
}
/** True if the primary provided a backup location (rather than an error). */
val hasArchive: Boolean
get() = cdn != null && key != null
}
@@ -12,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
*/
data class WaitForLinkedDeviceResponse(
@JsonProperty val id: Int,
@JsonProperty val name: String,
@JsonProperty val name: String?,
@JsonProperty val lastSeen: Long,
@JsonProperty val registrationId: Int,
@JsonProperty val createdAtCiphertext: String?
@@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.registration
import org.signal.network.NetworkResult
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.messages.multidevice.RegisterAsSecondaryDeviceResponse
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
@@ -150,7 +151,7 @@ class RegistrationApi(
* - 422: Request is invalid
* - 429: Rate limited
*/
fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?): NetworkResult<RegisterAsSecondaryDeviceResponse> {
fun registerAsSecondaryDevice(verificationCode: String, attributes: DeviceAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?): NetworkResult<RegisterAsSecondaryDeviceResponse> {
val request = RegisterAsSecondaryDeviceRequest(
verificationCode = verificationCode,
accountAttributes = attributes,
@@ -398,10 +398,6 @@ public class PushServiceSocket {
return makeServiceRequestWithoutValidation(path, "PUT", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**
* V2 API: Submits registration request and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
/**
* V2 API: Checks SVR2 auth credentials and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
@@ -486,6 +482,18 @@ public class PushServiceSocket {
return JsonUtil.fromJson(responseText, RegisterAsSecondaryDeviceResponse.class);
}
/**
* V2 API: Registers as a secondary device, authenticating via basic auth built from the given {@code e164} and {@code password}
* rather than the socket's credentials provider
* Caller is responsible for closing the response.
*/
public Response registerAsSecondaryDevice(String e164, String password, RegisterAsSecondaryDeviceRequest request) throws IOException {
String authHeader = "Basic " + Base64.encodeWithPadding((e164 + ":" + password).getBytes("UTF-8"));
Map<String, String> headers = Collections.singletonMap("Authorization", authHeader);
return makeServiceRequestWithoutValidation("/v1/devices/link", "PUT", jsonRequestBody(JsonUtil.toJson(request)), headers, SealedSenderAccess.NONE, false);
}
public SendMessageResponse sendMessage(OutgoingPushMessageList bundle, @Nullable SealedSenderAccess sealedSenderAccess, boolean story)
throws IOException
{
@@ -7,12 +7,12 @@ package org.whispersystems.signalservice.internal.push
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.DeviceAttributes
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
class RegisterAsSecondaryDeviceRequest @JsonCreator constructor(
@JsonProperty val verificationCode: String,
@JsonProperty val accountAttributes: AccountAttributes,
@JsonProperty val accountAttributes: DeviceAttributes,
@JsonProperty val aciSignedPreKey: SignedPreKeyEntity,
@JsonProperty val pniSignedPreKey: SignedPreKeyEntity,
@JsonProperty val aciPqLastResortPreKey: KyberPreKeyEntity,