mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-21 13:05:00 +01:00
Add device linking support to RegV5.
This commit is contained in:
committed by
Michelle Tang
parent
55b5997a4e
commit
91965d205a
@@ -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
|
||||
|
||||
+47
-23
@@ -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 -> {
|
||||
|
||||
+283
-3
@@ -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,
|
||||
|
||||
+86
-7
@@ -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))
|
||||
|
||||
+5
-19
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ class RegistrationApplication : Application() {
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
},
|
||||
isLinkAndSyncAvailable = false
|
||||
isLinkAndSyncAvailable = true
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+31
-1
@@ -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()
|
||||
|
||||
+248
-9
@@ -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,
|
||||
|
||||
+115
@@ -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))
|
||||
|
||||
+39
@@ -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(
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
+10
@@ -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
|
||||
)
|
||||
|
||||
|
||||
-1
@@ -185,7 +185,6 @@ class PinSettingsViewModel(
|
||||
spqr = true,
|
||||
usernameChangeSyncMessage = true
|
||||
),
|
||||
name = null,
|
||||
pniRegistrationId = RegistrationPreferences.pniRegistrationId,
|
||||
recoveryPassword = recoveryPassword
|
||||
)
|
||||
|
||||
+21
-1
@@ -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
|
||||
}
|
||||
|
||||
+197
-11
@@ -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.
|
||||
|
||||
+41
-3
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
)
|
||||
|
||||
+125
-7
@@ -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(
|
||||
|
||||
+35
@@ -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
|
||||
}
|
||||
+36
-23
@@ -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))
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+83
-5
@@ -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>
|
||||
|
||||
+229
@@ -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
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
}
|
||||
}
|
||||
+21
@@ -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?
|
||||
)
|
||||
+18
-4
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+12
-4
@@ -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
|
||||
{
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user