Add initial link+sync support.
This commit is contained in:
parent
ebca386dcb
commit
7f3ceea9fe
27 changed files with 1042 additions and 260 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.util.logging
|
||||
|
||||
/**
|
||||
* Convenience method to replace `.also { Log.v(TAG, "message") }`
|
||||
*/
|
||||
fun <T> T.logV(tag: String, message: String, throwable: Throwable? = null): T {
|
||||
Log.v(tag, message, throwable)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to replace `.also { Log.d(TAG, "message") }`
|
||||
*/
|
||||
fun <T> T.logD(tag: String, message: String, throwable: Throwable? = null): T {
|
||||
Log.d(tag, message, throwable)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to replace `.also { Log.i(TAG, "message") }`
|
||||
*/
|
||||
fun <T> T.logI(tag: String, message: String, throwable: Throwable? = null): T {
|
||||
Log.i(tag, message, throwable)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to replace `.also { Log.w(TAG, "message") }`
|
||||
*/
|
||||
fun <T> T.logW(tag: String, message: String, throwable: Throwable? = null): T {
|
||||
Log.w(tag, message, throwable)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to replace `.also { Log.e(TAG, "message") }`
|
||||
*/
|
||||
fun <T> T.logE(tag: String, message: String, throwable: Throwable? = null): T {
|
||||
Log.e(tag, message, throwable)
|
||||
return this
|
||||
}
|
|
@ -509,10 +509,6 @@ public class SignalServiceAccountManager {
|
|||
return pushServiceSocket.getAccountDataReport();
|
||||
}
|
||||
|
||||
public String getNewDeviceVerificationCode() throws IOException {
|
||||
return this.pushServiceSocket.getNewDeviceVerificationCode();
|
||||
}
|
||||
|
||||
public void addDevice(String deviceIdentifier,
|
||||
ECPublicKey deviceKey,
|
||||
IdentityKeyPair aciIdentityKeyPair,
|
||||
|
@ -552,7 +548,7 @@ public class SignalServiceAccountManager {
|
|||
return this.pushServiceSocket.getDevices();
|
||||
}
|
||||
|
||||
public void removeDevice(long deviceId) throws IOException {
|
||||
public void removeDevice(int deviceId) throws IOException {
|
||||
this.pushServiceSocket.removeDevice(deviceId);
|
||||
}
|
||||
|
||||
|
|
|
@ -112,7 +112,17 @@ class AttachmentApi(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
|
||||
/**
|
||||
* Uploads a raw file using the v4 upload scheme. No additional encryption is supplied! Always prefer [uploadAttachmentV4], unless you are using a separate
|
||||
* encryption scheme (i.e. like backup files).
|
||||
*/
|
||||
fun uploadPreEncryptedFileToAttachmentV4(uploadForm: AttachmentUploadForm, resumableUploadUrl: String, inputStream: InputStream, inputStreamLength: Long): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.uploadBackupFile(uploadForm, resumableUploadUrl, inputStream, inputStreamLength)
|
||||
}
|
||||
}
|
||||
|
||||
fun getResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult<String> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.getResumableUploadUrl(uploadForm)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.link
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage
|
||||
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Class to interact with device-linking endpoints.
|
||||
*/
|
||||
class LinkDeviceApi(private val pushServiceSocket: PushServiceSocket) {
|
||||
|
||||
/**
|
||||
* Fetches a new verification code that lets you link a new device.
|
||||
*
|
||||
* GET /v1/devices/provisioning/code
|
||||
*
|
||||
* - 200: Success.
|
||||
* - 411: Account is already at the device limit.
|
||||
* - 429: Rate-limited.
|
||||
*/
|
||||
fun getDeviceVerificationCode(): NetworkResult<LinkedDeviceVerificationCodeResponse> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.getLinkedDeviceVerificationCode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a new device to the account.
|
||||
*
|
||||
* PUT /v1/devices/link
|
||||
*
|
||||
* - 200: Success.
|
||||
* - 403: Account not found or incorrect verification code.
|
||||
* - 409: The new device is missing a required capability.
|
||||
* - 411: Account is already at the device limit.
|
||||
* - 422: Bad request.
|
||||
* - 429: Rate-limited.
|
||||
*/
|
||||
fun linkDevice(
|
||||
e164: String,
|
||||
aci: ACI,
|
||||
pni: PNI,
|
||||
deviceIdentifier: String,
|
||||
deviceKey: ECPublicKey,
|
||||
aciIdentityKeyPair: IdentityKeyPair,
|
||||
pniIdentityKeyPair: IdentityKeyPair,
|
||||
profileKey: ProfileKey,
|
||||
masterKey: MasterKey,
|
||||
code: String,
|
||||
ephemeralBackupKey: BackupKey?
|
||||
): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
val cipher = PrimaryProvisioningCipher(deviceKey)
|
||||
val message = ProvisionMessage(
|
||||
aciIdentityKeyPublic = aciIdentityKeyPair.publicKey.serialize().toByteString(),
|
||||
aciIdentityKeyPrivate = aciIdentityKeyPair.privateKey.serialize().toByteString(),
|
||||
pniIdentityKeyPublic = pniIdentityKeyPair.publicKey.serialize().toByteString(),
|
||||
pniIdentityKeyPrivate = pniIdentityKeyPair.privateKey.serialize().toByteString(),
|
||||
aci = aci.toString(),
|
||||
pni = pni.toStringWithoutPrefix(),
|
||||
number = e164,
|
||||
profileKey = profileKey.serialize().toByteString(),
|
||||
provisioningCode = code,
|
||||
provisioningVersion = ProvisioningVersion.CURRENT.value,
|
||||
masterKey = masterKey.serialize().toByteString(),
|
||||
ephemeralBackupKey = ephemeralBackupKey?.value?.toByteString()
|
||||
)
|
||||
val ciphertext = cipher.encrypt(message)
|
||||
|
||||
pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A "long-polling" endpoint that will return once the device has successfully been linked.
|
||||
*
|
||||
* @param timeoutSeconds The max amount of time to wait. Capped at 30 seconds.
|
||||
*
|
||||
* GET /v1/devices/wait_for_linked_device/{token}
|
||||
*
|
||||
* - 200: Success, a new device was linked associated with the provided token.
|
||||
* - 204: No device was linked before the max waiting time elapsed.
|
||||
* - 400: Invalid token/timeout.
|
||||
* - 429: Rate-limited.
|
||||
*/
|
||||
fun waitForLinkedDevice(token: String, timeoutSeconds: Int = 30): NetworkResult<WaitForLinkedDeviceResponse> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.waitForLinkedDevice(token, min(timeoutSeconds, 30))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a device has been linked and an archive has been uploaded, you can call this endpoint to share the archive with the linked device.
|
||||
*
|
||||
* PUT /v1/devices/transfer_archive
|
||||
*
|
||||
* - 204: Success.
|
||||
* - 422: Bad inputs.
|
||||
* - 429: Rate-limited.
|
||||
*/
|
||||
fun setTransferArchive(destinationDeviceId: Int, destinationDeviceCreated: Long, cdn: Int, cdnKey: String): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.setLinkedDeviceTransferArchive(
|
||||
SetLinkedDeviceTransferArchiveRequest(
|
||||
destinationDeviceId = destinationDeviceId,
|
||||
destinationDeviceCreated = destinationDeviceCreated,
|
||||
transferArchive = SetLinkedDeviceTransferArchiveRequest.CdnInfo(
|
||||
cdn = cdn,
|
||||
key = cdnKey
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.link
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Response object for: GET /v1/devices/provisioning/code
|
||||
*/
|
||||
data class LinkedDeviceVerificationCodeResponse(
|
||||
@JsonProperty val verificationCode: String,
|
||||
@JsonProperty val tokenIdentifier: String
|
||||
)
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.link
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request body for setting the transfer archive for a linked device.
|
||||
*/
|
||||
data class SetLinkedDeviceTransferArchiveRequest(
|
||||
@JsonProperty val destinationDeviceId: Int,
|
||||
@JsonProperty val destinationDeviceCreated: Long,
|
||||
@JsonProperty val transferArchive: CdnInfo
|
||||
) {
|
||||
data class CdnInfo(
|
||||
@JsonProperty val cdn: Int,
|
||||
@JsonProperty val key: String
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.link
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Response body for GET /v1/devices/wait_for_linked_device/{tokenIdentifier}
|
||||
*/
|
||||
data class WaitForLinkedDeviceResponse(
|
||||
@JsonProperty val id: Int,
|
||||
@JsonProperty val name: String,
|
||||
@JsonProperty val created: Long,
|
||||
@JsonProperty val lastSeen: Long
|
||||
)
|
|
@ -1,13 +0,0 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class DeviceCode {
|
||||
|
||||
@JsonProperty
|
||||
private String verificationCode;
|
||||
|
||||
public String getVerificationCode() {
|
||||
return verificationCode;
|
||||
}
|
||||
}
|
|
@ -65,6 +65,9 @@ import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResp
|
|||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess;
|
||||
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
|
||||
import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest;
|
||||
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
||||
import org.whispersystems.signalservice.api.messages.calls.CallingResponse;
|
||||
|
@ -251,6 +254,8 @@ public class PushServiceSocket {
|
|||
private static final String PROVISIONING_CODE_PATH = "/v1/devices/provisioning/code";
|
||||
private static final String PROVISIONING_MESSAGE_PATH = "/v1/provisioning/%s";
|
||||
private static final String DEVICE_PATH = "/v1/devices/%s";
|
||||
private static final String WAIT_FOR_DEVICES_PATH = "/v1/devices/wait_for_linked_device/%s?timeout=%s";
|
||||
private static final String TRANSFER_ARCHIVE_PATH = "/v1/devices/transfer_archive";
|
||||
|
||||
private static final String MESSAGE_PATH = "/v1/messages/%s";
|
||||
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s";
|
||||
|
@ -669,9 +674,9 @@ public class PushServiceSocket {
|
|||
makeServiceRequest(SET_ACCOUNT_ATTRIBUTES, "PUT", JsonUtil.toJson(accountAttributes));
|
||||
}
|
||||
|
||||
public String getNewDeviceVerificationCode() throws IOException {
|
||||
String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null);
|
||||
return JsonUtil.fromJson(responseText, DeviceCode.class).getVerificationCode();
|
||||
public LinkedDeviceVerificationCodeResponse getLinkedDeviceVerificationCode() throws IOException {
|
||||
String responseText = makeServiceRequest(PROVISIONING_CODE_PATH, "GET", null, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
|
||||
return JsonUtil.fromJson(responseText, LinkedDeviceVerificationCodeResponse.class);
|
||||
}
|
||||
|
||||
public List<DeviceInfo> getDevices() throws IOException {
|
||||
|
@ -679,6 +684,35 @@ public class PushServiceSocket {
|
|||
return JsonUtil.fromJson(responseText, DeviceInfoList.class).getDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s.
|
||||
*/
|
||||
public WaitForLinkedDeviceResponse waitForLinkedDevice(String token, int timeoutSeconds) throws IOException {
|
||||
// Note: We consider 204 failure, since that means that we timed out before determining if a device was linked. Easier that way.
|
||||
|
||||
String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, (responseCode, body) -> {
|
||||
if (responseCode == 204 || responseCode < 200 || responseCode > 299) {
|
||||
String bodyString = null;
|
||||
if (body != null) {
|
||||
try {
|
||||
bodyString = readBodyString(body);
|
||||
} catch (MalformedResponseException e) {
|
||||
Log.w(TAG, "Failed to read body string", e);
|
||||
}
|
||||
}
|
||||
|
||||
throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString);
|
||||
}
|
||||
}, SealedSenderAccess.NONE);
|
||||
|
||||
return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class);
|
||||
}
|
||||
|
||||
public void setLinkedDeviceTransferArchive(SetLinkedDeviceTransferArchiveRequest request) throws IOException {
|
||||
String body = JsonUtil.toJson(request);
|
||||
makeServiceRequest(String.format(Locale.US, TRANSFER_ARCHIVE_PATH), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
|
||||
}
|
||||
|
||||
public void removeDevice(long deviceId) throws IOException {
|
||||
makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null);
|
||||
}
|
||||
|
@ -1605,21 +1639,6 @@ public class PushServiceSocket {
|
|||
null, null);
|
||||
}
|
||||
|
||||
public Pair<Long, AttachmentDigest> uploadAttachment(PushAttachmentData attachment, AttachmentV2UploadAttributes uploadAttributes)
|
||||
throws PushNetworkException, NonSuccessfulResponseCodeException
|
||||
{
|
||||
long id = Long.parseLong(uploadAttributes.getAttachmentId());
|
||||
AttachmentDigest digest = uploadToCdn0(ATTACHMENT_UPLOAD_PATH, uploadAttributes.getAcl(), uploadAttributes.getKey(),
|
||||
uploadAttributes.getPolicy(), uploadAttributes.getAlgorithm(),
|
||||
uploadAttributes.getCredential(), uploadAttributes.getDate(),
|
||||
uploadAttributes.getSignature(), attachment.getData(),
|
||||
"application/octet-stream", attachment.getDataSize(),
|
||||
attachment.getIncremental(), attachment.getOutputStreamFactory(),
|
||||
attachment.getListener(), attachment.getCancelationSignal());
|
||||
|
||||
return new Pair<>(id, digest);
|
||||
}
|
||||
|
||||
public ResumableUploadSpec getResumableUploadSpec(AttachmentUploadForm uploadForm) throws IOException {
|
||||
return new ResumableUploadSpec(Util.getSecretBytes(64),
|
||||
Util.getSecretBytes(16),
|
||||
|
@ -1630,19 +1649,9 @@ public class PushServiceSocket {
|
|||
uploadForm.headers);
|
||||
}
|
||||
|
||||
public ResumableUploadSpec getResumableUploadSpecWithKey(AttachmentUploadForm uploadForm, byte[] secretKey) throws IOException {
|
||||
return new ResumableUploadSpec(secretKey,
|
||||
Util.getSecretBytes(16),
|
||||
uploadForm.key,
|
||||
uploadForm.cdn,
|
||||
getResumableUploadUrl(uploadForm),
|
||||
System.currentTimeMillis() + CDN2_RESUMABLE_LINK_LIFETIME_MILLIS,
|
||||
uploadForm.headers);
|
||||
}
|
||||
|
||||
public AttachmentDigest uploadAttachment(PushAttachmentData attachment) throws IOException {
|
||||
|
||||
if (attachment.getResumableUploadSpec() == null || attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {
|
||||
if (attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {
|
||||
throw new ResumeLocationInvalidException();
|
||||
}
|
||||
|
||||
|
|
|
@ -33,7 +33,10 @@ message ProvisionMessage {
|
|||
optional bool readReceipts = 7;
|
||||
optional uint32 provisioningVersion = 9;
|
||||
optional bytes masterKey = 13;
|
||||
// NEXT ID: 14
|
||||
optional bytes ephemeralBackupKey = 14; // 32 bytes
|
||||
optional string accountEntropyPool = 15;
|
||||
optional bytes mediaRootBackupKey = 16; // 32-bytes
|
||||
// NEXT ID: 17
|
||||
}
|
||||
|
||||
enum ProvisioningVersion {
|
||||
|
|
Loading…
Add table
Reference in a new issue