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