mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Add AccountApiV2.
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.signal.core.models.MasterKey
|
||||
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
||||
import org.signal.core.util.serialization.ByteArrayToUrlSafeBase64Serializer
|
||||
import org.signal.core.util.serialization.ECPublicKeyToBase64Serializer
|
||||
import org.signal.core.util.serialization.KEMPublicKeyToBase64Serializer
|
||||
import org.signal.core.util.serialization.SignalJson
|
||||
import org.signal.libsignal.net.AuthAccountsService
|
||||
import org.signal.libsignal.net.AuthDevicesService
|
||||
import org.signal.libsignal.net.AuthUsernamesService
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
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.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.kem.KEMPublicKey
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
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.put
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Various user account specific APIs to get, update, and delete account data.
|
||||
*/
|
||||
class AccountApiV2(private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket) {
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/**
|
||||
* Fetch information about yourself.
|
||||
*
|
||||
* GET /v1/accounts/whoami
|
||||
* - 200: Success
|
||||
* - 401: Unauthorized
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun whoAmI(): RequestResult<WhoAmIResponse, WhoAmIError> {
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.get("/v1/accounts/whoami"),
|
||||
parseSuccess = { response ->
|
||||
SignalJson.decode(WhoAmIResponse.serializer(), response.body).getOrNull()
|
||||
?: throw IOException("Unparseable whoami response")
|
||||
},
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
401 -> WhoAmIError.Unauthorized
|
||||
429 -> WhoAmIError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the FCM push token the server should use to notify this device of new messages.
|
||||
*/
|
||||
suspend fun setFcmToken(fcmToken: String): RequestResult<Unit, Nothing> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthDevicesService(connection).setPushToken(fcmToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any push tokens associated with this device. Afterwards, the server will assume this device
|
||||
* polls for new messages over an open websocket.
|
||||
*/
|
||||
suspend fun clearFcmToken(): RequestResult<Unit, Nothing> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthDevicesService(connection).clearPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set account attributes.
|
||||
*
|
||||
* PUT /v1/accounts/attributes
|
||||
* - 200: Success
|
||||
* - 401: Unauthorized
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun setAccountAttributes(accountAttributes: RegistrationApiV2.AccountAttributes): RequestResult<Unit, SetAccountAttributesError> {
|
||||
val body = SignalJson.encode(RegistrationApiV2.AccountAttributes.serializer(), accountAttributes).fold(
|
||||
ifLeft = { return RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { it }
|
||||
)
|
||||
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.put("/v1/accounts/attributes", body),
|
||||
parseSuccess = { },
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
401 -> SetAccountAttributesError.Unauthorized
|
||||
429 -> SetAccountAttributesError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the capabilities of the calling device.
|
||||
*
|
||||
* PUT /v1/devices/capabilities
|
||||
* - 200: Success
|
||||
* - 401: Unauthorized
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun setCapabilities(capabilities: RegistrationApiV2.AccountAttributes.Capabilities): RequestResult<Unit, SetCapabilitiesError> {
|
||||
val body = SignalJson.encode(RegistrationApiV2.AccountAttributes.Capabilities.serializer(), capabilities).fold(
|
||||
ifLeft = { return RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { it }
|
||||
)
|
||||
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.put("/v1/devices/capabilities", body),
|
||||
parseSuccess = { },
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
401 -> SetCapabilitiesError.Unauthorized
|
||||
429 -> SetCapabilitiesError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether this account is discoverable by phone number. Unlike [setAccountAttributes], this
|
||||
* dedicated endpoint can be called from a linked device.
|
||||
*/
|
||||
suspend fun setPhoneNumberDiscoverability(discoverable: Boolean): RequestResult<Unit, Nothing> {
|
||||
return 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.
|
||||
*/
|
||||
suspend fun enableRegistrationLock(masterKey: MasterKey): RequestResult<Unit, Nothing> {
|
||||
return 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.
|
||||
*/
|
||||
suspend fun disableRegistrationLock(): RequestResult<Unit, Nothing> {
|
||||
return 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.
|
||||
*
|
||||
* Note that we normally set the recovery password as part of [setAccountAttributes] instead.
|
||||
*/
|
||||
suspend fun setRegistrationRecoveryPassword(masterKey: MasterKey): RequestResult<Unit, Nothing> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthAccountsService(connection).setRegistrationRecoveryPassword(SvrKey(masterKey.serialize()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the account off the service.
|
||||
*
|
||||
* DELETE /v1/accounts/me
|
||||
* - 204: Success
|
||||
* - 401: Unauthorized
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun deleteAccount(): RequestResult<Unit, DeleteAccountError> {
|
||||
val result = authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.delete("/v1/accounts/me"),
|
||||
parseSuccess = { },
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
401 -> DeleteAccountError.Unauthorized
|
||||
429 -> DeleteAccountError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Deleting the account invalidates the connection, which can kill the request before the response is read and
|
||||
// surface as a synthetic 4401. The deletion still went through.
|
||||
return if (result is RequestResult.RetryableNetworkError && (result.networkError as? NonSuccessfulResponseCodeException)?.code == 4401) {
|
||||
RequestResult.Success(Unit)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and get an account data report. The report is returned as raw JSON.
|
||||
*
|
||||
* GET /v2/accounts/data_report
|
||||
* - 200: Success
|
||||
* - 401: Unauthorized
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun accountDataReport(): RequestResult<String, AccountDataReportError> {
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.get("/v2/accounts/data_report"),
|
||||
parseSuccess = { response -> response.body },
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
401 -> AccountDataReportError.Unauthorized
|
||||
429 -> AccountDataReportError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the phone number that an account is associated with.
|
||||
*
|
||||
* PUT /v2/accounts/number
|
||||
* - 200: Success
|
||||
* - 403: The session was not verified or the recovery password was incorrect
|
||||
* - 409: Mismatched device ids to notify
|
||||
* - 410: Mismatched device registration ids to notify
|
||||
* - 422: Unable to parse [ChangeNumberRequest]
|
||||
* - 423: Account reglock enabled for new phone number
|
||||
* - 429: Rate limited
|
||||
*/
|
||||
suspend fun changeNumber(changeNumberRequest: ChangeNumberRequest): RequestResult<ChangeNumberResponse, ChangeNumberError> {
|
||||
val body = SignalJson.encode(ChangeNumberRequest.serializer(), changeNumberRequest).fold(
|
||||
ifLeft = { return RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { it }
|
||||
)
|
||||
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.put("/v2/accounts/number", body),
|
||||
parseSuccess = { response ->
|
||||
SignalJson.decode(ChangeNumberResponse.serializer(), response.body).getOrNull()
|
||||
?: throw IOException("Unparseable change number response")
|
||||
},
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
403 -> ChangeNumberError.IncorrectVerification
|
||||
409 -> ChangeNumberError.MismatchedDevices(SignalJson.decode(MismatchedDevicesResponse.serializer(), response.body).getOrNull())
|
||||
410 -> ChangeNumberError.StaleDevices(SignalJson.decode(StaleDevicesResponse.serializer(), response.body).getOrNull())
|
||||
422 -> ChangeNumberError.InvalidRequest(response.body)
|
||||
423 -> ChangeNumberError.RegistrationLock(SignalJson.decode(RegistrationApiV2.RegistrationLockResponse.serializer(), response.body).getOrNull())
|
||||
429 -> ChangeNumberError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
suspend fun reserveUsername(usernameHashes: List<ByteArray>): RequestResult<ByteArray, UsernameNotAvailableException> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).reserveUsernameHash(usernameHashes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a previously reserved username for the account.
|
||||
*
|
||||
* PUT /v1/accounts/username_hash/confirm
|
||||
* - 200: Success, returns the username link handle
|
||||
* - 409: Username is not reserved
|
||||
* - 410: Username unavailable
|
||||
* - 422: Unable to parse the request
|
||||
* - 429: Rate limited
|
||||
*
|
||||
* @param username The username the user wishes to confirm.
|
||||
*/
|
||||
suspend fun confirmUsername(username: Username, link: Username.UsernameLink): RequestResult<UUID, ConfirmUsernameError> {
|
||||
val randomness = ByteArray(32)
|
||||
random.nextBytes(randomness)
|
||||
|
||||
val proof: ByteArray = try {
|
||||
username.generateProofWithRandomness(randomness)
|
||||
} catch (e: BaseUsernameException) {
|
||||
return RequestResult.ApplicationError(e)
|
||||
}
|
||||
|
||||
val body = SignalJson.encode(
|
||||
ConfirmUsernameRequestBody.serializer(),
|
||||
ConfirmUsernameRequestBody(
|
||||
usernameHash = username.hash,
|
||||
zkProof = proof,
|
||||
encryptedUsername = link.encryptedUsername
|
||||
)
|
||||
).fold(
|
||||
ifLeft = { return RequestResult.ApplicationError(it.cause) },
|
||||
ifRight = { it }
|
||||
)
|
||||
|
||||
return authWebSocket.requestResult(
|
||||
request = WebSocketRequestMessage.put("/v1/accounts/username_hash/confirm", body),
|
||||
parseSuccess = { response ->
|
||||
val parsed = SignalJson.decode(ConfirmUsernameResponseBody.serializer(), response.body).getOrNull()
|
||||
?: throw IOException("Unparseable confirm username response")
|
||||
UUID.fromString(parsed.usernameLinkHandle)
|
||||
},
|
||||
mapError = { response ->
|
||||
when (response.status) {
|
||||
409 -> ConfirmUsernameError.UsernameNotReserved
|
||||
410 -> ConfirmUsernameError.UsernameUnavailable
|
||||
422 -> ConfirmUsernameError.InvalidRequest(response.body)
|
||||
429 -> ConfirmUsernameError.RateLimited(response.retryAfter())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current username hash, ciphertext, and link for the authenticated account.
|
||||
*
|
||||
* This also succeeds if the account has no username set, so a caller retrying a deletion sees
|
||||
* the same result as the original call.
|
||||
*/
|
||||
suspend fun deleteUsernameHash(): RequestResult<Unit, Nothing> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).deleteUsernameHash()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new username link for the given [usernameLink]. A [UsernameNotSetException] means the account has no username set.
|
||||
*/
|
||||
suspend fun createUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return modifyUsernameLink(usernameLink, keepLinkHandle = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the account's username link to the given [usernameLink], keeping the existing link handle.
|
||||
* A [UsernameNotSetException] means the account has no username set.
|
||||
*/
|
||||
suspend fun updateUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return modifyUsernameLink(usernameLink, keepLinkHandle = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
suspend fun deleteUsernameLink(): RequestResult<Unit, Nothing> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).deleteUsernameLink()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
|
||||
return authWebSocket.runCatchingWithChatConnection { connection ->
|
||||
AuthUsernamesService(connection).setUsernameLink(usernameLink.encryptedUsername, keepLinkHandle)
|
||||
}.map { UsernameLinkComponents(usernameLink.entropy, it) }
|
||||
}
|
||||
|
||||
private fun <T, E : BadRequestError, R> RequestResult<T, E>.map(transform: (T) -> R): RequestResult<R, E> {
|
||||
return when (this) {
|
||||
is RequestResult.Success -> RequestResult.Success(transform(result))
|
||||
is RequestResult.NonSuccess -> this
|
||||
is RequestResult.RetryableNetworkError -> this
|
||||
is RequestResult.ApplicationError -> this
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WhoAmIResponse(
|
||||
@SerialName("uuid") val aci: String,
|
||||
val pni: String? = null,
|
||||
@SerialName("number") val e164: String? = null,
|
||||
val usernameHash: String? = null,
|
||||
val entitlements: Entitlements? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Entitlements(
|
||||
val badges: List<BadgeEntitlement> = emptyList(),
|
||||
val backup: BackupEntitlement? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BadgeEntitlement(
|
||||
val id: String? = null,
|
||||
val visible: Boolean? = null,
|
||||
val expirationSeconds: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupEntitlement(
|
||||
val backupLevel: Long? = null,
|
||||
val expirationSeconds: Long? = null
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChangeNumberRequest(
|
||||
val sessionId: String? = null,
|
||||
val recoveryPassword: String? = null,
|
||||
val number: String,
|
||||
@SerialName("reglock")
|
||||
val registrationLock: String? = null,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val pniIdentityKey: ByteArray,
|
||||
val deviceMessages: List<OutgoingPushMessage>,
|
||||
val devicePniSignedPrekeys: Map<String, SignedPreKeyEntity>,
|
||||
@SerialName("devicePniPqLastResortPrekeys")
|
||||
val devicePniLastResortKyberPrekeys: Map<String, KyberPreKeyEntity>,
|
||||
val pniRegistrationIds: Map<String, Int>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChangeNumberResponse(
|
||||
@SerialName("uuid") val aci: String,
|
||||
val pni: String? = null,
|
||||
@SerialName("number") val e164: String? = null,
|
||||
val storageCapable: Boolean = false,
|
||||
val reregistration: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OutgoingPushMessage(
|
||||
val type: Int,
|
||||
val destinationDeviceId: Int,
|
||||
val destinationRegistrationId: Int,
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class SignedPreKeyEntity(
|
||||
val keyId: Long,
|
||||
@Serializable(with = ECPublicKeyToBase64Serializer::class)
|
||||
val publicKey: ECPublicKey,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class KyberPreKeyEntity(
|
||||
val keyId: Long,
|
||||
@Serializable(with = KEMPublicKeyToBase64Serializer::class)
|
||||
val publicKey: KEMPublicKey,
|
||||
@Serializable(with = ByteArrayToBase64Serializer::class)
|
||||
val signature: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MismatchedDevicesResponse(
|
||||
val missingDevices: List<Int> = emptyList(),
|
||||
val extraDevices: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StaleDevicesResponse(
|
||||
val staleDevices: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class ConfirmUsernameRequestBody(
|
||||
@Serializable(with = ByteArrayToUrlSafeBase64Serializer::class)
|
||||
val usernameHash: ByteArray,
|
||||
@Serializable(with = ByteArrayToUrlSafeBase64Serializer::class)
|
||||
val zkProof: ByteArray,
|
||||
@Serializable(with = ByteArrayToUrlSafeBase64Serializer::class)
|
||||
val encryptedUsername: ByteArray
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private class ConfirmUsernameResponseBody(
|
||||
val usernameHash: String,
|
||||
val usernameLinkHandle: String
|
||||
)
|
||||
|
||||
sealed interface WhoAmIError : BadRequestError {
|
||||
data object Unauthorized : WhoAmIError
|
||||
data class RateLimited(val retryAfter: Duration?) : WhoAmIError
|
||||
}
|
||||
|
||||
sealed interface SetAccountAttributesError : BadRequestError {
|
||||
data object Unauthorized : SetAccountAttributesError
|
||||
data class RateLimited(val retryAfter: Duration?) : SetAccountAttributesError
|
||||
}
|
||||
|
||||
sealed interface SetCapabilitiesError : BadRequestError {
|
||||
data object Unauthorized : SetCapabilitiesError
|
||||
data class RateLimited(val retryAfter: Duration?) : SetCapabilitiesError
|
||||
}
|
||||
|
||||
sealed interface DeleteAccountError : BadRequestError {
|
||||
data object Unauthorized : DeleteAccountError
|
||||
data class RateLimited(val retryAfter: Duration?) : DeleteAccountError
|
||||
}
|
||||
|
||||
sealed interface AccountDataReportError : BadRequestError {
|
||||
data object Unauthorized : AccountDataReportError
|
||||
data class RateLimited(val retryAfter: Duration?) : AccountDataReportError
|
||||
}
|
||||
|
||||
sealed interface ChangeNumberError : BadRequestError {
|
||||
/** The session was not verified, or the recovery password was incorrect. */
|
||||
data object IncorrectVerification : ChangeNumberError
|
||||
|
||||
/** The set of devices to notify didn't match the account's devices. Null payloads mean the response body couldn't be parsed. */
|
||||
data class MismatchedDevices(val devices: MismatchedDevicesResponse?) : ChangeNumberError
|
||||
|
||||
/** Some of the devices to notify had stale registration ids. Null payloads mean the response body couldn't be parsed. */
|
||||
data class StaleDevices(val devices: StaleDevicesResponse?) : ChangeNumberError
|
||||
|
||||
data class InvalidRequest(val message: String) : ChangeNumberError
|
||||
|
||||
/** The new number has registration lock enabled. A null payload means the response body couldn't be parsed. */
|
||||
data class RegistrationLock(val data: RegistrationApiV2.RegistrationLockResponse?) : ChangeNumberError
|
||||
|
||||
data class RateLimited(val retryAfter: Duration?) : ChangeNumberError
|
||||
}
|
||||
|
||||
sealed interface ConfirmUsernameError : BadRequestError {
|
||||
data object UsernameNotReserved : ConfirmUsernameError
|
||||
data object UsernameUnavailable : ConfirmUsernameError
|
||||
data class InvalidRequest(val message: String) : ConfirmUsernameError
|
||||
data class RateLimited(val retryAfter: Duration?) : ConfirmUsernameError
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ 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.ServerSideErrorException
|
||||
import org.signal.libsignal.net.UnauthBackupsService
|
||||
import org.signal.libsignal.net.UnauthenticatedChatConnection
|
||||
import org.signal.libsignal.net.UploadForm
|
||||
@@ -39,7 +38,6 @@ import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
|
||||
import org.signal.network.toRequestResult
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.signal.network.websocket.get
|
||||
import org.signal.network.websocket.put
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation
|
||||
@@ -330,44 +328,6 @@ class ArchiveApiV2(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a hand-rolled REST-over-websocket request, classifying the outcome the same way libsignal classifies its own.
|
||||
*
|
||||
* A 5xx is reported as [RequestResult.RetryableNetworkError] rather than being offered to [mapError], because a server-side failure is transient no matter
|
||||
* which endpoint produced it, and callers uniformly want to back off rather than treat it as a decision point.
|
||||
*
|
||||
* A null from [mapError] on any other code means the status isn't one this endpoint documents. That's also a [RequestResult.RetryableNetworkError], not an
|
||||
* [RequestResult.ApplicationError]: the server can start returning a code we don't model yet without us shipping, so callers should back off rather than treat
|
||||
* it as a local bug. Several callers escalate an application error to a crash, which is the wrong response to an unexpected status code.
|
||||
*
|
||||
* Both of those carry a [ServerSideErrorException], which is what libsignal uses for a server-side failure on the endpoints it owns. That lets a caller that
|
||||
* wants a longer backoff for "the server is unhappy" than for "we couldn't reach the server" tell them apart, and get the same answer either way.
|
||||
*/
|
||||
private suspend fun <T, E : BadRequestError> SignalWebSocket.requestResult(
|
||||
request: WebSocketRequestMessage,
|
||||
parseSuccess: (WebsocketResponse) -> T,
|
||||
mapError: (WebsocketResponse) -> E?
|
||||
): RequestResult<T, E> {
|
||||
return try {
|
||||
val response = requestSuspend(request)
|
||||
|
||||
when {
|
||||
response.status in 200..299 -> RequestResult.Success(parseSuccess(response))
|
||||
response.status in 500..599 -> RequestResult.RetryableNetworkError(ServerSideErrorException("Server error: ${response.status}"))
|
||||
else -> when (val error = mapError(response)) {
|
||||
null -> RequestResult.RetryableNetworkError(ServerSideErrorException("Unexpected response code: ${response.status}"))
|
||||
else -> RequestResult.NonSuccess(error)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Throwable) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.api
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.ServerSideErrorException
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Issues a hand-rolled REST-over-websocket request, classifying the outcome the same way libsignal classifies its own. Never throws (aside from propagating
|
||||
* cancellation) -- every failure is reported as a [RequestResult] error variant.
|
||||
*
|
||||
* A null from [mapError] on any other code means the status isn't one this endpoint documents. It's mapped to [RequestResult.RetryableNetworkError].
|
||||
*
|
||||
* A 5xx or an unmapped status carries a [ServerSideErrorException], matching how libsignal reports server-side failures on the endpoints it owns.
|
||||
*
|
||||
* [parseSuccess] may throw an [IOException] for a 2xx body that can't be parsed; it is caught here and reported as a [RequestResult.RetryableNetworkError],
|
||||
* matching how a garbled response from the transport itself is classified.
|
||||
*/
|
||||
internal suspend fun <T, E : BadRequestError> SignalWebSocket.requestResult(
|
||||
request: WebSocketRequestMessage,
|
||||
parseSuccess: (WebsocketResponse) -> T,
|
||||
mapError: (WebsocketResponse) -> E?
|
||||
): RequestResult<T, E> {
|
||||
return try {
|
||||
val response = requestSuspend(request)
|
||||
|
||||
when {
|
||||
response.status in 200..299 -> RequestResult.Success(parseSuccess(response))
|
||||
response.status in 500..599 -> RequestResult.RetryableNetworkError(ServerSideErrorException("Server error: ${response.status}"))
|
||||
else -> when (val error = mapError(response)) {
|
||||
null -> RequestResult.RetryableNetworkError(ServerSideErrorException("Unexpected response code: ${response.status}"))
|
||||
else -> RequestResult.NonSuccess(error)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Throwable) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user