mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add initial link+sync support.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user