diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 2d373b707c..67fdca80f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI @@ -165,14 +166,13 @@ object BackupRepository { } /** - * A simple test method that just hits various network endpoints. Only useful for the playground. + * Returns an object with details about the remote backup state. */ - fun testNetworkInteractions() { + fun getRemoteBackupState(): NetworkResult { val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() - // Just running some sample requests - api + return api .triggerBackupIdReservation(backupKey) .then { getAuthCredential() } .then { credential -> @@ -182,13 +182,41 @@ object BackupRepository { } .then { credential -> api.getBackupInfo(backupKey, credential) - .also { Log.i(TAG, "BackupInfoResult: $it") } + } + } + + /** + * A simple test method that just hits various network endpoints. Only useful for the playground. + * + * @return True if successful, otherwise false. + */ + fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return api + .triggerBackupIdReservation(backupKey) + .then { getAuthCredential() } + .then { credential -> + api.setPublicKey(backupKey, credential) + .also { Log.i(TAG, "PublicKeyResult: $it") } .map { credential } } .then { credential -> api.getMessageBackupUploadForm(backupKey, credential) .also { Log.i(TAG, "UploadFormResult: $it") } - }.also { Log.i(TAG, "OverallResponse: $it") } + } + .then { form -> + api.getBackupResumableUploadUrl(form) + .also { Log.i(TAG, "ResumableUploadUrlResult: $it") } + .map { form to it } + } + .then { formAndUploadUrl -> + val (form, resumableUploadUrl) = formAndUploadUrl + api.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength) + .also { Log.i(TAG, "UploadBackupFileResult: $it") } + } + .also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 4dd7692d4d..1fd3173714 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -33,9 +33,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import org.signal.core.ui.Buttons +import org.signal.core.ui.Dividers import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.bytes import org.signal.core.util.getLength +import org.signal.core.util.roundedString import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState +import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupUploadState import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState import org.thoughtcrime.securesms.compose.ComposeFragment @@ -98,7 +102,8 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { exportFileLauncher.launch(intent) }, - onTestNetworkClicked = { viewModel.testNetworkInteractions() } + onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() }, + onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() } ) } @@ -115,7 +120,8 @@ fun Screen( onImportFileClicked: () -> Unit = {}, onPlaintextClicked: () -> Unit = {}, onSaveToDiskClicked: () -> Unit = {}, - onTestNetworkClicked: () -> Unit = {} + onUploadToRemoteClicked: () -> Unit = {}, + onCheckRemoteBackupStateClicked: () -> Unit = {} ) { Surface { Column( @@ -144,12 +150,9 @@ fun Screen( ) { Text("Export") } - Buttons.LargePrimary( - onClick = onTestNetworkClicked, - enabled = state.backupState == BackupState.EXPORT_DONE - ) { - Text("Test network") - } + + Dividers.Default() + Buttons.LargeTonal( onClick = onImportMemoryClicked, enabled = state.backupState == BackupState.EXPORT_DONE @@ -172,7 +175,7 @@ fun Screen( StateLabel("Export in progress...") } BackupState.EXPORT_DONE -> { - StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, or you can save it to a file.") + StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.") Spacer(modifier = Modifier.height(8.dp)) @@ -184,6 +187,57 @@ fun Screen( StateLabel("Import in progress...") } } + + Dividers.Default() + + Buttons.LargeTonal( + onClick = onCheckRemoteBackupStateClicked + ) { + Text("Check remote backup state") + } + + Spacer(modifier = Modifier.height(8.dp)) + + when (state.remoteBackupState) { + is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> { + StateLabel("Exists/allocated. Space used by media: ${state.remoteBackupState.response.usedSpace ?: 0} bytes (${state.remoteBackupState.response.usedSpace?.bytes?.inMebiBytes?.roundedString(3) ?: 0} MiB)") + } + InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> { + StateLabel("Hit an unknown error. Check the logs.") + } + InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> { + StateLabel("Not found.") + } + InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> { + StateLabel("Hit the button above to check the state.") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Buttons.LargePrimary( + onClick = onUploadToRemoteClicked, + enabled = state.backupState == BackupState.EXPORT_DONE + ) { + Text("Upload to remote") + } + + Spacer(modifier = Modifier.height(8.dp)) + + when (state.uploadState) { + BackupUploadState.NONE -> { + StateLabel("") + } + BackupUploadState.UPLOAD_IN_PROGRESS -> { + StateLabel("Upload in progress...") + } + BackupUploadState.UPLOAD_DONE -> { + StateLabel("Upload complete.") + } + BackupUploadState.UPLOAD_FAILED -> { + StateLabel("Upload failed.") + } + } } } } @@ -240,3 +294,14 @@ fun PreviewScreenImportInProgress() { } } } + +@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewScreenUploadInProgress() { + SignalTheme { + Surface { + Screen(state = ScreenState(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS, plaintext = false)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index b3956e47ad..b3d8f7478e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -17,6 +17,8 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse import java.io.ByteArrayInputStream import java.io.InputStream @@ -26,7 +28,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() { val disposables = CompositeDisposable() - private val _state: MutableState = mutableStateOf(ScreenState(backupState = BackupState.NONE, plaintext = false)) + private val _state: MutableState = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false)) val state: State = _state fun export() { @@ -80,11 +82,36 @@ class InternalBackupPlaygroundViewModel : ViewModel() { _state.value = _state.value.copy(plaintext = !_state.value.plaintext) } - fun testNetworkInteractions() { + fun uploadBackupToRemote() { + _state.value = _state.value.copy(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS) + disposables += Single - .fromCallable { BackupRepository.testNetworkInteractions() } + .fromCallable { BackupRepository.uploadBackupFile(backupData!!.inputStream(), backupData!!.size.toLong()) } .subscribeOn(Schedulers.io()) - .subscribe() + .subscribe { success -> + _state.value = _state.value.copy(uploadState = if (success) BackupUploadState.UPLOAD_DONE else BackupUploadState.UPLOAD_FAILED) + } + } + + fun checkRemoteBackupState() { + _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Unknown) + + disposables += Single + .fromCallable { BackupRepository.getRemoteBackupState() } + .subscribeOn(Schedulers.io()) + .subscribe { result -> + when { + result is NetworkResult.Success -> { + _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result)) + } + result is NetworkResult.StatusCodeError && result.code == 404 -> { + _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound) + } + else -> { + _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError) + } + } + } } override fun onCleared() { @@ -92,11 +119,24 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } data class ScreenState( - val backupState: BackupState, + val backupState: BackupState = BackupState.NONE, + val uploadState: BackupUploadState = BackupUploadState.NONE, + val remoteBackupState: RemoteBackupState = RemoteBackupState.Unknown, val plaintext: Boolean ) enum class BackupState(val inProgress: Boolean = false) { NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, IMPORT_IN_PROGRESS(true) } + + enum class BackupUploadState(val inProgress: Boolean = false) { + NONE, UPLOAD_IN_PROGRESS(true), UPLOAD_DONE, UPLOAD_FAILED + } + + sealed class RemoteBackupState { + object Unknown : RemoteBackupState() + object NotFound : RemoteBackupState() + object GeneralError : RemoteBackupState() + data class Available(val response: ArchiveGetBackupInfoResponse) : RemoteBackupState() + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt index 29a5790b63..bb89287395 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt @@ -16,6 +16,7 @@ import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.backup.BackupKey import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.internal.push.PushServiceSocket +import java.io.InputStream /** * Class to interact with various archive-related endpoints. @@ -101,6 +102,24 @@ class ArchiveApi( } } + /** + * Retrieves a resumable upload URL you can use to upload your main message backup file to cloud storage. + */ + fun getBackupResumableUploadUrl(archiveFormResponse: ArchiveMessageBackupUploadFormResponse): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.getResumableUploadUrl(archiveFormResponse) + } + } + + /** + * Uploads your main backup file to cloud storage. + */ + fun uploadBackupFile(archiveFormResponse: ArchiveMessageBackupUploadFormResponse, resumableUploadUrl: String, data: InputStream, dataLength: Long): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.uploadBackupFile(archiveFormResponse, resumableUploadUrl, data, dataLength) + } + } + private fun getZkCredential(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): BackupAuthCredential { val backupAuthResponse = BackupAuthCredentialResponse(serviceCredential.credential) val backupRequestContext = BackupAuthCredentialRequestContext.create(backupKey.value, aci.rawUuid) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 4afbef5d17..9ebf168378 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -1637,6 +1637,10 @@ public class PushServiceSocket { } } + public String getResumableUploadUrl(ArchiveMessageBackupUploadFormResponse uploadFormResponse) throws IOException { + return getResumableUploadUrl(uploadFormResponse.getCdn(), uploadFormResponse.getSignedUploadLocation(), uploadFormResponse.getHeaders()); + } + private String getResumableUploadUrl(int cdn, String signedUrl, Map headers) throws IOException { ConnectionHolder connectionHolder = getRandom(cdnClientsMap.get(cdn), random); OkHttpClient okHttpClient = connectionHolder.getClient() @@ -1739,6 +1743,10 @@ public class PushServiceSocket { } } + public void uploadBackupFile(ArchiveMessageBackupUploadFormResponse uploadFormResponse, String resumableUploadUrl, InputStream data, long dataLength) throws IOException { + uploadToCdn3(resumableUploadUrl, data, "application/octet-stream", dataLength, false, new NoCipherOutputStreamFactory(), null, null, uploadFormResponse.getHeaders()); + } + private AttachmentDigest uploadToCdn3(String resumableUrl, InputStream data, String contentType,