From 7f3ceea9fec121c7aa220bab0c5c7048eac4a6bd Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 25 Oct 2024 09:53:17 -0400 Subject: [PATCH] Add initial link+sync support. --- .../securesms/backup/v2/BackupRepository.kt | 17 +- .../securesms/dependencies/AppDependencies.kt | 6 + .../ApplicationDependencyProvider.java | 8 +- .../dependencies/NetworkDependenciesModule.kt | 5 + .../securesms/jobs/BackupMessagesJob.kt | 1 + .../linkdevice/AddLinkDeviceFragment.kt | 19 +- .../securesms/linkdevice/Device.kt | 2 +- .../LinkDeviceFinishedBottomSheet.kt | 14 +- .../linkdevice/LinkDeviceFragment.kt | 208 ++++++++++--- .../linkdevice/LinkDeviceQrScanScreen.kt | 62 ++-- .../linkdevice/LinkDeviceRepository.kt | 282 +++++++++++++++--- .../linkdevice/LinkDeviceSettingsState.kt | 45 ++- .../linkdevice/LinkDeviceViewModel.kt | 262 ++++++++++++---- .../securesms/net/SignalNetwork.kt | 4 + .../securesms/util/RemoteConfig.kt | 8 + app/src/main/res/values/strings.xml | 18 +- .../MockApplicationDependencyProvider.kt | 5 + .../core/util/logging/LoggingExtensions.kt | 46 +++ .../api/SignalServiceAccountManager.java | 6 +- .../api/attachment/AttachmentApi.kt | 12 +- .../signalservice/api/link/LinkDeviceApi.kt | 131 ++++++++ .../LinkedDeviceVerificationCodeResponse.kt | 16 + .../SetLinkedDeviceTransferArchiveRequest.kt | 22 ++ .../api/link/WaitForLinkedDeviceResponse.kt | 18 ++ .../internal/push/DeviceCode.java | 13 - .../internal/push/PushServiceSocket.java | 67 +++-- .../src/main/protowire/Provisioning.proto | 5 +- 27 files changed, 1042 insertions(+), 260 deletions(-) create mode 100644 core-util-jvm/src/main/java/org/signal/core/util/logging/LoggingExtensions.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkedDeviceVerificationCodeResponse.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetLinkedDeviceTransferArchiveRequest.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt delete mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index ac6efcd00d..1956b96449 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -245,19 +245,27 @@ object BackupRepository { } } - fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false, currentTime: Long = System.currentTimeMillis(), cancellationSignal: () -> Boolean = { false }) { + fun export( + outputStream: OutputStream, + append: (ByteArray) -> Unit, + backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(), + plaintext: Boolean = false, + currentTime: Long = System.currentTimeMillis(), + mediaBackupEnabled: Boolean = SignalStore.backup.backsUpMedia, + cancellationSignal: () -> Boolean = { false } + ) { val writer: BackupExportWriter = if (plaintext) { PlainTextBackupWriter(outputStream) } else { EncryptedBackupWriter( - key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(), + key = backupKey, aci = SignalStore.account.aci!!, outputStream = outputStream, append = append ) } - export(currentTime = currentTime, isLocal = false, writer = writer, cancellationSignal = cancellationSignal) + export(currentTime = currentTime, isLocal = false, writer = writer, mediaBackupEnabled = mediaBackupEnabled, cancellationSignal = cancellationSignal) } /** @@ -273,6 +281,7 @@ object BackupRepository { currentTime: Long, isLocal: Boolean, writer: BackupExportWriter, + mediaBackupEnabled: Boolean = SignalStore.backup.backsUpMedia, progressEmitter: ExportProgressListener? = null, cancellationSignal: () -> Boolean = { false }, exportExtras: ((SignalDatabase) -> Unit)? = null @@ -288,7 +297,7 @@ object BackupRepository { val signalStoreSnapshot: SignalStore = createSignalStoreSnapshot(keyValueDbName) eventTimer.emit("store-db-snapshot") - val exportState = ExportState(backupTime = currentTime, mediaBackupEnabled = SignalStore.backup.backsUpMedia) + val exportState = ExportState(backupTime = currentTime, mediaBackupEnabled = mediaBackupEnabled) var frameCount = 0L diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index c588a867a4..abf1a273db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -48,6 +48,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveApi import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi +import org.whispersystems.signalservice.api.link.LinkDeviceApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -294,6 +295,10 @@ object AppDependencies { val attachmentApi: AttachmentApi get() = networkModule.attachmentApi + @JvmStatic + val linkDeviceApi: LinkDeviceApi + get() = networkModule.linkDeviceApi + @JvmStatic val okHttpClient: OkHttpClient get() = networkModule.okHttpClient @@ -356,5 +361,6 @@ object AppDependencies { fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi fun provideKeysApi(pushServiceSocket: PushServiceSocket): KeysApi fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi + fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 2e4dcc68fe..61225c78c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -68,7 +68,6 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.shakereport.ShakeToReport; import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.util.AlarmSleepTimer; -import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.ByteUnit; import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.FrameRateTracker; @@ -87,9 +86,9 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.keys.KeysApi; +import org.whispersystems.signalservice.api.link.LinkDeviceApi; import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.ServiceId.PNI; -import org.whispersystems.signalservice.api.registration.RegistrationApi; import org.whispersystems.signalservice.api.services.CallLinksService; import org.whispersystems.signalservice.api.services.DonationsService; import org.whispersystems.signalservice.api.services.ProfileService; @@ -468,6 +467,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { return new AttachmentApi(signalWebSocket, pushServiceSocket); } + @Override + public @NonNull LinkDeviceApi provideLinkDeviceApi(@NonNull PushServiceSocket pushServiceSocket) { + return new LinkDeviceApi(pushServiceSocket); + } + @VisibleForTesting static class DynamicCredentialsProvider implements CredentialsProvider { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index 834af2ddcb..0c742e68b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -31,6 +31,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveApi import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi +import org.whispersystems.signalservice.api.link.LinkDeviceApi import org.whispersystems.signalservice.api.push.TrustStore import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService @@ -138,6 +139,10 @@ class NetworkDependenciesModule( provider.provideAttachmentApi(signalWebSocket, pushServiceSocket) } + val linkDeviceApi: LinkDeviceApi by lazy { + provider.provideLinkDeviceApi(pushServiceSocket) + } + val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .addInterceptor(StandardUserAgentInterceptor()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt index 68d3eaf8d1..fb388fc5d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt @@ -86,6 +86,7 @@ class BackupMessagesJob private constructor(parameters: Parameters) : Job(parame ArchiveUploadProgress.onMessageBackupCreated() + // TODO [backup] Need to make this resumable FileInputStream(tempBackupFile).use { when (val result = BackupRepository.uploadBackupFile(it, tempBackupFile.length())) { is NetworkResult.Success -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt index e0ef86cc8f..82842c7094 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt @@ -49,7 +49,7 @@ class AddLinkDeviceFragment : ComposeFragment() { viewModel.markIntroSheetSeen() } - if ((state.qrCodeFound || state.qrCodeInvalid) && navController.currentDestination?.id == R.id.linkDeviceIntroBottomSheet) { + if (state.qrCodeState != LinkDeviceSettingsState.QrCodeState.NONE && navController.currentDestination?.id == R.id.linkDeviceIntroBottomSheet) { navController.popBackStack() } @@ -60,14 +60,16 @@ class AddLinkDeviceFragment : ComposeFragment() { onRequestPermissions = { askPermissions() }, onShowFrontCamera = { viewModel.showFrontCamera() }, onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, - onQrCodeApproved = { viewModel.addDevice() }, - onQrCodeDismissed = { viewModel.onQrCodeDismissed() }, - onQrCodeRetry = { viewModel.onQrCodeScanned(state.url) }, - onLinkDeviceSuccess = { - viewModel.onLinkDeviceResult(true) + onQrCodeApproved = { navController.popBackStack() + viewModel.addDevice() }, - onLinkDeviceFailure = { viewModel.onLinkDeviceResult(false) } + onQrCodeDismissed = { viewModel.onQrCodeDismissed() }, + onQrCodeRetry = { viewModel.onQrCodeScanned(state.linkUri.toString()) }, + onLinkDeviceSuccess = { + viewModel.onLinkDeviceResult(showSheet = true) + }, + onLinkDeviceFailure = { viewModel.onLinkDeviceResult(showSheet = false) } ) } @@ -115,8 +117,7 @@ private fun MainScreen( hasPermission = hasPermissions, onRequestPermissions = onRequestPermissions, showFrontCamera = state.showFrontCamera, - qrCodeFound = state.qrCodeFound, - qrCodeInvalid = state.qrCodeInvalid, + qrCodeState = state.qrCodeState, onQrCodeScanned = onQrCodeScanned, onQrCodeAccepted = onQrCodeApproved, onQrCodeDismissed = onQrCodeDismissed, diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt index 509eee6343..c04b0a4fdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/Device.kt @@ -3,4 +3,4 @@ package org.thoughtcrime.securesms.linkdevice /** * Class that represents a linked device */ -data class Device(val id: Long, val name: String?, val createdMillis: Long, val lastSeenMillis: Long) +data class Device(val id: Int, val name: String?, val createdMillis: Long, val lastSeenMillis: Long) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFinishedBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFinishedBottomSheet.kt index b295f17310..c346803c85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFinishedBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFinishedBottomSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons import org.signal.core.ui.Previews @@ -27,9 +28,20 @@ import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment * Bottom sheet dialog prompting users to name their newly linked device */ class LinkDeviceFinishedSheet : ComposeBottomSheetDialogFragment() { + + private val viewModel: LinkDeviceViewModel by activityViewModels() + + override fun onStart() { + super.onStart() + viewModel.onBottomSheetVisible() + } + @Composable override fun SheetContent() { - FinishedSheet(this::dismissAllowingStateLoss) + FinishedSheet { + viewModel.onBottomSheetDismissed() + this.dismissAllowingStateLoss() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index 0013c2fc74..0bc18697f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.BiometricDeviceAuthentication import org.thoughtcrime.securesms.BiometricDeviceLockContract import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.navigation.safeNavigate import java.util.Locale @@ -89,7 +90,7 @@ class LinkDeviceFragment : ComposeFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.initialize(requireContext()) + viewModel.initialize() biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int -> if (result == BiometricDeviceAuthentication.AUTHENTICATED) { @@ -118,18 +119,37 @@ class LinkDeviceFragment : ComposeFragment() { override fun FragmentContent() { val state by viewModel.state.collectAsState() val navController: NavController by remember { mutableStateOf(findNavController()) } + val context = LocalContext.current - LaunchedEffect(state.toastDialog) { - if (state.toastDialog.isNotEmpty()) { - Toast.makeText(requireContext(), state.toastDialog, Toast.LENGTH_LONG).show() - viewModel.clearToast() + LaunchedEffect(state.oneTimeEvent) { + when (val event = state.oneTimeEvent) { + LinkDeviceSettingsState.OneTimeEvent.None -> { + Unit + } + is LinkDeviceSettingsState.OneTimeEvent.ToastLinked -> { + Toast.makeText(context, context.getString(R.string.LinkDeviceFragment__s_linked, event.name), Toast.LENGTH_LONG).show() + } + is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked -> { + Toast.makeText(context, context.getString(R.string.LinkDeviceFragment__s_unlinked, event.name), Toast.LENGTH_LONG).show() + } + LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> { + Toast.makeText(context, context.getString(R.string.DeviceListActivity_network_failed), Toast.LENGTH_LONG).show() + } + LinkDeviceSettingsState.OneTimeEvent.LaunchQrCodeScanner -> { + navController.navigateToQrScannerIfAuthed() + } + LinkDeviceSettingsState.OneTimeEvent.ShowFinishedSheet -> { + navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceFinishedSheet) + } + LinkDeviceSettingsState.OneTimeEvent.HideFinishedSheet -> { + if (navController.currentDestination?.id == R.id.linkDeviceFinishedSheet) { + navController.popBackStack() + } + } } - } - LaunchedEffect(state.showFinishedSheet) { - if (state.showFinishedSheet) { - navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceFinishedSheet) - viewModel.markFinishedSheetSeen() + if (state.oneTimeEvent != LinkDeviceSettingsState.OneTimeEvent.None) { + viewModel.clearOneTimeEvent() } } @@ -148,24 +168,27 @@ class LinkDeviceFragment : ComposeFragment() { navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24), navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close) ) { contentPadding: PaddingValues -> - DeviceDescriptionScreen( + DeviceListScreen( state = state, - navController = navController, modifier = Modifier.padding(contentPadding), - onLearnMore = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) }, - onLinkDevice = { - if (biometricAuth.canAuthenticate(requireContext())) { - navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceEducationSheet) - } else { - navController.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) - } - }, - setDeviceToRemove = { device -> viewModel.setDeviceToRemove(device) }, - onRemoveDevice = { device -> viewModel.removeDevice(requireContext(), device) } + onLearnMoreClicked = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) }, + onLinkNewDeviceClicked = { navController.navigateToQrScannerIfAuthed() }, + onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) }, + onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) }, + onSyncFailureRetryRequested = { deviceId -> viewModel.onSyncErrorRetryRequested(deviceId) }, + onSyncFailureIgnored = { viewModel.onSyncErrorIgnored() } ) } } + private fun NavController.navigateToQrScannerIfAuthed() { + if (biometricAuth.canAuthenticate(requireContext())) { + this.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceEducationSheet) + } else { + this.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) + } + } + private fun NavController.popOrFinish() { if (!popBackStack()) { requireActivity().finishAfterTransition() @@ -190,23 +213,51 @@ class LinkDeviceFragment : ComposeFragment() { } @Composable -fun DeviceDescriptionScreen( +fun DeviceListScreen( state: LinkDeviceSettingsState, - navController: NavController? = null, modifier: Modifier = Modifier, - onLearnMore: () -> Unit = {}, - onLinkDevice: () -> Unit = {}, - setDeviceToRemove: (Device?) -> Unit = {}, - onRemoveDevice: (Device) -> Unit = {} + onLearnMoreClicked: () -> Unit = {}, + onLinkNewDeviceClicked: () -> Unit = {}, + onDeviceSelectedForRemoval: (Device?) -> Unit = {}, + onDeviceRemovalConfirmed: (Device) -> Unit = {}, + onSyncFailureRetryRequested: (Int?) -> Unit = {}, + onSyncFailureIgnored: () -> Unit = {} ) { - if (state.progressDialogMessage != -1 && state.progressDialogMessage != R.string.LinkDeviceFragment__loading) { - if (navController?.currentDestination?.id == R.id.linkDeviceFinishedSheet && - state.progressDialogMessage == R.string.LinkDeviceFragment__linking_device - ) { - navController.popBackStack() + // If a bottom sheet is showing, we don't want the spinner underneath + if (!state.bottomSheetVisible) { + when (state.dialogState) { + DialogState.None -> { + Unit + } + DialogState.Linking -> { + Dialogs.IndeterminateProgressDialog(stringResource(id = R.string.LinkDeviceFragment__linking_device)) + } + DialogState.Unlinking -> { + Dialogs.IndeterminateProgressDialog(stringResource(id = R.string.DeviceListActivity_unlinking_device)) + } + DialogState.SyncingMessages -> { + Dialogs.IndeterminateProgressDialog(stringResource(id = R.string.LinkDeviceFragment__syncing_messages)) + } + is DialogState.SyncingFailed, + DialogState.SyncingTimedOut -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.LinkDeviceFragment__sync_failure_title), + body = stringResource(R.string.LinkDeviceFragment__sync_failure_body), + confirm = stringResource(R.string.LinkDeviceFragment__sync_failure_retry_button), + onConfirm = { + if (state.dialogState is DialogState.SyncingFailed) { + onSyncFailureRetryRequested(state.dialogState.deviceId) + } else { + onSyncFailureRetryRequested(null) + } + }, + dismiss = stringResource(R.string.LinkDeviceFragment__sync_failure_dismiss_button), + onDismiss = onSyncFailureIgnored + ) + } } - Dialogs.IndeterminateProgressDialog(stringResource(id = state.progressDialogMessage)) } + if (state.deviceToRemove != null) { val device: Device = state.deviceToRemove val name = if (device.name.isNullOrEmpty()) stringResource(R.string.DeviceListItem_unnamed_device) else device.name @@ -215,8 +266,8 @@ fun DeviceDescriptionScreen( body = stringResource(id = R.string.DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive), confirm = stringResource(R.string.LinkDeviceFragment__unlink), dismiss = stringResource(android.R.string.cancel), - onConfirm = { onRemoveDevice(device) }, - onDismiss = { setDeviceToRemove(null) } + onConfirm = { onDeviceRemovalConfirmed(device) }, + onDismiss = { onDeviceSelectedForRemoval(null) } ) } @@ -235,13 +286,13 @@ fun DeviceDescriptionScreen( text = AnnotatedString(stringResource(id = R.string.LearnMoreTextView_learn_more)), style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary) ) { - onLearnMore() + onLearnMoreClicked() } Spacer(modifier = Modifier.size(20.dp)) Buttons.LargeTonal( - onClick = onLinkDevice, + onClick = onLinkNewDeviceClicked, modifier = Modifier.defaultMinSize(300.dp).padding(bottom = 8.dp) ) { Text(stringResource(id = R.string.LinkDeviceFragment__link_a_new_device)) @@ -255,7 +306,7 @@ fun DeviceDescriptionScreen( style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 12.dp) ) - if (state.progressDialogMessage == R.string.LinkDeviceFragment__loading) { + if (state.deviceListLoading) { Spacer(modifier = Modifier.size(30.dp)) CircularProgressIndicator( modifier = Modifier @@ -277,7 +328,7 @@ fun DeviceDescriptionScreen( ) } else { state.devices.forEach { device -> - DeviceRow(device, setDeviceToRemove) + DeviceRow(device, onDeviceSelectedForRemoval) } } } @@ -356,14 +407,75 @@ fun DeviceRow(device: Device, setDeviceToRemove: (Device) -> Unit) { @SignalPreview @Composable -private fun DeviceScreenPreview() { - val previewDevices = listOf( - Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000), - Device(1, "Sam's iPad", 1715793182000, 1716053122000) - ) - val previewState = LinkDeviceSettingsState(devices = previewDevices) - +private fun DeviceListScreenPreview() { Previews.Preview { - DeviceDescriptionScreen(previewState) + DeviceListScreen( + state = LinkDeviceSettingsState( + devices = listOf( + Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000), + Device(1, "Sam's iPad", 1715793182000, 1716053122000) + ) + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenLoadingPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + deviceListLoading = true + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenLinkingPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.Linking + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenUnlinkingPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.Unlinking + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenSyncingMessagesPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.SyncingMessages + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenSyncingFailedPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.SyncingTimedOut + ) + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt index f9418c36e8..f4f68be868 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import org.signal.core.ui.Dialogs import org.signal.qr.QrScannerView import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist import org.thoughtcrime.securesms.qr.QrScanScreens import java.util.concurrent.TimeUnit @@ -27,13 +28,12 @@ fun LinkDeviceQrScanScreen( hasPermission: Boolean, onRequestPermissions: () -> Unit, showFrontCamera: Boolean?, - qrCodeFound: Boolean, - qrCodeInvalid: Boolean, + qrCodeState: LinkDeviceSettingsState.QrCodeState, onQrCodeScanned: (String) -> Unit, onQrCodeAccepted: () -> Unit, onQrCodeDismissed: () -> Unit, onQrCodeRetry: () -> Unit, - linkDeviceResult: LinkDeviceRepository.LinkDeviceResult, + linkDeviceResult: LinkDeviceResult, onLinkDeviceSuccess: () -> Unit, onLinkDeviceFailure: () -> Unit, modifier: Modifier = Modifier @@ -41,35 +41,41 @@ fun LinkDeviceQrScanScreen( val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current - if (qrCodeFound) { - Dialogs.SimpleAlertDialog( - title = stringResource(id = R.string.DeviceProvisioningActivity_link_this_device), - body = stringResource(id = R.string.AddLinkDeviceFragment__this_device_will_see_your_groups_contacts), - confirm = stringResource(id = R.string.device_list_fragment__link_new_device), - onConfirm = onQrCodeAccepted, - dismiss = stringResource(id = android.R.string.cancel), - onDismiss = onQrCodeDismissed - ) - } else if (qrCodeInvalid) { - Dialogs.SimpleAlertDialog( - title = stringResource(id = R.string.AddLinkDeviceFragment__linking_device_failed), - body = stringResource(id = R.string.AddLinkDeviceFragment__this_qr_code_not_valid), - confirm = stringResource(id = R.string.AddLinkDeviceFragment__retry), - onConfirm = onQrCodeRetry, - dismiss = stringResource(id = android.R.string.cancel), - onDismiss = onQrCodeDismissed - ) + when (qrCodeState) { + LinkDeviceSettingsState.QrCodeState.NONE -> { + Unit + } + LinkDeviceSettingsState.QrCodeState.VALID -> { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.DeviceProvisioningActivity_link_this_device), + body = stringResource(id = R.string.AddLinkDeviceFragment__this_device_will_see_your_groups_contacts), + confirm = stringResource(id = R.string.device_list_fragment__link_new_device), + onConfirm = onQrCodeAccepted, + dismiss = stringResource(id = android.R.string.cancel), + onDismiss = onQrCodeDismissed + ) + } + LinkDeviceSettingsState.QrCodeState.INVALID -> { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.AddLinkDeviceFragment__linking_device_failed), + body = stringResource(id = R.string.AddLinkDeviceFragment__this_qr_code_not_valid), + confirm = stringResource(id = R.string.AddLinkDeviceFragment__retry), + onConfirm = onQrCodeRetry, + dismiss = stringResource(id = android.R.string.cancel), + onDismiss = onQrCodeDismissed + ) + } } LaunchedEffect(linkDeviceResult) { when (linkDeviceResult) { - LinkDeviceRepository.LinkDeviceResult.SUCCESS -> onLinkDeviceSuccess() - LinkDeviceRepository.LinkDeviceResult.NO_DEVICE -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_no_device, onLinkDeviceFailure) - LinkDeviceRepository.LinkDeviceResult.NETWORK_ERROR -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_network_error, onLinkDeviceFailure) - LinkDeviceRepository.LinkDeviceResult.KEY_ERROR -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_key_error, onLinkDeviceFailure) - LinkDeviceRepository.LinkDeviceResult.LIMIT_EXCEEDED -> makeToast(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already, onLinkDeviceFailure) - LinkDeviceRepository.LinkDeviceResult.BAD_CODE -> makeToast(context, R.string.DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code, onLinkDeviceFailure) - LinkDeviceRepository.LinkDeviceResult.UNKNOWN -> Unit + is LinkDeviceResult.Success -> onLinkDeviceSuccess() + is LinkDeviceResult.NoDevice -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_no_device, onLinkDeviceFailure) + is LinkDeviceResult.NetworkError -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_network_error, onLinkDeviceFailure) + is LinkDeviceResult.KeyError -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_key_error, onLinkDeviceFailure) + is LinkDeviceResult.LimitExceeded -> makeToast(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already, onLinkDeviceFailure) + is LinkDeviceResult.BadCode -> makeToast(context, R.string.DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code, onLinkDeviceFailure) + is LinkDeviceResult.None -> Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index 5ca5afc81e..8b7b8e5ec0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -1,23 +1,36 @@ package org.thoughtcrime.securesms.linkdevice import android.net.Uri -import org.signal.core.util.Base64.decode +import org.signal.core.util.Base64 +import org.signal.core.util.Stopwatch import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log +import org.signal.core.util.logging.logW import org.signal.libsignal.protocol.ecc.Curve +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.devicelist.protos.DeviceName import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.backup.BackupKey +import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse +import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo import org.whispersystems.signalservice.api.push.SignalServiceAddress -import org.whispersystems.signalservice.api.push.exceptions.NotFoundException -import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException +import org.whispersystems.signalservice.internal.push.AttachmentUploadForm +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.io.IOException import java.security.InvalidKeyException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds /** * Repository for linked devices and its various actions (linking, unlinking, listing). @@ -26,7 +39,7 @@ object LinkDeviceRepository { private val TAG = Log.tag(LinkDeviceRepository::class) - fun removeDevice(deviceId: Long): Boolean { + fun removeDevice(deviceId: Int): Boolean { return try { val accountManager = AppDependencies.signalServiceAccountManager accountManager.removeDevice(deviceId) @@ -53,15 +66,25 @@ object LinkDeviceRepository { } } + fun WaitForLinkedDeviceResponse.getPlaintextDeviceName(): String { + val response = this + return DeviceInfo().apply { + id = response.id + name = response.name + created = response.created + lastSeen = response.lastSeen + }.toDevice().name ?: "" + } + private fun DeviceInfo.toDevice(): Device { - val defaultDevice = Device(getId().toLong(), getName(), getCreated(), getLastSeen()) + val defaultDevice = Device(getId(), getName(), getCreated(), getLastSeen()) try { if (getName().isNullOrEmpty() || getName().length < 4) { Log.w(TAG, "Invalid DeviceInfo name.") return defaultDevice } - val deviceName = DeviceName.ADAPTER.decode(decode(getName())) + val deviceName = DeviceName.ADAPTER.decode(Base64.decode(getName())) if (deviceName.ciphertext == null || deviceName.ephemeralPublic == null || deviceName.syntheticIv == null) { Log.w(TAG, "Got a DeviceName that wasn't properly populated.") return defaultDevice @@ -73,7 +96,7 @@ object LinkDeviceRepository { return defaultDevice } - return Device(getId().toLong(), String(plaintext), getCreated(), getLastSeen()) + return Device(getId(), String(plaintext), getCreated(), getLastSeen()) } catch (e: Exception) { Log.w(TAG, "Failed while reading the protobuf.", e) } @@ -90,42 +113,225 @@ object LinkDeviceRepository { return ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank() } - fun addDevice(uri: Uri): LinkDeviceResult { - return try { - val accountManager = AppDependencies.signalServiceAccountManager - val verificationCode = accountManager.getNewDeviceVerificationCode() - if (!isValidQr(uri)) { - LinkDeviceResult.BAD_CODE - } else { - val ephemeralId: String? = uri.getQueryParameter("uuid") - val publicKeyEncoded: String? = uri.getQueryParameter("pub_key") - val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0) - val aciIdentityKeyPair = SignalStore.account.aciIdentityKey - val pniIdentityKeyPair = SignalStore.account.pniIdentityKey - val profileKey = ProfileKeyUtil.getSelfProfileKey() + /** + * Adds a linked device to the account. + * + * @param ephemeralBackupKey An ephemeral key to provide the linked device to sync existing message content. Do not set if link+sync is unsupported. + */ + fun addDevice(uri: Uri, ephemeralBackupKey: BackupKey?): LinkDeviceResult { + if (!isValidQr(uri)) { + Log.w(TAG, "Bad URI! $uri") + return LinkDeviceResult.BadCode + } - accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, SignalStore.svr.getOrCreateMasterKey(), verificationCode) - TextSecurePreferences.setMultiDevice(AppDependencies.application, true) - LinkDeviceResult.SUCCESS + val verificationCodeResult: LinkedDeviceVerificationCodeResponse = when (val result = SignalNetwork.linkDevice.getDeviceVerificationCode()) { + is NetworkResult.Success -> result.result + is NetworkResult.ApplicationError -> throw result.throwable + is NetworkResult.NetworkError -> return LinkDeviceResult.NetworkError + is NetworkResult.StatusCodeError -> { + return when (result.code) { + 411 -> LinkDeviceResult.LimitExceeded + 429 -> LinkDeviceResult.NetworkError + else -> LinkDeviceResult.NetworkError + } } - } catch (e: NotFoundException) { - LinkDeviceResult.NO_DEVICE - } catch (e: DeviceLimitExceededException) { - LinkDeviceResult.LIMIT_EXCEEDED - } catch (e: IOException) { - LinkDeviceResult.NETWORK_ERROR + } + + val ephemeralId: String = uri.getQueryParameter("uuid") ?: return LinkDeviceResult.BadCode + val publicKey = try { + val publicKeyEncoded: String = uri.getQueryParameter("pub_key") ?: return LinkDeviceResult.BadCode + Curve.decodePoint(Base64.decode(publicKeyEncoded), 0) } catch (e: InvalidKeyException) { - LinkDeviceResult.KEY_ERROR + return LinkDeviceResult.KeyError + } + + val deviceLinkResult = SignalNetwork.linkDevice.linkDevice( + e164 = SignalStore.account.e164!!, + aci = SignalStore.account.aci!!, + pni = SignalStore.account.pni!!, + deviceIdentifier = ephemeralId, + deviceKey = publicKey, + aciIdentityKeyPair = SignalStore.account.aciIdentityKey, + pniIdentityKeyPair = SignalStore.account.pniIdentityKey, + profileKey = ProfileKeyUtil.getSelfProfileKey(), + masterKey = SignalStore.svr.getOrCreateMasterKey(), + code = verificationCodeResult.verificationCode, + ephemeralBackupKey = ephemeralBackupKey + ) + + return when (deviceLinkResult) { + is NetworkResult.Success -> { + TextSecurePreferences.setMultiDevice(AppDependencies.application, true) + LinkDeviceResult.Success(verificationCodeResult.tokenIdentifier) + } + is NetworkResult.ApplicationError -> throw deviceLinkResult.throwable + is NetworkResult.NetworkError -> LinkDeviceResult.NetworkError + is NetworkResult.StatusCodeError -> { + when (deviceLinkResult.code) { + 403 -> LinkDeviceResult.NoDevice + 409 -> LinkDeviceResult.NoDevice + 411 -> LinkDeviceResult.LimitExceeded + 422 -> LinkDeviceResult.NetworkError + 429 -> LinkDeviceResult.NetworkError + else -> LinkDeviceResult.NetworkError + } + } } } - enum class LinkDeviceResult { - SUCCESS, - NO_DEVICE, - NETWORK_ERROR, - KEY_ERROR, - LIMIT_EXCEEDED, - BAD_CODE, - UNKNOWN + /** + * Waits up to the specified [maxWaitTime] for a device with the given [token] to be linked. + * + * @param token Comes from [LinkDeviceResult.Success] + */ + fun waitForDeviceToBeLinked(token: String, maxWaitTime: Duration): WaitForLinkedDeviceResponse? { + val startTime = System.currentTimeMillis() + var timeRemaining = maxWaitTime.inWholeMilliseconds + + while (timeRemaining > 0) { + Log.d(TAG, "[waitForDeviceToBeLinked] Willing to wait for $timeRemaining ms...") + val result = SignalNetwork.linkDevice.waitForLinkedDevice( + token = token, + timeoutSeconds = timeRemaining.milliseconds.inWholeSeconds.toInt() + ) + + when (result) { + is NetworkResult.Success -> { + return result.result + } + is NetworkResult.ApplicationError -> { + throw result.throwable + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "[waitForDeviceToBeLinked] Hit a network error while waiting for linking. Will try to wait again.", result.exception) + } + is NetworkResult.StatusCodeError -> { + when (result.code) { + 400 -> { + Log.w(TAG, "[waitForDeviceToBeLinked] Invalid token/timeout!") + return null + } + 429 -> { + Log.w(TAG, "[waitForDeviceToBeLinked] Hit a rate-limit. Will try to wait again.") + } + } + } + } + + timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime) + } + + Log.w(TAG, "[waitForDeviceToBeLinked] No linked device found in ${System.currentTimeMillis() - startTime} ms. Bailing!") + return null + } + + /** + * Performs the entire process of creating and uploading an archive for a newly-linked device. + */ + fun createAndUploadArchive(ephemeralBackupKey: BackupKey, deviceId: Int, deviceCreatedAt: Long): LinkUploadArchiveResult { + val stopwatch = Stopwatch("link-archive") + val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) + val outputStream = FileOutputStream(tempBackupFile) + + try { + BackupRepository.export(outputStream = outputStream, append = { tempBackupFile.appendBytes(it) }, backupKey = ephemeralBackupKey, mediaBackupEnabled = false) + } catch (e: Exception) { + return LinkUploadArchiveResult.BackupCreationFailure(e) + } + stopwatch.split("create-backup") + + val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm()) { + is NetworkResult.Success -> result.result + is NetworkResult.ApplicationError -> throw result.throwable + is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "Network error when fetching form.", result.exception) + is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "Status code error when fetching form.", result.exception) + } + + when (val result = uploadArchive(tempBackupFile, uploadForm)) { + is NetworkResult.Success -> Log.i(TAG, "Successfully uploaded backup.") + is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "Network error when uploading archive.", result.exception) + is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "Status code error when uploading archive.", result.exception) + is NetworkResult.ApplicationError -> throw result.throwable + } + stopwatch.split("upload-backup") + + val transferSetResult = SignalNetwork.linkDevice.setTransferArchive( + destinationDeviceId = deviceId, + destinationDeviceCreated = deviceCreatedAt, + cdn = uploadForm.cdn, + cdnKey = uploadForm.key + ) + + when (transferSetResult) { + is NetworkResult.Success -> Log.i(TAG, "Successfully set transfer archive.") + is NetworkResult.ApplicationError -> throw transferSetResult.throwable + is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(transferSetResult.exception).logW(TAG, "Network error when setting transfer archive.", transferSetResult.exception) + is NetworkResult.StatusCodeError -> { + return when (transferSetResult.code) { + 422 -> LinkUploadArchiveResult.BadRequest(transferSetResult.exception).logW(TAG, "422 when setting transfer archive.", transferSetResult.exception) + else -> LinkUploadArchiveResult.NetworkError(transferSetResult.exception).logW(TAG, "Status code error when setting transfer archive.", transferSetResult.exception) + } + } + } + stopwatch.split("transfer-set") + stopwatch.stop(TAG) + + return LinkUploadArchiveResult.Success + } + + /** + * Handles uploading the archive for [createAndUploadArchive]. Handles resumable uploads and making multiple upload attempts. + */ + private fun uploadArchive(backupFile: File, uploadForm: AttachmentUploadForm): NetworkResult { + val resumableUploadUrl = when (val result = SignalNetwork.attachments.getResumableUploadUrl(uploadForm)) { + is NetworkResult.Success -> result.result + is NetworkResult.NetworkError -> return result.map { Unit }.logW(TAG, "Network error when fetching upload URL.", result.exception) + is NetworkResult.StatusCodeError -> return result.map { Unit }.logW(TAG, "Status code error when fetching upload URL.", result.exception) + is NetworkResult.ApplicationError -> throw result.throwable + } + + val maxRetries = 5 + var attemptCount = 0 + + while (attemptCount < maxRetries) { + Log.i(TAG, "Starting upload attempt ${attemptCount + 1}/$maxRetries") + val uploadResult = FileInputStream(backupFile).use { + SignalNetwork.attachments.uploadPreEncryptedFileToAttachmentV4( + uploadForm = uploadForm, + resumableUploadUrl = resumableUploadUrl, + inputStream = backupFile.inputStream(), + inputStreamLength = backupFile.length() + ) + } + + when (uploadResult) { + is NetworkResult.Success -> return uploadResult + is NetworkResult.NetworkError -> Log.w(TAG, "Hit network error while uploading. May retry.", uploadResult.exception) + is NetworkResult.StatusCodeError -> return uploadResult.logW(TAG, "Status code error when uploading archive.", uploadResult.exception) + is NetworkResult.ApplicationError -> throw uploadResult.throwable + } + + attemptCount++ + } + + Log.w(TAG, "Hit the max retry count of $maxRetries. Failing.") + return NetworkResult.NetworkError(IOException("Hit max retries!")) + } + + sealed interface LinkDeviceResult { + data object None : LinkDeviceResult + data class Success(val token: String) : LinkDeviceResult + data object NoDevice : LinkDeviceResult + data object NetworkError : LinkDeviceResult + data object KeyError : LinkDeviceResult + data object LimitExceeded : LinkDeviceResult + data object BadCode : LinkDeviceResult + } + + sealed interface LinkUploadArchiveResult { + data object Success : LinkUploadArchiveResult + data class BackupCreationFailure(val exception: Exception) : LinkUploadArchiveResult + data class BadRequest(val exception: IOException) : LinkUploadArchiveResult + data class NetworkError(val exception: IOException) : LinkUploadArchiveResult } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index ba840b7ecd..bea16668b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.linkdevice -import androidx.annotation.StringRes +import android.net.Uri +import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult /** * Information about linked devices. Used in [LinkDeviceViewModel]. @@ -8,15 +9,37 @@ import androidx.annotation.StringRes data class LinkDeviceSettingsState( val devices: List = emptyList(), val deviceToRemove: Device? = null, - @StringRes val progressDialogMessage: Int = -1, - val toastDialog: String = "", + val dialogState: DialogState = DialogState.None, + val deviceListLoading: Boolean = false, + val oneTimeEvent: OneTimeEvent = OneTimeEvent.None, val showFrontCamera: Boolean? = null, - val qrCodeFound: Boolean = false, - val qrCodeInvalid: Boolean = false, - val url: String = "", - val linkDeviceResult: LinkDeviceRepository.LinkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN, - val showFinishedSheet: Boolean = false, + val qrCodeState: QrCodeState = QrCodeState.NONE, + val linkUri: Uri? = null, + val linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None, val seenIntroSheet: Boolean = false, - val pendingNewDevice: Boolean = false, - val seenEducationSheet: Boolean = false -) + val seenEducationSheet: Boolean = false, + val bottomSheetVisible: Boolean = false +) { + sealed interface DialogState { + data object None : DialogState + data object Linking : DialogState + data object Unlinking : DialogState + data object SyncingMessages : DialogState + data object SyncingTimedOut : DialogState + data class SyncingFailed(val deviceId: Int) : DialogState + } + + sealed interface OneTimeEvent { + data object None : OneTimeEvent + data object ToastNetworkFailed : OneTimeEvent + data class ToastUnlinked(val name: String) : OneTimeEvent + data class ToastLinked(val name: String) : OneTimeEvent + data object ShowFinishedSheet : OneTimeEvent + data object HideFinishedSheet : OneTimeEvent + data object LaunchQrCodeScanner : OneTimeEvent + } + + enum class QrCodeState { + NONE, VALID, INVALID + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 7eaadc258e..a52dfcfceb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.linkdevice -import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -9,34 +8,37 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.thoughtcrime.securesms.R +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob -import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob +import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult +import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.getPlaintextDeviceName +import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState +import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.OneTimeEvent +import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.QrCodeState +import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.backup.BackupKey +import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse +import kotlin.time.Duration.Companion.seconds /** * Maintains the state of the [LinkDeviceFragment] */ class LinkDeviceViewModel : ViewModel() { + companion object { + val TAG = Log.tag(LinkDeviceViewModel::class) + } + private val _state = MutableStateFlow(LinkDeviceSettingsState()) val state = _state.asStateFlow() private lateinit var listener: JobTracker.JobListener - fun initialize(context: Context) { - listener = JobTracker.JobListener { _, jobState -> - if (jobState.isComplete) { - loadDevices(context = context, isPotentialNewDevice = true) - } - } - AppDependencies.jobManager.addListener( - { job: Job -> job.parameters.queue?.startsWith(MultiDeviceConfigurationUpdateJob.QUEUE) ?: false }, - listener - ) - loadDevices(context) + fun initialize() { + loadDevices() } override fun onCleared() { @@ -48,47 +50,48 @@ class LinkDeviceViewModel : ViewModel() { _state.update { it.copy(deviceToRemove = device) } } - fun removeDevice(context: Context, device: Device) { + fun removeDevice(device: Device) { viewModelScope.launch(Dispatchers.IO) { - _state.update { it.copy(progressDialogMessage = R.string.DeviceListActivity_unlinking_device) } + _state.update { it.copy(dialogState = DialogState.Unlinking) } val success = LinkDeviceRepository.removeDevice(device.id) if (success) { - loadDevices(context) + loadDevices() _state.value = _state.value.copy( - toastDialog = context.getString(R.string.LinkDeviceFragment__s_unlinked, device.name), - progressDialogMessage = -1 + oneTimeEvent = OneTimeEvent.ToastUnlinked(device.name ?: ""), + dialogState = DialogState.None, + deviceToRemove = null ) } else { _state.update { - it.copy(progressDialogMessage = -1) + it.copy( + dialogState = DialogState.None, + deviceToRemove = null + ) } } } } - private fun loadDevices(context: Context, isPotentialNewDevice: Boolean = false) { - if (isPotentialNewDevice && !_state.value.pendingNewDevice) { - return - } + private fun loadDevices() { _state.value = _state.value.copy( - progressDialogMessage = if (isPotentialNewDevice) R.string.LinkDeviceFragment__linking_device else R.string.LinkDeviceFragment__loading, - pendingNewDevice = if (isPotentialNewDevice) false else _state.value.pendingNewDevice, + deviceListLoading = true, showFrontCamera = null ) + viewModelScope.launch(Dispatchers.IO) { val devices = LinkDeviceRepository.loadDevices() if (devices == null) { _state.value = _state.value.copy( - toastDialog = context.getString(R.string.DeviceListActivity_network_failed), - progressDialogMessage = -1 + oneTimeEvent = OneTimeEvent.ToastNetworkFailed, + deviceListLoading = false ) } else { _state.update { it.copy( - toastDialog = if (isPotentialNewDevice) context.getString(R.string.LinkDeviceFragment__device_approved) else "", + oneTimeEvent = OneTimeEvent.None, devices = devices, - progressDialogMessage = -1 + deviceListLoading = false ) } } @@ -114,7 +117,7 @@ class LinkDeviceViewModel : ViewModel() { } fun onQrCodeScanned(url: String) { - if (_state.value.qrCodeFound || _state.value.qrCodeInvalid) { + if (_state.value.qrCodeState != QrCodeState.NONE) { return } @@ -122,18 +125,16 @@ class LinkDeviceViewModel : ViewModel() { if (LinkDeviceRepository.isValidQr(uri)) { _state.update { it.copy( - qrCodeFound = true, - qrCodeInvalid = false, - url = url, + qrCodeState = QrCodeState.VALID, + linkUri = uri, showFrontCamera = null ) } } else { _state.update { it.copy( - qrCodeFound = false, - qrCodeInvalid = true, - url = url, + qrCodeState = QrCodeState.INVALID, + linkUri = uri, showFrontCamera = null ) } @@ -143,59 +144,188 @@ class LinkDeviceViewModel : ViewModel() { fun onQrCodeDismissed() { _state.update { it.copy( - qrCodeFound = false, - qrCodeInvalid = false + qrCodeState = QrCodeState.NONE ) } } - fun addDevice() { - val uri = Uri.parse(_state.value.url) - viewModelScope.launch(Dispatchers.IO) { - val result = LinkDeviceRepository.addDevice(uri) - _state.update { - it.copy( - qrCodeFound = false, - qrCodeInvalid = false, - linkDeviceResult = result, - url = "" - ) - } - LinkedDeviceInactiveCheckJob.enqueue() + fun addDevice() = viewModelScope.launch(Dispatchers.IO) { + val linkUri: Uri = _state.value.linkUri!! + + _state.update { + it.copy( + qrCodeState = QrCodeState.NONE, + linkUri = null, + dialogState = DialogState.Linking + ) + } + + if (linkUri.supportsLinkAndSync() && RemoteConfig.linkAndSync) { + Log.i(TAG, "Link+Sync supported.") + addDeviceWithSync(linkUri) + } else { + Log.i(TAG, "Link+Sync not supported. (uri: ${linkUri.supportsLinkAndSync()}, remoteConfig: ${RemoteConfig.linkAndSync})") + addDeviceWithoutSync(linkUri) } } fun onLinkDeviceResult(showSheet: Boolean) { _state.update { it.copy( - showFinishedSheet = showSheet, - linkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN, - toastDialog = "", - pendingNewDevice = true + linkDeviceResult = LinkDeviceResult.None, + oneTimeEvent = if (showSheet) { + OneTimeEvent.ShowFinishedSheet + } else { + OneTimeEvent.None + } ) } } - fun markFinishedSheetSeen() { + fun onBottomSheetVisible() { _state.update { - it.copy( - showFinishedSheet = false - ) + it.copy(bottomSheetVisible = true) } } - fun clearToast() { + fun onBottomSheetDismissed() { _state.update { - it.copy( - toastDialog = "" - ) + it.copy(bottomSheetVisible = false) + } + } + + fun clearOneTimeEvent() { + _state.update { + it.copy(oneTimeEvent = OneTimeEvent.None) } } fun markEducationSheetSeen(seen: Boolean) { + _state.update { + it.copy(seenEducationSheet = seen) + } + } + + private fun addDeviceWithSync(linkUri: Uri) { + val ephemeralBackupKey = BackupKey(Util.getSecretBytes(32)) + val result = LinkDeviceRepository.addDevice(linkUri, ephemeralBackupKey) + _state.update { it.copy( - seenEducationSheet = seen + linkDeviceResult = result, + qrCodeState = QrCodeState.NONE, + linkUri = null + ) + } + + if (result !is LinkDeviceResult.Success) { + return + } + + Log.i(TAG, "Waiting for a new linked device...") + val waitResult: WaitForLinkedDeviceResponse? = LinkDeviceRepository.waitForDeviceToBeLinked(result.token, maxWaitTime = 60.seconds) + if (waitResult == null) { + Log.i(TAG, "No linked device found!") + _state.update { + it.copy( + dialogState = DialogState.SyncingTimedOut + ) + } + return + } + + Log.i(TAG, "Found a linked device!") + + _state.update { + it.copy( + linkDeviceResult = result, + dialogState = DialogState.SyncingMessages + ) + } + + Log.i(TAG, "Beginning the archive generation process...") + val uploadResult = LinkDeviceRepository.createAndUploadArchive(ephemeralBackupKey, waitResult.id, waitResult.created) + when (uploadResult) { + LinkDeviceRepository.LinkUploadArchiveResult.Success -> { + _state.update { + it.copy( + oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDeviceName()), + dialogState = DialogState.None + ) + } + } + is LinkDeviceRepository.LinkUploadArchiveResult.BackupCreationFailure, + is LinkDeviceRepository.LinkUploadArchiveResult.BadRequest, + is LinkDeviceRepository.LinkUploadArchiveResult.NetworkError -> { + _state.update { + it.copy( + dialogState = DialogState.SyncingFailed(waitResult.id) + ) + } + } + } + } + + private fun addDeviceWithoutSync(linkUri: Uri) { + val result = LinkDeviceRepository.addDevice(linkUri, ephemeralBackupKey = null) + + _state.update { + it.copy( + linkDeviceResult = result, + qrCodeState = QrCodeState.NONE, + linkUri = null + ) + } + + if (result !is LinkDeviceResult.Success) { + return + } + + Log.i(TAG, "Waiting for a new linked device...") + val waitResult: WaitForLinkedDeviceResponse? = LinkDeviceRepository.waitForDeviceToBeLinked(result.token, maxWaitTime = 30.seconds) + if (waitResult == null) { + Log.i(TAG, "No linked device found!") + } else { + _state.update { + it.copy(oneTimeEvent = OneTimeEvent.ToastLinked(waitResult.getPlaintextDeviceName())) + } + } + + _state.update { + it.copy( + linkDeviceResult = LinkDeviceResult.None, + dialogState = DialogState.None + ) + } + + loadDevices() + + LinkedDeviceInactiveCheckJob.enqueue() + } + + private fun Uri.supportsLinkAndSync(): Boolean { + return this.getQueryParameter("capabilities")?.split(",")?.contains("backup") == true + } + + fun onSyncErrorIgnored() { + _state.update { + it.copy(dialogState = DialogState.None) + } + } + + fun onSyncErrorRetryRequested(deviceId: Int?) = viewModelScope.launch(Dispatchers.IO) { + if (deviceId != null) { + Log.i(TAG, "Need to unlink device first...") + val success = LinkDeviceRepository.removeDevice(deviceId) + if (!success) { + Log.w(TAG, "Failed to remove device! We did our best. Continuing.") + } + } + + _state.update { + it.copy( + dialogState = DialogState.None, + oneTimeEvent = OneTimeEvent.LaunchQrCodeScanner ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt index d996aebe71..0ebe5cfd83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.whispersystems.signalservice.api.archive.ArchiveApi import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.keys.KeysApi +import org.whispersystems.signalservice.api.link.LinkDeviceApi /** * A convenient way to access network operations, similar to [org.thoughtcrime.securesms.database.SignalDatabase] and [org.thoughtcrime.securesms.keyvalue.SignalStore]. @@ -22,4 +23,7 @@ object SignalNetwork { val keys: KeysApi get() = AppDependencies.keysApi + + val linkDevice: LinkDeviceApi + get() = AppDependencies.linkDeviceApi } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index afa94ab86b..b0db40d002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1135,5 +1135,13 @@ object RemoteConfig { hotSwappable = false ) + /** Whether or not this device supports syncing data to newly-linked device. */ + @JvmStatic + val linkAndSync: Boolean by remoteBoolean( + key = "android.linkAndSync", + defaultValue = false, + hotSwappable = true + ) + // endregion } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cff43ea754..0fa1eb2442 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -982,14 +982,18 @@ My linked devices Unlink - - %s unlinked + + \"%s\" unlinked + + \"%s\" linked Linking device… Device approved Loading… + + Syncing messages… No linked devices @@ -1000,6 +1004,14 @@ Tap continue and enter your phone\'s lock to confirm. Do not enter your Signal PIN. Continue + + Message sync failed + + Your messages couldn\'t be transferred to your linked device. You can try re-linking and transferring again, or continue without transferring your message history. + + Try linking again + + Continue without transferring @@ -1024,7 +1036,7 @@ Retry - Unlink \'%s\'? + Unlink \"%s\"? By unlinking this device, it will no longer be able to send or receive messages. Network connection failed diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 2404ca86ae..510af479e4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -42,6 +42,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveApi import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi +import org.whispersystems.signalservice.api.link.LinkDeviceApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -217,4 +218,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { override fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi { return mockk() } + + override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi { + return mockk() + } } diff --git a/core-util-jvm/src/main/java/org/signal/core/util/logging/LoggingExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/logging/LoggingExtensions.kt new file mode 100644 index 0000000000..4c1550205f --- /dev/null +++ b/core-util-jvm/src/main/java/org/signal/core/util/logging/LoggingExtensions.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.logging + +/** + * Convenience method to replace `.also { Log.v(TAG, "message") }` + */ +fun T.logV(tag: String, message: String, throwable: Throwable? = null): T { + Log.v(tag, message, throwable) + return this +} + +/** + * Convenience method to replace `.also { Log.d(TAG, "message") }` + */ +fun T.logD(tag: String, message: String, throwable: Throwable? = null): T { + Log.d(tag, message, throwable) + return this +} + +/** + * Convenience method to replace `.also { Log.i(TAG, "message") }` + */ +fun T.logI(tag: String, message: String, throwable: Throwable? = null): T { + Log.i(tag, message, throwable) + return this +} + +/** + * Convenience method to replace `.also { Log.w(TAG, "message") }` + */ +fun T.logW(tag: String, message: String, throwable: Throwable? = null): T { + Log.w(tag, message, throwable) + return this +} + +/** + * Convenience method to replace `.also { Log.e(TAG, "message") }` + */ +fun T.logE(tag: String, message: String, throwable: Throwable? = null): T { + Log.e(tag, message, throwable) + return this +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index d7754756e7..31131daeea 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -509,10 +509,6 @@ public class SignalServiceAccountManager { return pushServiceSocket.getAccountDataReport(); } - public String getNewDeviceVerificationCode() throws IOException { - return this.pushServiceSocket.getNewDeviceVerificationCode(); - } - public void addDevice(String deviceIdentifier, ECPublicKey deviceKey, IdentityKeyPair aciIdentityKeyPair, @@ -552,7 +548,7 @@ public class SignalServiceAccountManager { return this.pushServiceSocket.getDevices(); } - public void removeDevice(long deviceId) throws IOException { + public void removeDevice(int deviceId) throws IOException { this.pushServiceSocket.removeDevice(deviceId); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt index 4436bd3844..a83757e2ea 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt @@ -112,7 +112,17 @@ class AttachmentApi( } } - private fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult { + /** + * Uploads a raw file using the v4 upload scheme. No additional encryption is supplied! Always prefer [uploadAttachmentV4], unless you are using a separate + * encryption scheme (i.e. like backup files). + */ + fun uploadPreEncryptedFileToAttachmentV4(uploadForm: AttachmentUploadForm, resumableUploadUrl: String, inputStream: InputStream, inputStreamLength: Long): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, inputStream, inputStreamLength) + } + } + + fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult { return NetworkResult.fromFetch { pushServiceSocket.getResumableUploadUrl(uploadForm) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt new file mode 100644 index 0000000000..d7eb52b91e --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.link + +import okio.ByteString.Companion.toByteString +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.backup.BackupKey +import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher +import org.whispersystems.signalservice.internal.push.ProvisionMessage +import org.whispersystems.signalservice.internal.push.ProvisioningVersion +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import kotlin.math.min + +/** + * Class to interact with device-linking endpoints. + */ +class LinkDeviceApi(private val pushServiceSocket: PushServiceSocket) { + + /** + * Fetches a new verification code that lets you link a new device. + * + * GET /v1/devices/provisioning/code + * + * - 200: Success. + * - 411: Account is already at the device limit. + * - 429: Rate-limited. + */ + fun getDeviceVerificationCode(): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.getLinkedDeviceVerificationCode() + } + } + + /** + * Links a new device to the account. + * + * PUT /v1/devices/link + * + * - 200: Success. + * - 403: Account not found or incorrect verification code. + * - 409: The new device is missing a required capability. + * - 411: Account is already at the device limit. + * - 422: Bad request. + * - 429: Rate-limited. + */ + fun linkDevice( + e164: String, + aci: ACI, + pni: PNI, + deviceIdentifier: String, + deviceKey: ECPublicKey, + aciIdentityKeyPair: IdentityKeyPair, + pniIdentityKeyPair: IdentityKeyPair, + profileKey: ProfileKey, + masterKey: MasterKey, + code: String, + ephemeralBackupKey: BackupKey? + ): NetworkResult { + return NetworkResult.fromFetch { + val cipher = PrimaryProvisioningCipher(deviceKey) + val message = ProvisionMessage( + aciIdentityKeyPublic = aciIdentityKeyPair.publicKey.serialize().toByteString(), + aciIdentityKeyPrivate = aciIdentityKeyPair.privateKey.serialize().toByteString(), + pniIdentityKeyPublic = pniIdentityKeyPair.publicKey.serialize().toByteString(), + pniIdentityKeyPrivate = pniIdentityKeyPair.privateKey.serialize().toByteString(), + aci = aci.toString(), + pni = pni.toStringWithoutPrefix(), + number = e164, + profileKey = profileKey.serialize().toByteString(), + provisioningCode = code, + provisioningVersion = ProvisioningVersion.CURRENT.value, + masterKey = masterKey.serialize().toByteString(), + ephemeralBackupKey = ephemeralBackupKey?.value?.toByteString() + ) + val ciphertext = cipher.encrypt(message) + + pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext) + } + } + + /** + * A "long-polling" endpoint that will return once the device has successfully been linked. + * + * @param timeoutSeconds The max amount of time to wait. Capped at 30 seconds. + * + * GET /v1/devices/wait_for_linked_device/{token} + * + * - 200: Success, a new device was linked associated with the provided token. + * - 204: No device was linked before the max waiting time elapsed. + * - 400: Invalid token/timeout. + * - 429: Rate-limited. + */ + fun waitForLinkedDevice(token: String, timeoutSeconds: Int = 30): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.waitForLinkedDevice(token, min(timeoutSeconds, 30)) + } + } + + /** + * After a device has been linked and an archive has been uploaded, you can call this endpoint to share the archive with the linked device. + * + * PUT /v1/devices/transfer_archive + * + * - 204: Success. + * - 422: Bad inputs. + * - 429: Rate-limited. + */ + fun setTransferArchive(destinationDeviceId: Int, destinationDeviceCreated: Long, cdn: Int, cdnKey: String): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.setLinkedDeviceTransferArchive( + SetLinkedDeviceTransferArchiveRequest( + destinationDeviceId = destinationDeviceId, + destinationDeviceCreated = destinationDeviceCreated, + transferArchive = SetLinkedDeviceTransferArchiveRequest.CdnInfo( + cdn = cdn, + key = cdnKey + ) + ) + ) + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkedDeviceVerificationCodeResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkedDeviceVerificationCodeResponse.kt new file mode 100644 index 0000000000..86d162d8e1 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkedDeviceVerificationCodeResponse.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.link + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response object for: GET /v1/devices/provisioning/code + */ +data class LinkedDeviceVerificationCodeResponse( + @JsonProperty val verificationCode: String, + @JsonProperty val tokenIdentifier: String +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetLinkedDeviceTransferArchiveRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetLinkedDeviceTransferArchiveRequest.kt new file mode 100644 index 0000000000..5f080aef28 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetLinkedDeviceTransferArchiveRequest.kt @@ -0,0 +1,22 @@ +/* + * 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 transfer archive for a linked device. + */ +data class SetLinkedDeviceTransferArchiveRequest( + @JsonProperty val destinationDeviceId: Int, + @JsonProperty val destinationDeviceCreated: Long, + @JsonProperty val transferArchive: CdnInfo +) { + data class CdnInfo( + @JsonProperty val cdn: Int, + @JsonProperty val key: String + ) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt new file mode 100644 index 0000000000..6aef006489 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.link + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response body for GET /v1/devices/wait_for_linked_device/{tokenIdentifier} + */ +data class WaitForLinkedDeviceResponse( + @JsonProperty val id: Int, + @JsonProperty val name: String, + @JsonProperty val created: Long, + @JsonProperty val lastSeen: Long +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java deleted file mode 100644 index 6f1f06482e..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DeviceCode.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.whispersystems.signalservice.internal.push; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DeviceCode { - - @JsonProperty - private String verificationCode; - - public String getVerificationCode() { - return verificationCode; - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 90ff2ba413..85a6011a90 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -65,6 +65,9 @@ import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResp import org.whispersystems.signalservice.api.crypto.SealedSenderAccess; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; +import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse; +import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest; +import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; import org.whispersystems.signalservice.api.messages.calls.CallingResponse; @@ -251,6 +254,8 @@ public class PushServiceSocket { private static final String PROVISIONING_CODE_PATH = "/v1/devices/provisioning/code"; private static final String PROVISIONING_MESSAGE_PATH = "/v1/provisioning/%s"; private static final String DEVICE_PATH = "/v1/devices/%s"; + private static final String WAIT_FOR_DEVICES_PATH = "/v1/devices/wait_for_linked_device/%s?timeout=%s"; + private static final String TRANSFER_ARCHIVE_PATH = "/v1/devices/transfer_archive"; private static final String MESSAGE_PATH = "/v1/messages/%s"; private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s"; @@ -669,9 +674,9 @@ public class PushServiceSocket { makeServiceRequest(SET_ACCOUNT_ATTRIBUTES, "PUT", JsonUtil.toJson(accountAttributes)); } - public String getNewDeviceVerificationCode() throws IOException { - String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null); - return JsonUtil.fromJson(responseText, DeviceCode.class).getVerificationCode(); + public LinkedDeviceVerificationCodeResponse getLinkedDeviceVerificationCode() throws IOException { + String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE); + return JsonUtil.fromJson(responseText, LinkedDeviceVerificationCodeResponse.class); } public List getDevices() throws IOException { @@ -679,6 +684,35 @@ public class PushServiceSocket { return JsonUtil.fromJson(responseText, DeviceInfoList.class).getDevices(); } + /** + * This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s. + */ + public WaitForLinkedDeviceResponse waitForLinkedDevice(String token, int timeoutSeconds) throws IOException { + // Note: We consider 204 failure, since that means that we timed out before determining if a device was linked. Easier that way. + + String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, (responseCode, body) -> { + if (responseCode == 204 || responseCode < 200 || responseCode > 299) { + String bodyString = null; + if (body != null) { + try { + bodyString = readBodyString(body); + } catch (MalformedResponseException e) { + Log.w(TAG, "Failed to read body string", e); + } + } + + throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString); + } + }, SealedSenderAccess.NONE); + + return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class); + } + + public void setLinkedDeviceTransferArchive(SetLinkedDeviceTransferArchiveRequest request) throws IOException { + String body = JsonUtil.toJson(request); + makeServiceRequest(String.format(Locale.US, TRANSFER_ARCHIVE_PATH), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE); + } + public void removeDevice(long deviceId) throws IOException { makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null); } @@ -1605,21 +1639,6 @@ public class PushServiceSocket { null, null); } - public Pair uploadAttachment(PushAttachmentData attachment, AttachmentV2UploadAttributes uploadAttributes) - throws PushNetworkException, NonSuccessfulResponseCodeException - { - long id = Long.parseLong(uploadAttributes.getAttachmentId()); - AttachmentDigest digest = uploadToCdn0(ATTACHMENT_UPLOAD_PATH, uploadAttributes.getAcl(), uploadAttributes.getKey(), - uploadAttributes.getPolicy(), uploadAttributes.getAlgorithm(), - uploadAttributes.getCredential(), uploadAttributes.getDate(), - uploadAttributes.getSignature(), attachment.getData(), - "application/octet-stream", attachment.getDataSize(), - attachment.getIncremental(), attachment.getOutputStreamFactory(), - attachment.getListener(), attachment.getCancelationSignal()); - - return new Pair<>(id, digest); - } - public ResumableUploadSpec getResumableUploadSpec(AttachmentUploadForm uploadForm) throws IOException { return new ResumableUploadSpec(Util.getSecretBytes(64), Util.getSecretBytes(16), @@ -1630,19 +1649,9 @@ public class PushServiceSocket { uploadForm.headers); } - public ResumableUploadSpec getResumableUploadSpecWithKey(AttachmentUploadForm uploadForm, byte[] secretKey) throws IOException { - return new ResumableUploadSpec(secretKey, - Util.getSecretBytes(16), - uploadForm.key, - uploadForm.cdn, - getResumableUploadUrl(uploadForm), - System.currentTimeMillis() + CDN2_RESUMABLE_LINK_LIFETIME_MILLIS, - uploadForm.headers); - } - public AttachmentDigest uploadAttachment(PushAttachmentData attachment) throws IOException { - if (attachment.getResumableUploadSpec() == null || attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) { + if (attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) { throw new ResumeLocationInvalidException(); } diff --git a/libsignal-service/src/main/protowire/Provisioning.proto b/libsignal-service/src/main/protowire/Provisioning.proto index 2b9bef426e..03fb0085e8 100644 --- a/libsignal-service/src/main/protowire/Provisioning.proto +++ b/libsignal-service/src/main/protowire/Provisioning.proto @@ -33,7 +33,10 @@ message ProvisionMessage { optional bool readReceipts = 7; optional uint32 provisioningVersion = 9; optional bytes masterKey = 13; - // NEXT ID: 14 + optional bytes ephemeralBackupKey = 14; // 32 bytes + optional string accountEntropyPool = 15; + optional bytes mediaRootBackupKey = 16; // 32-bytes + // NEXT ID: 17 } enum ProvisioningVersion {