mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-19 12:49:54 +01:00
Migrate a batch of requests to libsignal-net.
This commit is contained in:
committed by
Alex Hart
parent
91ffba810f
commit
1911bf7df7
@@ -31,11 +31,11 @@ import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.models.backup.BackupId
|
||||
import org.signal.core.models.backup.MediaId
|
||||
import org.signal.core.models.backup.MediaName
|
||||
import org.signal.core.models.backup.MediaRootBackupKey
|
||||
import org.signal.core.models.backup.MessageBackupKey
|
||||
import org.signal.core.models.database.AttachmentId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decodeBase64OrThrow
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.DiskUtil
|
||||
@@ -66,6 +66,9 @@ import org.signal.core.util.stream.NonClosingOutputStream
|
||||
import org.signal.core.util.urlEncode
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import org.signal.libsignal.net.CopyBackupMediaItem
|
||||
import org.signal.libsignal.net.CopyBackupMediaOutcome
|
||||
import org.signal.libsignal.net.DeleteBackupMediaItem
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
@@ -152,12 +155,9 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccessPair
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
@@ -1626,7 +1626,7 @@ object BackupRepository {
|
||||
fun debugGetRemoteBackupState(): NetworkResult<DebugBackupMetadata> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess)
|
||||
SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess)
|
||||
.map { it to credential }
|
||||
}
|
||||
.then { pair ->
|
||||
@@ -1634,7 +1634,7 @@ object BackupRepository {
|
||||
SignalNetwork.archive.debugGetUploadedMediaItemMetadata(SignalStore.account.requireAci(), credential.mediaBackupAccess)
|
||||
.map { mediaObjects ->
|
||||
DebugBackupMetadata(
|
||||
usedSpace = mediaBackupInfo.usedSpace ?: 0,
|
||||
usedSpace = mediaBackupInfo.usedSpace,
|
||||
mediaCount = mediaObjects.size.toLong(),
|
||||
mediaSize = mediaObjects.sumOf { it.objectLength }
|
||||
)
|
||||
@@ -1652,26 +1652,26 @@ object BackupRepository {
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess)
|
||||
SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } }
|
||||
.map { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
val messageReceiver = AppDependencies.signalServiceMessageReceiver
|
||||
messageReceiver.retrieveBackup(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener)
|
||||
messageReceiver.retrieveBackup(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBackupFileLastModified(): NetworkResult<ZonedDateTime> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess)
|
||||
SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: RemoteConfig.backupFallbackArchiveCdn).map { it.headers to info } }
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
NetworkResult.fromFetch {
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1737,17 +1737,14 @@ object BackupRepository {
|
||||
|
||||
/**
|
||||
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
|
||||
*
|
||||
* @return The archive cdn number the thumbnail landed on.
|
||||
*/
|
||||
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<Int> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
item = request
|
||||
)
|
||||
val item = buildCopyBackupMediaItem(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
copySingleMediaToArchive(credential.mediaBackupAccess, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1757,43 +1754,32 @@ object BackupRepository {
|
||||
fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val mediaName = attachment.requireMediaName()
|
||||
val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey)
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
item = request
|
||||
)
|
||||
val item = buildCopyBackupMediaItem(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, attachment.requireMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
copySingleMediaToArchive(credential.mediaBackupAccess, item)
|
||||
}
|
||||
.map { response ->
|
||||
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = response.cdn)
|
||||
.map { archiveCdn ->
|
||||
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = archiveCdn)
|
||||
}
|
||||
.also { Log.i(TAG, "archiveMediaResult: ${it::class.simpleName}") }
|
||||
}
|
||||
|
||||
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
|
||||
val mediaToDelete = mediaObjects
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.cdn,
|
||||
mediaId = it.mediaId
|
||||
)
|
||||
}
|
||||
.filter { it.cdn == Cdn.CDN_3.cdnNumber }
|
||||
return NetworkResult
|
||||
.fromLocal { mediaObjects.filter { it.cdn == Cdn.CDN_3.cdnNumber }.map { it.toDeleteBackupMediaItem() } }
|
||||
.then { mediaToDelete ->
|
||||
if (mediaToDelete.isEmpty()) {
|
||||
Log.i(TAG, "No media to delete, quick success")
|
||||
return@then NetworkResult.Success(Unit)
|
||||
}
|
||||
|
||||
if (mediaToDelete.isEmpty()) {
|
||||
Log.i(TAG, "No media to delete, quick success")
|
||||
return NetworkResult.Success(Unit)
|
||||
}
|
||||
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.deleteArchivedMedia(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
mediaToDelete = mediaToDelete
|
||||
)
|
||||
initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.deleteArchivedMedia(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
mediaToDelete = mediaToDelete
|
||||
).map { }
|
||||
}
|
||||
}
|
||||
.also { Log.i(TAG, "deleteAbandonedMediaObjectsResult: ${it::class.simpleName}") }
|
||||
}
|
||||
@@ -1817,13 +1803,8 @@ object BackupRepository {
|
||||
return debugGetArchivedMediaState()
|
||||
.then { archivedMedia ->
|
||||
val mediaChunksToDelete = archivedMedia
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.cdn,
|
||||
mediaId = it.mediaId
|
||||
)
|
||||
}
|
||||
.filter { it.cdn == Cdn.CDN_3.cdnNumber }
|
||||
.map { ArchivedMediaObject(mediaId = it.mediaId, cdn = it.cdn).toDeleteBackupMediaItem() }
|
||||
.chunked(itemLimit)
|
||||
|
||||
if (mediaChunksToDelete.isEmpty()) {
|
||||
@@ -1838,7 +1819,7 @@ object BackupRepository {
|
||||
aci = SignalStore.account.requireAci(),
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
mediaToDelete = chunk
|
||||
)
|
||||
).map { }
|
||||
|
||||
if (result !is NetworkResult.Success) {
|
||||
Log.w(TAG, "Error occurred while deleting archived media chunk #$index: $result")
|
||||
@@ -1975,12 +1956,12 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
.then { messageAccess ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), messageAccess)
|
||||
.then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn ?: RemoteConfig.backupFallbackArchiveCdn, aci, messageAccess).map { it.headers to info } }
|
||||
SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), messageAccess)
|
||||
.then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn, aci, messageAccess).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
NetworkResult.fromFetch {
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2026,8 +2007,8 @@ object BackupRepository {
|
||||
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess).map {
|
||||
"${it.backupDir!!.urlEncode()}/${it.mediaDir!!.urlEncode()}"
|
||||
SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess).map {
|
||||
"${it.backupDir.urlEncode()}/${it.mediaDir.urlEncode()}"
|
||||
}
|
||||
}
|
||||
.also {
|
||||
@@ -2209,21 +2190,38 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
private fun buildCopyBackupMediaItem(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): CopyBackupMediaItem {
|
||||
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdnNumber,
|
||||
key = remoteLocation
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(),
|
||||
mediaId = mediaSecrets.id.encode(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey)
|
||||
return CopyBackupMediaItem(
|
||||
sourceAttachmentCdn = cdnNumber,
|
||||
sourceKey = remoteLocation,
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)),
|
||||
mediaId = mediaSecrets.id.value,
|
||||
encryptionKey = mediaSecrets.macKey + mediaSecrets.aesKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a single item to the archive cdn, returning the cdn it landed on.
|
||||
*
|
||||
* libsignal reports per-item outcomes rather than status codes, so we translate them back into the status codes callers already branch on: a missing source
|
||||
* is a 410, a length mismatch is a 400, and no remaining space is a 413.
|
||||
*/
|
||||
private fun copySingleMediaToArchive(archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, item: CopyBackupMediaItem): NetworkResult<Int> {
|
||||
return SignalNetwork.archive
|
||||
.copyMediaToArchive(SignalStore.account.requireAci(), archiveServiceAccess, listOf(item))
|
||||
.then { outcomes ->
|
||||
when (val outcome = outcomes.firstOrNull()) {
|
||||
is CopyBackupMediaOutcome.Success -> NetworkResult.Success(outcome.cdn)
|
||||
is CopyBackupMediaOutcome.SourceNotFound -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(410, "Source attachment not found"))
|
||||
is CopyBackupMediaOutcome.WrongSourceLength -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(400, "Wrong source length"))
|
||||
is CopyBackupMediaOutcome.OutOfSpace -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "No media space remaining"))
|
||||
null -> NetworkResult.ApplicationError(IllegalStateException("Copy stream ended without an outcome for the item!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreRemoteBackup(): RemoteRestoreResult {
|
||||
val context = AppDependencies.application
|
||||
ArchiveRestoreProgress.onRestorePending()
|
||||
@@ -2471,8 +2469,8 @@ object BackupRepository {
|
||||
|
||||
fun getRemoteBackupForwardSecrecyMetadata(): NetworkResult<ByteArray?> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential -> SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) }
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.then { credential -> SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) }
|
||||
.then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } }
|
||||
.then { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
val headers = cdnCredentials.toMutableMap().apply {
|
||||
@@ -2480,7 +2478,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
AppDependencies.signalServiceMessageReceiver.retrieveBackupForwardSecretMetadataBytes(
|
||||
info.cdn!!,
|
||||
info.cdn,
|
||||
headers,
|
||||
"backups/${info.backupDir}/${info.backupName}",
|
||||
EncryptedBackupReader.BACKUP_SECRET_METADATA_UPPERBOUND
|
||||
@@ -2511,7 +2509,11 @@ data class ResumableMessagesBackupUploadSpec(
|
||||
val resumableUri: String
|
||||
)
|
||||
|
||||
data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
|
||||
data class ArchivedMediaObject(val mediaId: String, val cdn: Int) {
|
||||
fun toDeleteBackupMediaItem(): DeleteBackupMediaItem {
|
||||
return DeleteBackupMediaItem(mediaId = MediaId(mediaId).value, cdn = cdn)
|
||||
}
|
||||
}
|
||||
|
||||
class ExportState(
|
||||
val backupTime: Long,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.models.database.AttachmentId
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
|
||||
|
||||
/**
|
||||
* Result of attempting to batch copy multiple attachments at once with helpers for
|
||||
* processing the collection of mini-responses.
|
||||
*/
|
||||
data class BatchArchiveMediaResult(
|
||||
private val response: BatchArchiveMediaResponse,
|
||||
private val mediaIdToAttachmentId: Map<String, AttachmentId>,
|
||||
private val attachmentIdToMediaName: Map<AttachmentId, String>
|
||||
) {
|
||||
val successfulResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
|
||||
get() = response
|
||||
.responses
|
||||
.asSequence()
|
||||
.filter { it.status == 200 }
|
||||
|
||||
val sourceNotFoundResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
|
||||
get() = response
|
||||
.responses
|
||||
.asSequence()
|
||||
.filter { it.status == 410 }
|
||||
|
||||
fun mediaIdToAttachmentId(mediaId: String): AttachmentId {
|
||||
return mediaIdToAttachmentId[mediaId]!!
|
||||
}
|
||||
|
||||
fun attachmentIdToMediaName(attachmentId: AttachmentId): String {
|
||||
return attachmentIdToMediaName[attachmentId]!!
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ public class RefreshAttributesJob extends BaseJob {
|
||||
boolean phoneNumberDiscoverable = SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.DISCOVERABLE;
|
||||
Log.i(TAG, "Linked device, refreshing device capabilities and phone number discoverability. Capabilities: " + capabilities + ", discoverable: " + phoneNumberDiscoverable);
|
||||
RequestResultUtil.successOrThrow(SignalNetwork.account().setCapabilities(capabilities));
|
||||
RequestResultUtil.successOrThrow(SignalNetwork.account().setPhoneNumberDiscoverability(phoneNumberDiscoverable));
|
||||
RequestResultUtil.successOrThrowNoError(SignalNetwork.account().setPhoneNumberDiscoverability(phoneNumberDiscoverable));
|
||||
}
|
||||
|
||||
hasRefreshedThisAppCycle = true;
|
||||
|
||||
@@ -490,21 +490,21 @@ object LinkDeviceRepository {
|
||||
/**
|
||||
* Changes the name of a linked device and sends a sync message if successful
|
||||
*/
|
||||
fun changeDeviceName(deviceName: String, deviceId: Int): DeviceNameChangeResult {
|
||||
val encryptedDeviceName = Base64.encodeWithoutPadding(DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), SignalStore.account.aciIdentityKey))
|
||||
suspend fun changeDeviceName(deviceName: String, deviceId: Int): DeviceNameChangeResult {
|
||||
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), SignalStore.account.aciIdentityKey)
|
||||
return when (val result = SignalNetwork.linkDevice.setDeviceName(encryptedDeviceName, deviceId)) {
|
||||
is NetworkResult.Success -> {
|
||||
is RequestResult.Success -> {
|
||||
AppDependencies.jobManager.add(DeviceNameChangeJob(deviceId))
|
||||
DeviceNameChangeResult.Success.logI(TAG, "Successfully changed device name")
|
||||
}
|
||||
is NetworkResult.NetworkError -> {
|
||||
DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to network error.", result.exception)
|
||||
is RequestResult.NonSuccess -> {
|
||||
DeviceNameChangeResult.NetworkError(result.error).logW(TAG, "Could not change name because the device could not be found.")
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to status code error ${result.code}")
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
DeviceNameChangeResult.NetworkError(result.networkError).logW(TAG, "Could not change name due to network error.", result.networkError)
|
||||
}
|
||||
is NetworkResult.ApplicationError -> {
|
||||
throw result.throwable.logW(TAG, "Could not change name due to application error.")
|
||||
is RequestResult.ApplicationError -> {
|
||||
throw result.cause.logW(TAG, "Could not change name due to application error.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
import org.whispersystems.signalservice.api.successOrThrow
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
|
||||
@@ -393,7 +393,7 @@ object SvrRepository {
|
||||
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
|
||||
|
||||
Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true)
|
||||
NetworkResultUtil.toBasicLegacy(SignalNetwork.account.enableRegistrationLock(SignalStore.svr.masterKey.deriveRegistrationLock()))
|
||||
SignalNetwork.account.enableRegistrationLock(SignalStore.svr.masterKey).successOrThrow()
|
||||
SignalStore.svr.isRegistrationLockEnabled = true
|
||||
Log.i(TAG, "[enableRegistrationLockForUserWithPin] Registration lock successfully enabled.", true)
|
||||
}
|
||||
@@ -407,7 +407,7 @@ object SvrRepository {
|
||||
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
|
||||
|
||||
Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true)
|
||||
NetworkResultUtil.toBasicLegacy(SignalNetwork.account.disableRegistrationLock())
|
||||
SignalNetwork.account.disableRegistrationLock().successOrThrow()
|
||||
SignalStore.svr.isRegistrationLockEnabled = false
|
||||
Log.i(TAG, "[disableRegistrationLockForUserWithPin] Registration lock successfully disabled.", true)
|
||||
}
|
||||
|
||||
+19
-29
@@ -12,6 +12,7 @@ import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.RetryLaterException
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.usernames.UsernameLinkInvalidEntropyDataLength
|
||||
@@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.getCause
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import java.util.UUID
|
||||
@@ -208,7 +210,7 @@ object UsernameRepository {
|
||||
|
||||
val usernameLink = username.generateLink()
|
||||
when (val result = SignalNetwork.account.createUsernameLink(usernameLink)) {
|
||||
is NetworkResult.Success -> {
|
||||
is RequestResult.Success -> {
|
||||
SignalStore.account.usernameLink = result.result
|
||||
|
||||
if (SignalStore.account.usernameSyncState == AccountValues.UsernameSyncState.LINK_CORRUPTED) {
|
||||
@@ -389,12 +391,11 @@ object UsernameRepository {
|
||||
return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
|
||||
}
|
||||
|
||||
val hashes: List<String> = candidates
|
||||
.map { Base64.encodeUrlSafeWithoutPadding(it.hash) }
|
||||
val hashes: List<ByteArray> = candidates.map { it.hash }
|
||||
|
||||
return when (val result = SignalNetwork.account.reserveUsername(hashes)) {
|
||||
is NetworkResult.Success -> {
|
||||
val hashIndex = hashes.indexOf(result.result.usernameHash)
|
||||
is RequestResult.Success -> {
|
||||
val hashIndex = hashes.indexOfFirst { it.contentEquals(result.result) }
|
||||
if (hashIndex == -1) {
|
||||
Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.")
|
||||
return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
|
||||
@@ -403,31 +404,20 @@ object UsernameRepository {
|
||||
Log.i(TAG, "[reserveUsername] Successfully reserved username.")
|
||||
success(UsernameState.Reserved(candidates[hashIndex]))
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
409 -> {
|
||||
Log.w(TAG, "[reserveUsername] Username taken.")
|
||||
failure(UsernameSetResult.USERNAME_UNAVAILABLE)
|
||||
}
|
||||
422 -> {
|
||||
Log.w(TAG, "[reserveUsername] Username malformed.")
|
||||
failure(UsernameSetResult.USERNAME_INVALID)
|
||||
}
|
||||
429 -> {
|
||||
Log.w(TAG, "[reserveUsername] Rate limit exceeded.")
|
||||
failure(UsernameSetResult.RATE_LIMIT_ERROR)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "[reserveUsername] Generic network exception.", result.exception)
|
||||
failure(UsernameSetResult.NETWORK_ERROR)
|
||||
}
|
||||
is RequestResult.NonSuccess -> {
|
||||
Log.w(TAG, "[reserveUsername] Username taken.")
|
||||
failure(UsernameSetResult.USERNAME_UNAVAILABLE)
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
if (result.networkError is RetryLaterException) {
|
||||
Log.w(TAG, "[reserveUsername] Rate limit exceeded.")
|
||||
failure(UsernameSetResult.RATE_LIMIT_ERROR)
|
||||
} else {
|
||||
Log.w(TAG, "[reserveUsername] Generic network exception.", result.networkError)
|
||||
failure(UsernameSetResult.NETWORK_ERROR)
|
||||
}
|
||||
}
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "[reserveUsername] Generic network exception.", result.exception)
|
||||
failure(UsernameSetResult.NETWORK_ERROR)
|
||||
}
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +434,7 @@ object UsernameRepository {
|
||||
val newUsernameLink = updatedUsername.generateLink(oldUsernameLink.entropy)
|
||||
|
||||
return when (val result = SignalNetwork.account.updateUsernameLink(newUsernameLink)) {
|
||||
is NetworkResult.Success -> {
|
||||
is RequestResult.Success -> {
|
||||
SignalStore.account.username = updatedUsername.username
|
||||
SignalStore.account.usernameLink = result.result
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, updatedUsername.username)
|
||||
|
||||
+13
-31
@@ -296,32 +296,11 @@ class AppRegistrationNetworkController(
|
||||
return@withContext RequestResult.NonSuccess(SetRegistrationLockError.NoPinSet)
|
||||
}
|
||||
|
||||
when (val result = SignalNetwork.account.enableRegistrationLock(masterKey.deriveRegistrationLock())) {
|
||||
is NetworkResult.Success -> RequestResult.Success(Unit)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized)
|
||||
422 -> RequestResult.NonSuccess(SetRegistrationLockError.InvalidRequest(result.toString()))
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
|
||||
}
|
||||
}
|
||||
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
|
||||
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
|
||||
}
|
||||
SignalNetwork.account.enableRegistrationLock(masterKey)
|
||||
}
|
||||
|
||||
override suspend fun disableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
|
||||
when (val result = SignalNetwork.account.disableRegistrationLock()) {
|
||||
is NetworkResult.Success -> RequestResult.Success(Unit)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized)
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
|
||||
}
|
||||
}
|
||||
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
|
||||
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
|
||||
}
|
||||
SignalNetwork.account.disableRegistrationLock()
|
||||
}
|
||||
|
||||
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> = withContext(Dispatchers.IO) {
|
||||
@@ -384,26 +363,29 @@ class AppRegistrationNetworkController(
|
||||
}
|
||||
}
|
||||
|
||||
when (val result = SignalNetwork.archive.getBackupInfo(aci, access)) {
|
||||
when (val result = SignalNetwork.archive.getMessageBackupInfo(aci, access)) {
|
||||
is NetworkResult.Success -> {
|
||||
val info = result.result
|
||||
RequestResult.Success(
|
||||
NetworkController.GetBackupInfoResponse(
|
||||
cdn = info.cdn,
|
||||
backupDir = info.backupDir,
|
||||
mediaDir = info.mediaDir,
|
||||
// mediaDir and usedSpace live under the media credential, not the message credential we're using here. The server left them empty on this request
|
||||
// before too, so nothing that reads them is losing a value it used to get.
|
||||
mediaDir = null,
|
||||
backupName = info.backupName,
|
||||
usedSpace = info.usedSpace
|
||||
usedSpace = null
|
||||
)
|
||||
)
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
400 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadArguments(result.stringBody))
|
||||
401 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadAuthCredential(result.stringBody))
|
||||
403 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.Forbidden(result.stringBody))
|
||||
404 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
|
||||
429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(0.seconds))
|
||||
401 -> {
|
||||
// The server doesn't distinguish an invalid credential from a backup-id that was never provisioned, so libsignal's guidance is to treat this as
|
||||
// "backups aren't set up" rather than an auth failure.
|
||||
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
|
||||
}
|
||||
429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(result.retryAfter() ?: Duration.ZERO))
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -89,6 +89,20 @@ fun <T : Any> RequestResult<T, Nothing>.successOrThrow(): T {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [Throwable] associated with a non-successful result, or null if there isn't one. Mirrors
|
||||
* [org.signal.network.NetworkResult.getCause] for logging-only call sites that don't care which flavor of failure
|
||||
* they got.
|
||||
*/
|
||||
fun RequestResult<*, *>.getCause(): Throwable? {
|
||||
return when (this) {
|
||||
is RequestResult.Success -> null
|
||||
is RequestResult.NonSuccess -> error as? Throwable
|
||||
is RequestResult.RetryableNetworkError -> networkError
|
||||
is RequestResult.ApplicationError -> cause
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Any> WebsocketResponse.toRequestResult(clazz: KClass<T>): RequestResult<T, RestStatusCodeError> {
|
||||
return if (status < 200 || status > 299) {
|
||||
RequestResult.NonSuccess(RestStatusCodeError(status, headers, body?.toByteArray()))
|
||||
|
||||
+83
-64
@@ -6,11 +6,15 @@
|
||||
package org.whispersystems.signalservice.api.account
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.Base64.encodeUrlSafeWithoutPadding
|
||||
import org.signal.libsignal.net.AuthAccountsService
|
||||
import org.signal.libsignal.net.AuthDevicesService
|
||||
import org.signal.libsignal.net.AuthUsernamesService
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.SvrKey
|
||||
import org.signal.libsignal.net.UsernameNotAvailableException
|
||||
import org.signal.libsignal.net.UsernameNotSetException
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.network.NetworkResult
|
||||
@@ -24,12 +28,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest
|
||||
import org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse
|
||||
import org.whispersystems.signalservice.internal.push.PhoneNumberDiscoverabilityRequest
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameRequest
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import org.whispersystems.signalservice.internal.push.SetUsernameLinkRequestBody
|
||||
import org.whispersystems.signalservice.internal.push.SetUsernameLinkResponseBody
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.security.SecureRandom
|
||||
@@ -99,31 +97,51 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock
|
||||
/**
|
||||
* Set whether this account is discoverable by phone number. Unlike [setAccountAttributes], this
|
||||
* dedicated endpoint can be called from a linked device.
|
||||
*/
|
||||
fun setPhoneNumberDiscoverability(discoverable: Boolean): RequestResult<Unit, Nothing> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthAccountsService(connection).setDiscoverableByPhoneNumber(discoverable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the registration lock, deriving the lock secret from [masterKey]. While enabled, re-registering this
|
||||
* account's phone number requires proving knowledge of the secret. Only the primary device may do this.
|
||||
*/
|
||||
fun enableRegistrationLock(masterKey: MasterKey): RequestResult<Unit, Nothing> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthAccountsService(connection).setRegistrationLock(SvrKey(masterKey.serialize()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any registration lock from the account. Also succeeds if no lock was set. Only the primary device may
|
||||
* do this.
|
||||
*/
|
||||
fun disableRegistrationLock(): RequestResult<Unit, Nothing> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthAccountsService(connection).clearRegistrationLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the registration recovery password, derived from [masterKey], letting this account re-register its phone number without SMS verification.
|
||||
* Any of the account's devices may do this.
|
||||
*
|
||||
* PUT /v2/accounts/phone_number_discoverability
|
||||
* - 204: Success
|
||||
* Note that we normally set the recovery password as part of [setAccountAttributes] instead.
|
||||
*/
|
||||
fun setPhoneNumberDiscoverability(discoverable: Boolean): RequestResult<Unit, RestStatusCodeError> {
|
||||
val request = WebSocketRequestMessage.put("/v2/accounts/phone_number_discoverability", PhoneNumberDiscoverabilityRequest(discoverable))
|
||||
return authWebSocket.fromWebSocketRequest(request, Unit::class)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /v1/accounts/registration_lock
|
||||
* - 204: Success
|
||||
*/
|
||||
fun enableRegistrationLock(registrationLock: String): NetworkResult<Unit> {
|
||||
val request = WebSocketRequestMessage.put("/v1/accounts/registration_lock", PushServiceSocket.RegistrationLockV2(registrationLock))
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /v1/accounts/registration_lock
|
||||
* - 204: Success
|
||||
*/
|
||||
fun disableRegistrationLock(): NetworkResult<Unit> {
|
||||
val request = WebSocketRequestMessage.delete("/v1/accounts/registration_lock")
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
|
||||
fun setRegistrationRecoveryPassword(masterKey: MasterKey): RequestResult<Unit, Nothing> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthAccountsService(connection).setRegistrationRecoveryPassword(SvrKey(masterKey.serialize()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,18 +186,16 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock
|
||||
* Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can
|
||||
* be confirmed with confirmUsername.
|
||||
*
|
||||
* PUT /v1/accounts/username_hash/reserve
|
||||
* - 200: Success
|
||||
* - 409: Username taken
|
||||
* - 422: Username malformed
|
||||
* - 429: Rate limited
|
||||
*
|
||||
* @param usernameHashes A list of hashed usernames encoded as web-safe base64 strings without padding. The list will have a max length of 20, and each hash will be 32 bytes.
|
||||
* @return The reserved username. It is available for confirmation for 5 minutes.
|
||||
* @param usernameHashes A prioritized list of 32-byte username hashes. Must contain between 1 and 20 entries.
|
||||
* @return The hash of the reserved username. It is available for confirmation for 5 minutes. A [UsernameNotAvailableException] means none of the provided
|
||||
* hashes were available.
|
||||
*/
|
||||
fun reserveUsername(usernameHashes: List<String>): NetworkResult<ReserveUsernameResponse> {
|
||||
val request = WebSocketRequestMessage.put("/v1/accounts/username_hash/reserve", ReserveUsernameRequest(usernameHashes))
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ReserveUsernameResponse::class)
|
||||
fun reserveUsername(usernameHashes: List<ByteArray>): RequestResult<ByteArray, UsernameNotAvailableException> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).reserveUsernameHash(usernameHashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,36 +246,39 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new username link for the given [usernameLink].
|
||||
*
|
||||
* PUT /v1/accounts/username_link
|
||||
* - 200: Success
|
||||
* - 409: Username is not set
|
||||
* - 422: Invalid [SetUsernameLinkRequestBody] format
|
||||
* - 429: Rate limited
|
||||
* Creates a new username link for the given [usernameLink]. A [UsernameNotSetException] means the account has no username set.
|
||||
*/
|
||||
fun createUsernameLink(usernameLink: Username.UsernameLink): NetworkResult<UsernameLinkComponents> {
|
||||
return modifyUsernameLink(usernameLink, false)
|
||||
fun createUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return modifyUsernameLink(usernameLink, keepLinkHandle = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account username link for the given [usernameLink].
|
||||
*
|
||||
* PUT /v1/accounts/username_link
|
||||
* - 200: Success
|
||||
* - 409: Username is not set
|
||||
* - 422: Invalid [SetUsernameLinkRequestBody] format
|
||||
* - 429: Rate limited
|
||||
* Updates the account's username link to the given [usernameLink], keeping the existing link handle.
|
||||
* A [UsernameNotSetException] means the account has no username set.
|
||||
*/
|
||||
fun updateUsernameLink(usernameLink: Username.UsernameLink): NetworkResult<UsernameLinkComponents> {
|
||||
return modifyUsernameLink(usernameLink, true)
|
||||
fun updateUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return modifyUsernameLink(usernameLink, keepLinkHandle = true)
|
||||
}
|
||||
|
||||
private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): NetworkResult<UsernameLinkComponents> {
|
||||
val encryptedUsername = Base64.encodeUrlSafeWithPadding(usernameLink.encryptedUsername)
|
||||
val request = WebSocketRequestMessage.put("/v1/accounts/username_link", SetUsernameLinkRequestBody(encryptedUsername, keepLinkHandle))
|
||||
/**
|
||||
* Clears any username link on the account, deactivating the link handle but leaving the username hash in place. This also succeeds if the account has no
|
||||
* username link, so a caller retrying a deletion sees the same result as the original call.
|
||||
*
|
||||
* Note that our own delete flow uses [deleteUsernameHash], which clears the link as well.
|
||||
*/
|
||||
fun deleteUsernameLink(): RequestResult<Unit, Nothing> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).deleteUsernameLink()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request, SetUsernameLinkResponseBody::class)
|
||||
.map { UsernameLinkComponents(usernameLink.entropy, it.usernameLinkHandle) }
|
||||
private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return runBlocking {
|
||||
authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).setUsernameLink(usernameLink.encryptedUsername, keepLinkHandle)
|
||||
}.map { UsernameLinkComponents(usernameLink.entropy, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Represents the response when fetching the archive backup info.
|
||||
*/
|
||||
data class ArchiveGetBackupInfoResponse(
|
||||
@JsonProperty
|
||||
val cdn: Int?,
|
||||
@JsonProperty
|
||||
val backupDir: String?,
|
||||
@JsonProperty
|
||||
val mediaDir: String?,
|
||||
@JsonProperty
|
||||
val backupName: String?,
|
||||
@JsonProperty
|
||||
val usedSpace: Long?
|
||||
)
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
class ArchiveMediaRequest(
|
||||
@JsonProperty val sourceAttachment: SourceAttachment,
|
||||
@JsonProperty val objectLength: Int,
|
||||
@JsonProperty val mediaId: String,
|
||||
@JsonProperty val hmacKey: String,
|
||||
@JsonProperty val encryptionKey: String
|
||||
) {
|
||||
class SourceAttachment(
|
||||
@JsonProperty val cdn: Int,
|
||||
@JsonProperty val key: String
|
||||
)
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Response to archiving media, backup CDN number where media is located.
|
||||
*/
|
||||
class ArchiveMediaResponse(
|
||||
@JsonProperty val cdn: Int
|
||||
) {
|
||||
enum class StatusCodes(val code: Int) {
|
||||
BadArguments(400),
|
||||
InvalidPresentationOrSignature(401),
|
||||
InsufficientPermissions(403),
|
||||
NoMediaSpaceRemaining(413),
|
||||
RateLimited(429),
|
||||
Unknown(-1);
|
||||
|
||||
companion object {
|
||||
fun from(code: Int): StatusCodes {
|
||||
return entries.firstOrNull { it.code == code } ?: Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.JsonSerializer
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
|
||||
/**
|
||||
* Represents the request body when setting the archive public key.
|
||||
*/
|
||||
class ArchiveSetPublicKeyRequest(
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = PublicKeySerializer::class)
|
||||
val backupIdPublicKey: ECPublicKey
|
||||
) {
|
||||
class PublicKeySerializer : JsonSerializer<ECPublicKey>() {
|
||||
override fun serialize(value: ECPublicKey, gen: JsonGenerator, serializers: SerializerProvider) {
|
||||
gen.writeString(Base64.encodeWithPadding(value.serialize()))
|
||||
}
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
class BatchArchiveMediaRequest(
|
||||
@JsonProperty val items: List<ArchiveMediaRequest>
|
||||
)
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Multi-response data for a batch archive media operation.
|
||||
*/
|
||||
class BatchArchiveMediaResponse(
|
||||
@JsonProperty val responses: List<BatchArchiveMediaItemResponse>
|
||||
) {
|
||||
class BatchArchiveMediaItemResponse(
|
||||
@JsonProperty val status: Int?,
|
||||
@JsonProperty val failureReason: String?,
|
||||
@JsonProperty val cdn: Int?,
|
||||
@JsonProperty val mediaId: String
|
||||
)
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Delete media from the backup cdn.
|
||||
*/
|
||||
class DeleteArchivedMediaRequest(
|
||||
@JsonProperty val mediaToDelete: List<ArchivedMediaObject>
|
||||
) {
|
||||
data class ArchivedMediaObject(
|
||||
@JsonProperty val cdn: Int,
|
||||
@JsonProperty val mediaId: String
|
||||
)
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.link
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request body for setting the name of a linked device.
|
||||
*/
|
||||
data class SetDeviceNameRequest(
|
||||
@JsonProperty val deviceName: String
|
||||
)
|
||||
+13
@@ -8,6 +8,8 @@ package org.whispersystems.signalservice.api.profiles
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.UnauthProfilesService
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential
|
||||
@@ -184,6 +186,17 @@ class ProfileApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account with the given ACI or PNI exists. This is an unauthenticated request.
|
||||
*
|
||||
* Note that we normally learn this from a 404 on a profile fetch instead.
|
||||
*/
|
||||
suspend fun accountExists(serviceId: ServiceId): RequestResult<Boolean, Nothing> {
|
||||
return unauthWebSocket.runCatchingWithChatConnection { connection ->
|
||||
UnauthProfilesService(connection).accountExists(serviceId.libSignalServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
private class ProfileAndCredentialResponseConverter(
|
||||
private val clientZkProfileOperations: ClientZkProfileOperations,
|
||||
private val requestContext: ProfileKeyCredentialRequestContext
|
||||
|
||||
+8
@@ -368,6 +368,14 @@ sealed class SignalWebSocket(
|
||||
suspend fun <Result, Error : BadRequestError> runCatchingWithChatConnection(
|
||||
callback: (UnauthenticatedChatConnection) -> CompletableFuture<RequestResult<Result, Error>>
|
||||
): RequestResult<Result, Error> = runCatchingWithChatConnectionInternal { callback(it as UnauthenticatedChatConnection) }
|
||||
|
||||
/**
|
||||
* Companion to [runCatchingWithChatConnection] for libsignal's streaming endpoints, which hand back a [kotlinx.coroutines.flow.Flow] rather than a future.
|
||||
* The stream is started on the chat connection here; collecting it (and classifying whatever it throws) is up to the caller.
|
||||
*/
|
||||
suspend fun <T> withChatConnection(callback: (UnauthenticatedChatConnection) -> T): T {
|
||||
return getWebSocket().runWithChatConnection { callback(it as UnauthenticatedChatConnection) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class PhoneNumberDiscoverabilityRequest(
|
||||
@JsonProperty val discoverableByPhoneNumber: Boolean
|
||||
)
|
||||
-11
@@ -1610,17 +1610,6 @@ public class PushServiceSocket {
|
||||
|
||||
public enum VerificationCodeTransport { SMS, VOICE }
|
||||
|
||||
public static class RegistrationLockV2 {
|
||||
@JsonProperty
|
||||
private String registrationLock;
|
||||
|
||||
public RegistrationLockV2() {}
|
||||
|
||||
public RegistrationLockV2(String registrationLock) {
|
||||
this.registrationLock = registrationLock;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RegistrationLockFailure {
|
||||
@JsonProperty
|
||||
public int length;
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ReserveUsernameRequest {
|
||||
@JsonProperty
|
||||
private List<String> usernameHashes;
|
||||
|
||||
public ReserveUsernameRequest(List<String> usernameHashes) {
|
||||
this.usernameHashes = usernameHashes;
|
||||
}
|
||||
|
||||
List<String> getUsernameHashes() {
|
||||
return usernameHashes;
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class ReserveUsernameResponse {
|
||||
@JsonProperty
|
||||
private String usernameHash;
|
||||
|
||||
ReserveUsernameResponse() {}
|
||||
|
||||
/**
|
||||
* Visible for testing.
|
||||
*/
|
||||
public ReserveUsernameResponse(String usernameHash) {
|
||||
this.usernameHash = usernameHash;
|
||||
}
|
||||
|
||||
public String getUsernameHash() {
|
||||
return usernameHash;
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/** Request body for setting a username link on the service. */
|
||||
data class SetUsernameLinkRequestBody(@JsonProperty val usernameLinkEncryptedValue: String, @JsonProperty val keepLinkHandle: Boolean)
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import org.signal.network.util.JsonUtil.UuidDeserializer
|
||||
import java.util.UUID
|
||||
|
||||
/** Response body for setting a username link on the service. */
|
||||
data class SetUsernameLinkResponseBody(
|
||||
@JsonProperty
|
||||
@JsonDeserialize(using = UuidDeserializer::class)
|
||||
val usernameLinkHandle: UUID
|
||||
)
|
||||
@@ -5,38 +5,48 @@
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.backup.BackupKey
|
||||
import org.signal.core.models.backup.MediaRootBackupKey
|
||||
import org.signal.core.models.backup.MessageBackupKey
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.internal.CompletableFuture
|
||||
import org.signal.libsignal.net.BackupAuth
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
import org.signal.libsignal.net.CopyBackupMediaItem
|
||||
import org.signal.libsignal.net.CopyBackupMediaOutcome
|
||||
import org.signal.libsignal.net.DeleteBackupMediaItem
|
||||
import org.signal.libsignal.net.GetUploadFormError
|
||||
import org.signal.libsignal.net.MediaBackupInfo
|
||||
import org.signal.libsignal.net.MessageBackupInfo
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.RequestUnauthorizedException
|
||||
import org.signal.libsignal.net.UnauthBackupsService
|
||||
import org.signal.libsignal.net.UnauthenticatedChatConnection
|
||||
import org.signal.libsignal.net.UploadForm
|
||||
import org.signal.libsignal.net.UploadTooLargeException
|
||||
import org.signal.libsignal.net.toRequestResult
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.GenericServerPublicParams
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.delete
|
||||
import org.signal.network.websocket.get
|
||||
import org.signal.network.websocket.post
|
||||
import org.signal.network.websocket.put
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
|
||||
import org.whispersystems.signalservice.api.fromWebSocketRequest
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
@@ -48,7 +58,6 @@ import java.io.InputStream
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Class to interact with various archive-related endpoints.
|
||||
@@ -91,23 +100,16 @@ class ArchiveApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets credentials needed to read from the CDN. Make sure you use the right [backupKey] depending on whether you're doing a message or media operation.
|
||||
* Gets credentials needed to read from the CDN. Make sure you use the right [archiveServiceAccess] depending on whether you're doing a message or media
|
||||
* operation.
|
||||
*
|
||||
* GET /v1/archives/auth/read
|
||||
*
|
||||
* - 200: Success
|
||||
* - 400: Bad arguments, or made on an authenticated channel
|
||||
* - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden
|
||||
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun getCdnReadCredentials(cdnNumber: Int, aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<GetArchiveCdnCredentialsResponse> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives/auth/read?cdn=$cdnNumber", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, GetArchiveCdnCredentialsResponse::class)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).getCdnCredentials(auth, cdnNumber)
|
||||
}.toNetworkResult().map { GetArchiveCdnCredentialsResponse(it.headers) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,110 +141,67 @@ class ArchiveApi(
|
||||
* unauthorized users from changing your backup data. You only need to do it once, but repeated
|
||||
* calls are safe.
|
||||
*
|
||||
* PUT /v1/archives/keys
|
||||
*
|
||||
* - 204: Success
|
||||
* - 400: Bad arguments, or request was made on an authenticated channel
|
||||
* - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden
|
||||
* - 401: The credential in particular is invalid, since the key is being updated
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun setPublicKey(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.then { presentation ->
|
||||
val headers = presentation.toArchiveCredentialPresentation().toHeaders()
|
||||
val publicKey = presentation.publicKey
|
||||
|
||||
val request = WebSocketRequestMessage.put("/v1/archives/keys", ArchiveSetPublicKeyRequest(publicKey), headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).setPublicKey(auth) }.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an upload form you can use to upload your main message backup file to cloud storage.
|
||||
*
|
||||
* GET /v1/archives/upload/form
|
||||
* - 200: Success
|
||||
* - 400: Bad args, or made on an authenticated channel
|
||||
* - 403: Insufficient permissions
|
||||
* - 401: Authorization failed
|
||||
* - 413: The backup is too large
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun getMessageBackupUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>, backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives/upload/form?uploadLength=$backupFileSize", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).getUploadForm(auth, backupFileSize)
|
||||
}.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches metadata about your current backup. This will be different for different key/credential pairs. For example, message credentials will always
|
||||
* return 0 for used space since that is stored under the media key/credential.
|
||||
* Fetches metadata about the currently-stored message backup.
|
||||
*
|
||||
* Will return a [NetworkResult.StatusCodeError] with status code 404 if you haven't uploaded a backup yet.
|
||||
*
|
||||
* GET /v1/archives
|
||||
* - 200: Success
|
||||
* - 400: Bad arguments. The request may have been made on an authenticated channel.
|
||||
* - 401: The provided backup auth credential presentation could not be verified or the public key signature was invalid or there is no backup associated with
|
||||
* the backup-id in the presentation or the credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden
|
||||
* - 404: No backup
|
||||
* - 429: Rate limited
|
||||
* - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using
|
||||
* this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error.
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun getBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<ArchiveGetBackupInfoResponse> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetBackupInfoResponse::class)
|
||||
}
|
||||
fun getMessageBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<MessageBackupInfo> {
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMessageBackupInfo(auth) }.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh via a POST request. If a backup is not
|
||||
* refreshed, after 30 days it may be deleted.
|
||||
* Fetches metadata about the currently-stored media backup, including how much space it uses.
|
||||
*
|
||||
* POST /v1/archives
|
||||
* - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using
|
||||
* this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error.
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun getMediaBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): NetworkResult<MediaBackupInfo> {
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMediaBackupInfo(auth) }.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh. If a backup is not refreshed, after 30 days
|
||||
* it may be deleted.
|
||||
*
|
||||
* - 204: The backup was successfully refreshed.
|
||||
* - 400: Bad arguments. The request may have been made on an authenticated channel.
|
||||
* - 401: The provided backup auth credential presentation could not be verified or The public key signature was invalid or There is no backup associated with
|
||||
* the backup-id in the presentation or The credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden. The request had insufficient permissions to perform the requested action.
|
||||
* - 429: Rate limited.
|
||||
* - 401: Authorization failed
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.post(path = "/v1/archives", body = null, headers = headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).refresh(auth) }.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied.
|
||||
*
|
||||
* DELETE /v1/archives
|
||||
*
|
||||
* - 204: The backup has been successfully deleted
|
||||
* - 400: Bad arguments. The request may have been made on an authenticated channel.
|
||||
* - 401: The provided backup auth credential presentation could not be verified or The public key signature was invalid or There is no backup associated with
|
||||
* the backup-id in the presentation or The credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden. The request had insufficient permissions to perform the requested action.
|
||||
* - 429: Rate limited.
|
||||
*
|
||||
* - 401: Authorization failed
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.delete("/v1/archives", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).deleteAll(auth) }.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,23 +246,16 @@ class ArchiveApi(
|
||||
* This is basically the same as [org.signal.network.api.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit
|
||||
* so we can request them more often (which is required for backfilling).
|
||||
*
|
||||
* After uploading, the media still needs to be copied via [copyAttachmentToArchive].
|
||||
* After uploading, the media still needs to be copied via [copyMediaToArchive].
|
||||
*
|
||||
* GET /v1/archives/media/upload/form
|
||||
*
|
||||
* - 200: Success
|
||||
* - 400: Bad request, or made on authenticated channel
|
||||
* - 403: Forbidden
|
||||
* - 401: Authorization failed
|
||||
* - 413: The media is too large
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun getMediaUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, uploadLength: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives/media/upload/form?uploadLength=$uploadLength", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).getMediaUploadForm(auth, uploadLength)
|
||||
}.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,8 +291,7 @@ class ArchiveApi(
|
||||
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
|
||||
*/
|
||||
fun getArchiveMediaItemsPage(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, limit: Int, cursor: String?): NetworkResult<ArchiveGetMediaItemsResponse> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
return getCredentialPresentationHeaders(aci, archiveServiceAccess)
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives/media?limit=$limit${if (cursor.isNotNullOrBlank()) "&cursor=$cursor" else ""}", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetMediaItemsResponse::class)
|
||||
@@ -350,84 +301,49 @@ class ArchiveApi(
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*
|
||||
* PUT /v1/archives/media
|
||||
* The copy operation is not atomic: each item gets its own [CopyBackupMediaOutcome], and there is no need to retry items that produced one. If the stream
|
||||
* terminates early, the returned list only contains the outcomes received so far, so a partial success is reported as a failure carrying no outcomes.
|
||||
*
|
||||
* - 200: Success
|
||||
* - 400: Bad arguments, or made on an authenticated channel
|
||||
* - 401: Invalid presentation or signature
|
||||
* - 403: Insufficient permissions
|
||||
* - 410: The source object was not found
|
||||
* - 413: No media space remaining
|
||||
* - 401: Authorization failed. Because large batches span multiple server requests, this can happen partway through.
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun copyAttachmentToArchive(
|
||||
fun copyMediaToArchive(
|
||||
aci: ACI,
|
||||
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
|
||||
item: ArchiveMediaRequest
|
||||
): NetworkResult<ArchiveMediaResponse> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.put("/v1/archives/media", item, headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveMediaResponse::class)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
fun copyAttachmentToArchive(
|
||||
aci: ACI,
|
||||
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
|
||||
items: List<ArchiveMediaRequest>
|
||||
): NetworkResult<BatchArchiveMediaResponse> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.put("/v1/archives/media/batch", BatchArchiveMediaRequest(items = items), headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, BatchArchiveMediaResponse::class)
|
||||
}
|
||||
items: List<CopyBackupMediaItem>
|
||||
): NetworkResult<List<CopyBackupMediaOutcome>> {
|
||||
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).copyMedia(auth, items)
|
||||
}.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media from the backup cdn.
|
||||
*
|
||||
* POST /v1/archives/media/delete
|
||||
* Like [copyMediaToArchive], the operation is not atomic and a stream that terminates early reports failure rather than a partial result.
|
||||
*
|
||||
* - 400: Bad args or made on an authenticated channel
|
||||
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden
|
||||
* - 401: Authorization failed
|
||||
* - 429: Rate-limited
|
||||
*/
|
||||
fun deleteArchivedMedia(
|
||||
aci: ACI,
|
||||
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
|
||||
mediaToDelete: List<DeleteArchivedMediaRequest.ArchivedMediaObject>
|
||||
): NetworkResult<Unit> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.post("/v1/archives/media/delete", DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete), headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, timeout = 30.seconds)
|
||||
}
|
||||
mediaToDelete: List<DeleteBackupMediaItem>
|
||||
): NetworkResult<List<DeleteBackupMediaItem>> {
|
||||
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).deleteMedia(auth, mediaToDelete)
|
||||
}.toNetworkResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves auth credentials that can be used to perform SVRB operations.
|
||||
* Retrieves auth credentials that can be used to perform SVR-B operations.
|
||||
*
|
||||
* GET /v1/archives/auth/svrb
|
||||
* - 200: Success
|
||||
* - 400: Bad arguments, or made on an authenticated channel
|
||||
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
|
||||
* - 403: Forbidden
|
||||
* - 401: Authorization failed
|
||||
*/
|
||||
fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<AuthCredentials> {
|
||||
return getCredentialPresentation(aci, archiveServiceAccess)
|
||||
.map { it.toArchiveCredentialPresentation().toHeaders() }
|
||||
.then { headers ->
|
||||
val request = WebSocketRequestMessage.get("/v1/archives/auth/svrb", headers)
|
||||
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AuthCredentials::class)
|
||||
}
|
||||
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
|
||||
UnauthBackupsService(connection).getSvrBCredentials(auth)
|
||||
}.toNetworkResult().map { (username, password) -> AuthCredentials.create(username, password) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,10 +358,121 @@ class ArchiveApi(
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class)
|
||||
}
|
||||
|
||||
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
|
||||
/**
|
||||
* Issues an anonymous backup request over the unauthenticated chat connection, deriving the [BackupAuth] from [archiveServiceAccess]. Failing to derive it is
|
||||
* a local programming error, so it surfaces as [RequestResult.ApplicationError].
|
||||
*/
|
||||
private fun <T, E : BadRequestError> runWithBackupAuth(
|
||||
aci: ACI,
|
||||
archiveServiceAccess: ArchiveServiceAccess<*>,
|
||||
block: (UnauthenticatedChatConnection, BackupAuth) -> CompletableFuture<RequestResult<T, E>>
|
||||
): RequestResult<T, E> {
|
||||
val auth = try {
|
||||
getBackupAuth(aci, archiveServiceAccess)
|
||||
} catch (e: Throwable) {
|
||||
return RequestResult.ApplicationError(e)
|
||||
}
|
||||
|
||||
return runBlocking {
|
||||
unauthWebSocket.runCatchingWithChatConnection { connection -> block(connection, auth) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains a per-item backup media stream into a list. A stream that terminates early throws, which we classify the same way the non-streaming endpoints do.
|
||||
*/
|
||||
private fun <T> collectBackupMediaStream(
|
||||
aci: ACI,
|
||||
archiveServiceAccess: ArchiveServiceAccess<*>,
|
||||
block: (UnauthenticatedChatConnection, BackupAuth) -> Flow<T>
|
||||
): RequestResult<List<T>, RequestUnauthorizedException> {
|
||||
val auth = try {
|
||||
getBackupAuth(aci, archiveServiceAccess)
|
||||
} catch (e: Throwable) {
|
||||
return RequestResult.ApplicationError(e)
|
||||
}
|
||||
|
||||
return runBlocking {
|
||||
try {
|
||||
val stream = unauthWebSocket.withChatConnection { connection -> block(connection, auth) }
|
||||
RequestResult.Success(stream.toList())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.toRequestResult<RequestUnauthorizedException>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the anonymous-credential headers for the archive endpoints that still go out as hand-rolled websocket requests.
|
||||
*/
|
||||
private fun getCredentialPresentationHeaders(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Map<String, String>> {
|
||||
return NetworkResult.fromLocal {
|
||||
val zkCredential = getZkCredential(aci, archiveServiceAccess)
|
||||
CredentialPresentationData.from(archiveServiceAccess.backupKey, aci, zkCredential, backupServerPublicParams)
|
||||
val privateKey: ECPrivateKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
|
||||
val presentation: ByteArray = getZkCredential(aci, archiveServiceAccess).present(backupServerPublicParams).serialize()
|
||||
|
||||
ArchiveCredentialPresentation(
|
||||
presentation = presentation,
|
||||
signedPresentation = privateKey.calculateSignature(presentation)
|
||||
).toHeaders()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBackupAuth(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): BackupAuth {
|
||||
return BackupAuth(
|
||||
credential = getZkCredential(aci, archiveServiceAccess),
|
||||
serverKeys = backupServerPublicParams,
|
||||
signingKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UploadForm.toAttachmentUploadForm(): AttachmentUploadForm {
|
||||
return AttachmentUploadForm(
|
||||
cdn = cdn,
|
||||
key = key,
|
||||
headers = headers,
|
||||
signedUploadLocation = signedUploadUrl.toString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a libsignal result into a [NetworkResult] for this class's callers, which still chain [NetworkResult].
|
||||
*
|
||||
* Note that libsignal reports both HTTP 401 and 403 as [RequestUnauthorizedException] ("incorrect or insufficient" authorization), so a caller that used to
|
||||
* distinguish "bad credential" from "insufficient permissions" only ever sees the 401.
|
||||
*/
|
||||
private fun <T> RequestResult<T, RequestUnauthorizedException>.toNetworkResult(): NetworkResult<T> {
|
||||
return when (this) {
|
||||
is RequestResult.Success -> NetworkResult.Success(result)
|
||||
is RequestResult.NonSuccess -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized"))
|
||||
is RequestResult.RetryableNetworkError -> toNetworkResult()
|
||||
is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [toNetworkResult] for the upload-form endpoints, which can additionally reject the requested size.
|
||||
*/
|
||||
private fun <T> RequestResult<T, GetUploadFormError>.toUploadFormNetworkResult(): NetworkResult<T> {
|
||||
return when (this) {
|
||||
is RequestResult.Success -> NetworkResult.Success(result)
|
||||
is RequestResult.NonSuccess -> when (error) {
|
||||
is UploadTooLargeException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "Upload too large"))
|
||||
is RequestUnauthorizedException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized"))
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> toNetworkResult()
|
||||
is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rate limit becomes a synthetic 429 carrying a `retry-after` header, since that's where [NetworkResult.StatusCodeError.retryAfter] looks for it.
|
||||
*/
|
||||
private fun <T> RequestResult.RetryableNetworkError.toNetworkResult(): NetworkResult<T> {
|
||||
return when (val retryAfter = retryAfter) {
|
||||
null -> NetworkResult.NetworkError(networkError)
|
||||
else -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429, "Rate limited", null as String?, mapOf("retry-after" to retryAfter.seconds.toString())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,29 +486,4 @@ class ArchiveApi(
|
||||
backupServerPublicParams
|
||||
)
|
||||
}
|
||||
|
||||
private class CredentialPresentationData(
|
||||
val privateKey: ECPrivateKey,
|
||||
val presentation: ByteArray,
|
||||
val signedPresentation: ByteArray
|
||||
) {
|
||||
val publicKey: ECPublicKey = privateKey.getPublicKey()
|
||||
|
||||
companion object {
|
||||
fun from(backupKey: BackupKey, aci: ACI, credential: BackupAuthCredential, backupServerPublicParams: GenericServerPublicParams): CredentialPresentationData {
|
||||
val privateKey: ECPrivateKey = backupKey.deriveAnonymousCredentialPrivateKey(aci)
|
||||
val presentation: ByteArray = credential.present(backupServerPublicParams).serialize()
|
||||
val signedPresentation: ByteArray = privateKey.calculateSignature(presentation)
|
||||
|
||||
return CredentialPresentationData(privateKey, presentation, signedPresentation)
|
||||
}
|
||||
}
|
||||
|
||||
fun toArchiveCredentialPresentation(): ArchiveCredentialPresentation {
|
||||
return ArchiveCredentialPresentation(
|
||||
presentation = presentation,
|
||||
signedPresentation = signedPresentation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.urlEncode
|
||||
import org.signal.libsignal.net.AuthDevicesService
|
||||
import org.signal.libsignal.net.DeviceIdNotFoundException
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
@@ -26,7 +27,6 @@ import org.signal.network.websocket.get
|
||||
import org.signal.network.websocket.put
|
||||
import org.whispersystems.signalservice.api.fromWebSocketRequest
|
||||
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse
|
||||
import org.whispersystems.signalservice.api.link.SetDeviceNameRequest
|
||||
import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveError
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
@@ -215,17 +215,16 @@ class LinkDeviceApi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name for a linked device
|
||||
* Sets the name for a linked device.
|
||||
*
|
||||
* PUT /v1/accounts/name?deviceId=[deviceId]
|
||||
* @param encryptedDeviceName Must be between 1 and 225 bytes long.
|
||||
*
|
||||
* - 204: Success.
|
||||
* - 403: Not authorized to change the name of the device with the given ID
|
||||
* - 404: No device found with the given ID
|
||||
* A [DeviceIdNotFoundException] means there is no device with the given [deviceId].
|
||||
*/
|
||||
fun setDeviceName(encryptedDeviceName: String, deviceId: Int): NetworkResult<Unit> {
|
||||
val request = WebSocketRequestMessage.put("/v1/accounts/name?deviceId=$deviceId", SetDeviceNameRequest(encryptedDeviceName))
|
||||
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
|
||||
suspend fun setDeviceName(encryptedDeviceName: ByteArray, deviceId: Int): RequestResult<Unit, DeviceIdNotFoundException> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthDevicesService(connection).setDeviceName(deviceId, encryptedDeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user