Add initial link+sync support.

This commit is contained in:
Greyson Parrelli 2024-10-25 09:53:17 -04:00
parent ebca386dcb
commit 7f3ceea9fe
27 changed files with 1042 additions and 260 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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())

View file

@ -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 -> {

View file

@ -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,

View file

@ -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)

View file

@ -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()
}
}
}

View file

@ -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
)
)
}
}

View file

@ -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
}
}

View file

@ -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<Unit> {
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
}
}

View file

@ -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<Device> = 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
}
}

View file

@ -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
)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -982,14 +982,18 @@
<string name="LinkDeviceFragment__my_linked_devices">My linked devices</string>
<!-- Dialog confirmation to unlink a device -->
<string name="LinkDeviceFragment__unlink">Unlink</string>
<!-- Toast message indicating a device has been unlinked where %s is the name of the device -->
<string name="LinkDeviceFragment__s_unlinked">%s unlinked</string>
<!-- Toast message indicating a device has been unlinked, where %s is the name of the device -->
<string name="LinkDeviceFragment__s_unlinked">\"%s\" unlinked</string>
<!-- Toast message indicating a device has been successfully linked, where %s is the name of the device -->
<string name="LinkDeviceFragment__s_linked">\"%s\" linked</string>
<!-- Progress dialog message indicating that a device is currently being linked with an account -->
<string name="LinkDeviceFragment__linking_device">Linking device…</string>
<!-- Toast message shown after a device has been linked -->
<string name="LinkDeviceFragment__device_approved">Device approved</string>
<!-- Progress dialog message indicating that the list of linked devices is currently loading -->
<string name="LinkDeviceFragment__loading">Loading…</string>
<!-- Progress dialog message indicating that you are syncing messages to your linked device -->
<string name="LinkDeviceFragment__syncing_messages">Syncing messages…</string>
<!-- Text message shown when the user has no linked devices -->
<string name="LinkDeviceFragment__no_linked_devices">No linked devices</string>
<!-- Title on biometrics prompt explaining what biometrics are being used for -->
@ -1000,6 +1004,14 @@
<string name="LinkDeviceFragment__tap_continue_and_enter_phone">Tap continue and enter your phone\'s lock to confirm. Do not enter your Signal PIN.</string>
<!-- Button that dismisses the bottom sheet -->
<string name="LinkDeviceFragment__continue">Continue</string>
<!-- Title of a dialog letting the user know that syncing messages to their linked device failed -->
<string name="LinkDeviceFragment__sync_failure_title">Message sync failed</string>
<!-- Body of a dialog letting the user know that syncing messages to their linked device failed -->
<string name="LinkDeviceFragment__sync_failure_body">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.</string>
<!-- Text button of a button in a dialog that, when pressed, will restart the process of linking a device -->
<string name="LinkDeviceFragment__sync_failure_retry_button">Try linking again</string>
<!-- Text button of a button in a dialog that, when pressed, will ignore syncing errors and link a new device without syncing message content -->
<string name="LinkDeviceFragment__sync_failure_dismiss_button">Continue without transferring</string>
<!-- AddLinkDeviceFragment -->
<!-- Description text shown on the QR code scanner when linking a device -->
@ -1024,7 +1036,7 @@
<string name="AddLinkDeviceFragment__retry">Retry</string>
<!-- DeviceListActivity -->
<string name="DeviceListActivity_unlink_s">Unlink \'%s\'?</string>
<string name="DeviceListActivity_unlink_s">Unlink \"%s\"?</string>
<string name="DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive">By unlinking this device, it will no longer be able to send or receive messages.</string>
<string name="DeviceListActivity_network_connection_failed">Network connection failed</string>
<!-- Button label on an alert dialog. The dialog informs the user they have network issues. If pressed, we will retry the network request. -->

View file

@ -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()
}
}

View file

@ -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> 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> 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> 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> 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> T.logE(tag: String, message: String, throwable: Throwable? = null): T {
Log.e(tag, message, throwable)
return this
}

View file

@ -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);
}

View file

@ -112,7 +112,17 @@ class AttachmentApi(
}
}
private fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
/**
* 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<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, inputStream, inputStreamLength)
}
}
fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
return NetworkResult.fromFetch {
pushServiceSocket.getResumableUploadUrl(uploadForm)
}

View file

@ -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<LinkedDeviceVerificationCodeResponse> {
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<Unit> {
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<WaitForLinkedDeviceResponse> {
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<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.setLinkedDeviceTransferArchive(
SetLinkedDeviceTransferArchiveRequest(
destinationDeviceId = destinationDeviceId,
destinationDeviceCreated = destinationDeviceCreated,
transferArchive = SetLinkedDeviceTransferArchiveRequest.CdnInfo(
cdn = cdn,
key = cdnKey
)
)
)
}
}
}

View file

@ -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
)

View file

@ -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
)
}

View file

@ -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
)

View file

@ -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;
}
}

View file

@ -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<DeviceInfo> 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<Long, AttachmentDigest> 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();
}

View file

@ -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 {