From c4faafa9ae1f2355dd1e087c3d6fee7fae643ca7 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 30 Jun 2026 16:21:48 -0400 Subject: [PATCH] Use device specific API for linked devices updating capabilities. --- .../securesms/jobs/RefreshAttributesJob.java | 20 ++++++++++++++---- .../securesms/jobs/ResetSvrGuessCountJob.kt | 5 +++++ .../securesms/jobs/Svr3MirrorJob.kt | 5 +++++ .../v2/AppRegistrationStorageController.kt | 2 +- .../api/RequestResultExtensions.kt | 21 +++++++++++++++++++ .../signalservice/api/account/AccountApi.kt | 13 ++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index 41c8442e37..92be2b1306 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.net.SignalNetwork; import org.thoughtcrime.securesms.registration.data.RegistrationRepository; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.NetworkResultUtil; +import org.whispersystems.signalservice.api.RequestResultUtil; import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; @@ -95,12 +96,26 @@ public class RefreshAttributesJob extends BaseJob { return; } + SvrValues svrValues = SignalStore.svr(); + + AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut()); + + if (SignalStore.account().isPrimaryDevice()) { + setPrimaryDeviceAttributes(svrValues, capabilities); + } else { + Log.i(TAG, "Linked device, refreshing device capabilities only. Capabilities: " + capabilities); + RequestResultUtil.successOrThrow(SignalNetwork.account().setCapabilities(capabilities)); + } + + hasRefreshedThisAppCycle = true; + } + + private void setPrimaryDeviceAttributes(@NonNull SvrValues svrValues, @NonNull AccountAttributes.Capabilities capabilities) throws IOException { int registrationId = SignalStore.account().getRegistrationId(); boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.settings().getForceWebsocketMode().isEnabled(); byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context); String registrationLockV2 = null; - SvrValues svrValues = SignalStore.svr(); int pniRegistrationId = RegistrationRepository.getPniRegistrationId(); String recoveryPassword = svrValues.getMasterKey().deriveRegistrationRecoveryPassword(); @@ -113,7 +128,6 @@ public class RefreshAttributesJob extends BaseJob { String deviceName = SignalStore.account().getDeviceName(); byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey()); - AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut()); Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", restoredAEP? " + SignalStore.account().restoredAccountEntropyPool() + "\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) + "\n Phone number discoverable : " + phoneNumberDiscoverable + @@ -135,8 +149,6 @@ public class RefreshAttributesJob extends BaseJob { ); NetworkResultUtil.toBasicLegacy(SignalNetwork.account().setAccountAttributes(accountAttributes)); - - hasRefreshedThisAppCycle = true; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResetSvrGuessCountJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResetSvrGuessCountJob.kt index 51a93a7c76..32a621684b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResetSvrGuessCountJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResetSvrGuessCountJob.kt @@ -72,6 +72,11 @@ class ResetSvrGuessCountJob private constructor( override fun getFactoryKey(): String = KEY override fun run(): Result { + if (SignalStore.account.isLinkedDevice) { + Log.i(TAG, "Not primary device. Skipping.") + return Result.success() + } + SvrRepository.operationLock.withLock { val pin = SignalStore.svr.pin diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/Svr3MirrorJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/Svr3MirrorJob.kt index 1de5673240..c836447a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/Svr3MirrorJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/Svr3MirrorJob.kt @@ -52,6 +52,11 @@ class Svr3MirrorJob private constructor(parameters: Parameters, private var seri override fun getFactoryKey(): String = KEY override fun run(): Result { + if (SignalStore.account.isLinkedDevice) { + Log.i(TAG, "Not primary device. Skipping.") + return Result.success() + } + if (!Svr3Migration.shouldWriteToSvr3) { Log.w(TAG, "Writes to SVR3 are disabled. Skipping.") return Result.success() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index e17dbad0ed..1059b8e6ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -260,7 +260,7 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo data.registrationLockEnabled, data.accountEntropyPool.isNotEmpty() ) - } else if (data.pinOptedOut) { + } else if (data.pinOptedOut && data.linkedDeviceData == null) { Log.i(TAG, "[commitRegistrationData] User opted out of creating a PIN. Applying opt-out.") SvrRepository.optOutOfPin(rotateAep = false) } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt index 806bac27bd..27178c6c94 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt @@ -2,11 +2,13 @@ * Copyright 2026 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ +@file:JvmName("RequestResultUtil") package org.whispersystems.signalservice.api import org.signal.core.util.concurrent.safeBlockingGet import org.signal.libsignal.net.RequestResult +import org.signal.network.exceptions.NonSuccessfulResponseCodeException import org.signal.network.exceptions.PushNetworkException import org.signal.network.rest.RestStatusCodeError import org.signal.network.util.JsonUtil @@ -49,6 +51,25 @@ fun SignalWebSocket.fromWebSocketRequest( } } +/** + * Unwraps a [RequestResult] to its success value, or throws an [IOException]. Useful for callers bridging the + * libsignal [RequestResult] surface back into legacy, exception-based code. + * + * All non-2xx responses become a [NonSuccessfulResponseCodeException]. + */ +@Throws(IOException::class) +fun RequestResult.successOrThrow(): T { + return when (this) { + is RequestResult.Success -> result + is RequestResult.RetryableNetworkError -> throw networkError + is RequestResult.NonSuccess -> throw NonSuccessfulResponseCodeException(error.statusCode, "StatusCode: ${error.statusCode}", error.body, error.headers) + is RequestResult.ApplicationError -> throw when (val error = cause) { + is IOException, is RuntimeException -> error + else -> RuntimeException(error) + } + } +} + private fun WebsocketResponse.toRequestResult(clazz: KClass): RequestResult { return if (status < 200 || status > 299) { RequestResult.NonSuccess(RestStatusCodeError(status, headers, body?.toByteArray())) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt index b4ac80da9e..6befea4b8b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt @@ -7,9 +7,11 @@ package org.whispersystems.signalservice.api.account import org.signal.core.util.Base64 import org.signal.core.util.Base64.encodeUrlSafeWithoutPadding +import org.signal.libsignal.net.RequestResult import org.signal.libsignal.usernames.BaseUsernameException import org.signal.libsignal.usernames.Username import org.signal.network.NetworkResult +import org.signal.network.rest.RestStatusCodeError import org.signal.network.websocket.WebSocketRequestMessage import org.signal.network.websocket.delete import org.signal.network.websocket.get @@ -77,6 +79,17 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock return NetworkResult.fromWebSocketRequest(authWebSocket, request) } + /** + * Update the capabilities of the calling device. + * + * PUT /v1/devices/capabilities + * - 200: Success + */ + fun setCapabilities(capabilities: AccountAttributes.Capabilities): RequestResult { + val request = WebSocketRequestMessage.put("/v1/devices/capabilities", capabilities) + return authWebSocket.fromWebSocketRequest(request, Unit::class) + } + /** * PUT /v1/accounts/registration_lock * - 204: Success