diff --git a/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToUrlSafeBase64Serializer.kt b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToUrlSafeBase64Serializer.kt new file mode 100644 index 0000000000..bd157ef219 --- /dev/null +++ b/core/serialization/src/main/java/org/signal/core/util/serialization/ByteArrayToUrlSafeBase64Serializer.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.signal.core.util.Base64 + +class ByteArrayToUrlSafeBase64Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ByteArray { + return Base64.decode(decoder.decodeString()) + } + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(Base64.encodeUrlSafeWithoutPadding(value)) + } +} diff --git a/lib/network/src/main/java/org/signal/network/api/AccountApiV2.kt b/lib/network/src/main/java/org/signal/network/api/AccountApiV2.kt new file mode 100644 index 0000000000..253450605c --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/api/AccountApiV2.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): RequestResult { + 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 { + 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 { + 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 { + 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 { + 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 { + return authWebSocket.runCatchingWithChatConnection { connection -> + AuthUsernamesService(connection).deleteUsernameLink() + } + } + + private suspend fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): RequestResult { + return authWebSocket.runCatchingWithChatConnection { connection -> + AuthUsernamesService(connection).setUsernameLink(usernameLink.encryptedUsername, keepLinkHandle) + }.map { UsernameLinkComponents(usernameLink.entropy, it) } + } + + private fun RequestResult.map(transform: (T) -> R): RequestResult { + 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 = 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, + val devicePniSignedPrekeys: Map, + @SerialName("devicePniPqLastResortPrekeys") + val devicePniLastResortKyberPrekeys: Map, + val pniRegistrationIds: Map + ) + + @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 = emptyList(), + val extraDevices: List = emptyList() + ) + + @Serializable + data class StaleDevicesResponse( + val staleDevices: List = 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 + } +} diff --git a/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt b/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt index ac3aa15a9f..cfaf41b6d8 100644 --- a/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt +++ b/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt @@ -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 SignalWebSocket.requestResult( - request: WebSocketRequestMessage, - parseSuccess: (WebsocketResponse) -> T, - mapError: (WebsocketResponse) -> E? - ): RequestResult { - 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]. diff --git a/lib/network/src/main/java/org/signal/network/api/SignalWebSocketExtensions.kt b/lib/network/src/main/java/org/signal/network/api/SignalWebSocketExtensions.kt new file mode 100644 index 0000000000..0ab5e934a5 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/api/SignalWebSocketExtensions.kt @@ -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 SignalWebSocket.requestResult( + request: WebSocketRequestMessage, + parseSuccess: (WebsocketResponse) -> T, + mapError: (WebsocketResponse) -> E? +): RequestResult { + 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) + } +}