diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt index 7db281c195..bf942d0dc8 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt @@ -1537,7 +1537,7 @@ class ImportExportTest { val frameReader = EncryptedBackupReader( key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(), aci = selfData.aci, - streamLength = import.size.toLong(), + length = import.size.toLong(), dataStream = inputFactory ) val frames = ArrayList() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06ec3e43c7..9f7a4905a1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -988,8 +988,8 @@ android:windowSoftInputMode="stateVisible|adjustResize" android:exported="false"/> - { @@ -220,6 +224,7 @@ object BackupRepository { else -> Log.w(TAG, "Unrecognized frame") } + EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength)) } if (chatItemInserter.flush()) { @@ -263,6 +268,21 @@ object BackupRepository { } } + private fun getBackupTier(): NetworkResult { + val api = AppDependencies.signalServiceAccountManager.archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return initBackupAndFetchAuth(backupKey) + .map { credential -> + val zkCredential = api.getZkCredential(backupKey, credential) + if (zkCredential.backupLevel == BackupLevel.MEDIA) { + MessageBackupTier.PAID + } else { + MessageBackupTier.FREE + } + } + } + /** * Returns an object with details about the remote backup state. */ @@ -331,6 +351,24 @@ object BackupRepository { } is NetworkResult.Success } + fun checkForBackupFile(): Boolean { + val api = AppDependencies.signalServiceAccountManager.archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return initBackupAndFetchAuth(backupKey) + .then { credential -> + api.getBackupInfo(backupKey, credential) + } + .then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } } + .then { pair -> + val (cdnCredentials, info) = pair + val messageReceiver = AppDependencies.signalServiceMessageReceiver + NetworkResult.fromFetch { + messageReceiver.checkBackupExistence(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") + } + } is NetworkResult.Success + } + /** * Returns an object with details about the remote backup state. */ @@ -560,6 +598,24 @@ object BackupRepository { .also { Log.i(TAG, "getCdnReadCredentialsResult: $it") } } + fun restoreBackupTier(): MessageBackupTier? { + // TODO: more complete error handling + try { + checkForBackupFile() + } catch (e: Exception) { + Log.i(TAG, "Could not check for backup file.", e) + SignalStore.backup().backupTier = null + return null + } + SignalStore.backup().backupTier = try { + getBackupTier().successOrThrow() + } catch (e: Exception) { + Log.i(TAG, "Could not retrieve backup tier.", e) + null + } + return SignalStore.backup().backupTier + } + /** * Retrieves backupDir and mediaDir, preferring cached value if available. * @@ -693,13 +749,13 @@ enum class MessageBackupTier(val value: Int) { FREE(0), PAID(1); - companion object Serializer : LongSerializer { - override fun serialize(data: MessageBackupTier): Long { - return data.value.toLong() + companion object Serializer : LongSerializer { + override fun serialize(data: MessageBackupTier?): Long { + return data?.value?.toLong() ?: -1 } - override fun deserialize(data: Long): MessageBackupTier { - return values().firstOrNull { it.value == data.toInt() } ?: FREE + override fun deserialize(data: Long): MessageBackupTier? { + return values().firstOrNull { it.value == data.toInt() } ?: null } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt new file mode 100644 index 0000000000..8183c5173e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2 + +class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) { + enum class Type { + PROGRESS_DOWNLOAD, + PROGRESS_RESTORE, + PROGRESS_MEDIA_RESTORE, + FINISHED + } + + fun getProgress(): Float { + if (estimatedTotalCount == 0L) { + return 0f + } + return count.toFloat() / estimatedTotalCount.toFloat() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt index f5b5e4d24c..80f83893eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt @@ -10,4 +10,6 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame interface BackupImportReader : Iterator, AutoCloseable { fun getHeader(): BackupInfo? + fun getBytesRead(): Long + fun getStreamLength(): Long } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt index 7031bca71f..7e8701d898 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2.stream +import com.google.common.io.CountingInputStream import org.signal.core.util.readFully import org.signal.core.util.readNBytesOrThrow import org.signal.core.util.readVarInt32 @@ -32,21 +33,22 @@ import javax.crypto.spec.SecretKeySpec class EncryptedBackupReader( key: BackupKey, aci: ACI, - streamLength: Long, + val length: Long, dataStream: () -> InputStream ) : BackupImportReader { val backupInfo: BackupInfo? var next: Frame? = null val stream: InputStream + val countingStream: CountingInputStream init { val keyMaterial = key.deriveBackupSecrets(aci) - validateMac(keyMaterial.macKey, streamLength, dataStream()) + validateMac(keyMaterial.macKey, length, dataStream()) - val inputStream = dataStream() - val iv = inputStream.readNBytesOrThrow(16) + countingStream = CountingInputStream(dataStream()) + val iv = countingStream.readNBytesOrThrow(16) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv)) @@ -55,8 +57,8 @@ class EncryptedBackupReader( stream = GZIPInputStream( CipherInputStream( TruncatingInputStream( - wrapped = inputStream, - maxBytes = streamLength - MAC_SIZE + wrapped = countingStream, + maxBytes = length - MAC_SIZE ), cipher ) @@ -69,6 +71,10 @@ class EncryptedBackupReader( return backupInfo } + override fun getBytesRead() = countingStream.count + + override fun getStreamLength() = length + override fun hasNext(): Boolean { return next != null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt index 136ee50dd0..8ada418766 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2.stream +import com.google.common.io.CountingInputStream import org.signal.core.util.readNBytesOrThrow import org.signal.core.util.readVarInt32 import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo @@ -15,12 +16,14 @@ import java.io.InputStream /** * Reads a plaintext backup import stream one frame at a time. */ -class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader { +class PlainTextBackupReader(val dataStream: InputStream, val length: Long) : BackupImportReader { val backupInfo: BackupInfo? var next: Frame? = null + val inputStream: CountingInputStream init { + inputStream = CountingInputStream(dataStream) backupInfo = readHeader() next = read() } @@ -29,6 +32,10 @@ class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader { return backupInfo } + override fun getBytesRead() = inputStream.count + + override fun getStreamLength() = length + override fun hasNext(): Boolean { return next != null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreActivity.kt deleted file mode 100644 index 1ce5bccc51..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreActivity.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.backup.v2.ui.subscription - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import org.signal.core.ui.Buttons -import org.signal.core.ui.Dividers -import org.signal.core.util.getLength -import org.thoughtcrime.securesms.BaseActivity -import org.thoughtcrime.securesms.MainActivity -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobs.ProfileUploadJob -import org.thoughtcrime.securesms.profiles.AvatarHelper -import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.registration.RegistrationUtil - -class MessageBackupsTestRestoreActivity : BaseActivity() { - companion object { - fun getIntent(context: Context): Intent { - return Intent(context, MessageBackupsTestRestoreActivity::class.java) - } - } - - private val viewModel: MessageBackupsTestRestoreViewModel by viewModels() - private lateinit var importFileLauncher: ActivityResultLauncher - - private fun onPlaintextClicked() { - viewModel.onPlaintextToggled() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.data?.let { uri -> - contentResolver.getLength(uri)?.let { length -> - viewModel.import(length) { contentResolver.openInputStream(uri)!! } - } - } ?: Toast.makeText(this, "No URI selected", Toast.LENGTH_SHORT).show() - } - } - - setContent { - val state by viewModel.state - Surface { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - Buttons.LargePrimary( - onClick = this@MessageBackupsTestRestoreActivity::restoreFromServer, - enabled = !state.importState.inProgress - ) { - Text("Restore") - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - StateLabel(text = "Plaintext?") - Spacer(modifier = Modifier.width(8.dp)) - Switch( - checked = state.plaintext, - onCheckedChange = { onPlaintextClicked() } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Buttons.LargePrimary( - onClick = { - val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT - type = "application/octet-stream" - addCategory(Intent.CATEGORY_OPENABLE) - } - - importFileLauncher.launch(intent) - }, - enabled = !state.importState.inProgress - ) { - Text("Import from file") - } - - Spacer(modifier = Modifier.height(8.dp)) - - Dividers.Default() - - Buttons.LargeTonal( - onClick = { continueRegistration() }, - enabled = !state.importState.inProgress - ) { - Text("Continue Reg Flow") - } - } - } - if (state.importState == MessageBackupsTestRestoreViewModel.ImportState.RESTORED) { - SideEffect { - RegistrationUtil.maybeMarkRegistrationComplete() - AppDependencies.jobManager.add(ProfileUploadJob()) - startActivity(MainActivity.clearTop(this)) - } - } - } - } - - private fun restoreFromServer() { - viewModel.restore() - } - - private fun continueRegistration() { - if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) { - val main = MainActivity.clearTop(this) - val profile = CreateProfileActivity.getIntentForUserProfile(this) - profile.putExtra("next_intent", main) - startActivity(profile) - } else { - RegistrationUtil.maybeMarkRegistrationComplete() - AppDependencies.jobManager.add(ProfileUploadJob()) - startActivity(MainActivity.clearTop(this)) - } - finish() - } - - @Composable - private fun StateLabel(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt similarity index 78% rename from app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreViewModel.kt rename to app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt index 40d41b965a..29cb2dfcf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTestRestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt @@ -17,18 +17,23 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.backup.v2.RestoreV2Event import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.BackupRestoreJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.RegistrationUtil import java.io.InputStream import kotlin.time.Duration.Companion.seconds -class MessageBackupsTestRestoreViewModel : ViewModel() { +class RemoteRestoreViewModel : ViewModel() { val disposables = CompositeDisposable() - private val _state: MutableState = mutableStateOf(ScreenState(importState = ImportState.NONE, plaintext = false)) + private val _state: MutableState = mutableStateOf(ScreenState(backupTier = SignalStore.backup().backupTier, importState = ImportState.NONE, restoreProgress = null)) + val state: State = _state fun import(length: Long, inputStreamFactory: () -> InputStream) { @@ -37,7 +42,7 @@ class MessageBackupsTestRestoreViewModel : ViewModel() { val self = Recipient.self() val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) - disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = _state.value.plaintext) } + disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { @@ -54,6 +59,7 @@ class MessageBackupsTestRestoreViewModel : ViewModel() { .then(SyncArchivedMediaJob()) .then(BackupRestoreMediaJob()) .enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds) + RegistrationUtil.maybeMarkRegistrationComplete() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -62,8 +68,8 @@ class MessageBackupsTestRestoreViewModel : ViewModel() { } } - fun onPlaintextToggled() { - _state.value = _state.value.copy(plaintext = !_state.value.plaintext) + fun updateRestoreProgress(restoreEvent: RestoreV2Event) { + _state.value = _state.value.copy(restoreProgress = restoreEvent) } override fun onCleared() { @@ -71,8 +77,9 @@ class MessageBackupsTestRestoreViewModel : ViewModel() { } data class ScreenState( + val backupTier: MessageBackupTier?, val importState: ImportState, - val plaintext: Boolean + val restoreProgress: RestoreV2Event? ) enum class ImportState(val inProgress: Boolean = false) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index a69dbd2a99..73aa7933d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -816,6 +816,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter SignalStore.account().setRegistered(false) SignalStore.registrationValues().clearRegistrationComplete() SignalStore.registrationValues().clearHasUploadedProfile() + SignalStore.registrationValues().clearSkippedTransferOrRestore() Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt index 69da6dd7b9..9c946f0858 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt @@ -5,11 +5,13 @@ package org.thoughtcrime.securesms.jobs +import org.greenrobot.eventbus.EventBus import org.signal.core.util.logging.Log import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.RestoreState import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.RestoreV2Event import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint @@ -71,6 +73,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par progress = progress.toFloat() / total.toFloat(), indeterminate = false ) + EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress, total)) } override fun shouldCancel() = isCanceled diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index a8cab496a6..0d8362e86e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -60,7 +60,7 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1) var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1) var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer) - var backupTier: MessageBackupTier by enumValue(KEY_BACKUP_TIER, MessageBackupTier.FREE, MessageBackupTier.Serializer) + var backupTier: MessageBackupTier? by enumValue(KEY_BACKUP_TIER, null, MessageBackupTier.Serializer) val totalBackupSize: Long get() = lastBackupProtoSize + usedBackupMediaSpace diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index fca0bdb1ce..0982b162cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -199,14 +199,6 @@ public final class InternalValues extends SignalStoreValues { return FeatureFlags.internalUser() && getBoolean(CONVERSATION_ITEM_V2_MEDIA, false); } - public void setForceEnterRestoreV2Flow(boolean enter) { - putBoolean(FORCE_ENTER_RESTORE_V2_FLOW, enter); - } - - public boolean enterRestoreV2Flow() { - return FeatureFlags.restoreAfterRegistration() && getBoolean(FORCE_ENTER_RESTORE_V2_FLOW, false); - } - public synchronized void setWebSocketShadowingStats(byte[] bytes) { putBlob(WEB_SOCKET_SHADOWING_STATS, bytes); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java index 273cff76f5..0e116e10c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java @@ -9,11 +9,12 @@ import java.util.List; public final class RegistrationValues extends SignalStoreValues { - private static final String REGISTRATION_COMPLETE = "registration.complete"; - private static final String PIN_REQUIRED = "registration.pin_required"; - private static final String HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"; - private static final String SESSION_E164 = "registration.session_e164"; - private static final String SESSION_ID = "registration.session_id"; + private static final String REGISTRATION_COMPLETE = "registration.complete"; + private static final String PIN_REQUIRED = "registration.pin_required"; + private static final String HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"; + private static final String SESSION_E164 = "registration.session_e164"; + private static final String SESSION_ID = "registration.session_id"; + private static final String SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore"; RegistrationValues(@NonNull KeyValueStore store) { super(store); @@ -24,6 +25,7 @@ public final class RegistrationValues extends SignalStoreValues { .putBoolean(HAS_UPLOADED_PROFILE, false) .putBoolean(REGISTRATION_COMPLETE, false) .putBoolean(PIN_REQUIRED, true) + .putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) .commit(); } @@ -68,6 +70,18 @@ public final class RegistrationValues extends SignalStoreValues { putString(SESSION_ID, sessionId); } + public boolean hasSkippedTransferOrRestore() { + return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false); + } + + public void markSkippedTransferOrRestore() { + putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true); + } + + public void clearSkippedTransferOrRestore() { + putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false); + } + @Nullable public String getSessionId() { return getString(SESSION_ID, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java index 3b74148976..8643676443 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java @@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTestRestoreActivity; +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.jobs.ProfileUploadJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -38,7 +38,9 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.registration.RegistrationUtil; import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate; +import org.thoughtcrime.securesms.restore.RestoreActivity; import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SupportEmailUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; @@ -238,8 +240,8 @@ public class PinRestoreEntryFragment extends LoggingFragment { Activity activity = requireActivity(); - if (BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED) { - startActivity(MessageBackupsTestRestoreActivity.Companion.getIntent(activity)); + if (FeatureFlags.messageBackups()) { + startActivity(RestoreActivity.getIntentForTransferOrRestore(activity)); } else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) { final Intent main = MainActivity.clearTop(activity); final Intent profile = CreateProfileActivity.getIntentForUserProfile(activity); diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.kt index 08d7556bec..ce5c9b61f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreViewModel.kt @@ -7,6 +7,7 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.lock.v2.PinKeyboardType import org.thoughtcrime.securesms.lock.v2.SvrConstants import org.thoughtcrime.securesms.util.DefaultValueLiveData @@ -35,7 +36,11 @@ class PinRestoreViewModel : ViewModel() { } disposables += Single - .fromCallable { repo.restoreMasterKeyPostRegistration(pin, pinKeyboardType) } + .fromCallable { + val response = repo.restoreMasterKeyPostRegistration(pin, pinKeyboardType) + BackupRepository.restoreBackupTier() + response + } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { result -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt index 2d62e9ea32..9a24a41420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt @@ -23,7 +23,9 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver +import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.FeatureFlags /** * Activity to hold the entire registration process. @@ -76,8 +78,6 @@ class RegistrationV2Activity : BaseActivity() { Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin") - SignalStore.internalValues().setForceEnterRestoreV2Flow(true) - if (!needsProfile && !needsPin) { sharedViewModel.completeRegistration() } @@ -86,9 +86,9 @@ class RegistrationV2Activity : BaseActivity() { val startIntent = MainActivity.clearTop(this).apply { if (needsPin) { putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationV2Activity)) - } - - if (needsProfile) { + } else if (!SignalStore.registrationValues().hasSkippedTransferOrRestore() && FeatureFlags.messageBackups()) { + putExtra("next_intent", RemoteRestoreActivity.getIntent(this@RegistrationV2Activity)) + } else if (needsProfile) { putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(this@RegistrationV2Activity)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt index 9dd5d51344..f7fdff2d5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2ViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.withContext import org.signal.core.util.Stopwatch import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob @@ -748,6 +749,8 @@ class RegistrationV2ViewModel : ViewModel() { Log.v(TAG, "onSuccessfulRegistration()") RegistrationRepository.registerAccountLocally(context, registrationData, remoteResult, reglockEnabled) + restoreBackupTier() + if (reglockEnabled) { SignalStore.onboarding().clearAll() val stopwatch = Stopwatch("RegistrationLockRestore") @@ -838,6 +841,12 @@ class RegistrationV2ViewModel : ViewModel() { companion object { private val TAG = Log.tag(RegistrationV2ViewModel::class.java) + private suspend fun restoreBackupTier() = withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + BackupRepository.restoreBackupTier() + Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to restore the backup tier..") + } + private suspend fun refreshFeatureFlags() = withContext(Dispatchers.IO) { val startTime = System.currentTimeMillis() try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt index 232f615dfc..e365981e1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt @@ -104,7 +104,7 @@ class GrantPermissionsV2Fragment : ComposeFragment() { when (welcomeAction) { WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsV2FragmentDirections.actionEnterPhoneNumber()) WelcomeAction.RESTORE_BACKUP -> { - val restoreIntent = RestoreActivity.getIntentForRestore(requireActivity()) + val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity()) launchRestoreActivity.launch(restoreIntent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/restore/RemoteRestoreActivity.kt new file mode 100644 index 0000000000..649742078a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/restore/RemoteRestoreActivity.kt @@ -0,0 +1,364 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.v2.ui.restore + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.BaseActivity +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.backup.v2.RestoreV2Event +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow +import org.thoughtcrime.securesms.backup.v2.ui.subscription.RemoteRestoreViewModel +import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.ProfileUploadJob +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.RegistrationUtil +import org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreMoreOptionsDialog +import org.thoughtcrime.securesms.util.Util + +class RemoteRestoreActivity : BaseActivity() { + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, RemoteRestoreActivity::class.java) + } + } + + private val viewModel: RemoteRestoreViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val state by viewModel.state + SignalTheme { + Surface { + RestoreFromBackupContent( + features = getFeatureList(state.backupTier), + onRestoreBackupClick = { + viewModel.restore() + }, + onCancelClick = { + finish() + }, + onMoreOptionsClick = { + TransferOrRestoreMoreOptionsDialog.show(fragmentManager = supportFragmentManager, skipOnly = false) + }, + state.backupTier, + state.backupTier != MessageBackupTier.PAID + ) + if (state.importState == RemoteRestoreViewModel.ImportState.RESTORED) { + SideEffect { + RegistrationUtil.maybeMarkRegistrationComplete() + AppDependencies.jobManager.add(ProfileUploadJob()) + startActivity(MainActivity.clearTop(this)) + } + } else if (state.importState == RemoteRestoreViewModel.ImportState.IN_PROGRESS) { + ProgressDialog(state.restoreProgress) + } + } + } + } + EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onEvent(restoreEvent: RestoreV2Event) { + viewModel.updateRestoreProgress(restoreEvent) + } + + private fun getFeatureList(tier: MessageBackupTier?): ImmutableList { + return when (tier) { + null -> persistentListOf() + MessageBackupTier.PAID -> { + persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "All of your media" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_recent_compact_bold_16, + label = "All of your text messages" + ) + ) + } + MessageBackupTier.FREE -> { + persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Your last 30 days of media" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_recent_compact_bold_16, + label = "All of your text messages" + ) + ) + } + } + } + + /** + * A dialog that *just* shows a spinner. Useful for short actions where you need to + * let the user know that some action is completing. + */ + @Composable + fun ProgressDialog(restoreProgress: RestoreV2Event?) { + androidx.compose.material3.AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {}, + text = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.wrapContentSize() + ) { + if (restoreProgress == null) { + CircularProgressIndicator( + modifier = Modifier + .padding(top = 55.dp, bottom = 16.dp) + .width(48.dp) + .height(48.dp) + ) + } else { + CircularProgressIndicator( + progress = restoreProgress.getProgress(), + modifier = Modifier + .padding(top = 55.dp, bottom = 16.dp) + .width(48.dp) + .height(48.dp) + ) + } + + // TODO [message-backups] Finalized copy. + val progressText = when (restoreProgress?.type) { + RestoreV2Event.Type.PROGRESS_DOWNLOAD -> "Downloading backup..." + RestoreV2Event.Type.PROGRESS_RESTORE -> "Restoring messages..." + else -> "Restoring..." + } + + Text( + text = progressText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + if (restoreProgress != null) { + val progressBytes = Util.getPrettyFileSize(restoreProgress.count) + val totalBytes = Util.getPrettyFileSize(restoreProgress.estimatedTotalCount) + Text( + text = "$progressBytes of $totalBytes (%.2f%%)".format(restoreProgress.getProgress()), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 12.dp) + ) + } + } + } + }, + modifier = Modifier.width(212.dp) + ) + } + + @Preview + @Composable + private fun ProgressDialogPreview() { + Previews.Preview { + ProgressDialog(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, 10, 1000)) + } + } + + @Preview + @Composable + private fun RestoreFromBackupContentPreview() { + Previews.Preview { + RestoreFromBackupContent( + features = persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Your last 30 days of media" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_recent_compact_bold_16, + label = "All of your text messages" + ) + ), + onRestoreBackupClick = {}, + onCancelClick = {}, + onMoreOptionsClick = {}, + MessageBackupTier.PAID, + true + ) + } + } + + @Composable + private fun RestoreFromBackupContent( + features: ImmutableList, + onRestoreBackupClick: () -> Unit, + onCancelClick: () -> Unit, + onMoreOptionsClick: () -> Unit, + tier: MessageBackupTier?, + cancelable: Boolean + ) { + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 40.dp, bottom = 24.dp) + ) { + Text( + text = "Restore from backup", // TODO [message-backups] Finalized copy. + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + val yourLastBackupText = buildAnnotatedString { + append("Your last backup was made on March 5, 2024 at 9:00am.") // TODO [message-backups] Finalized copy. + append(" ") + if (tier != MessageBackupTier.PAID) { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append("Only media sent or received in the past 30 days is included.") // TODO [message-backups] Finalized copy. + } + } + } + + Text( + text = yourLastBackupText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 28.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp)) + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 18.dp) + ) { + Text( + text = "Your backup includes:", // TODO [message-backups] Finalized copy. + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 6.dp) + ) + + features.forEach { + MessageBackupsTypeFeatureRow( + messageBackupsTypeFeature = it, + iconTint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 6.dp) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Buttons.LargeTonal( + onClick = onRestoreBackupClick, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Restore backup" // TODO [message-backups] Finalized copy. + ) + } + + if (cancelable) { + TextButton( + onClick = onCancelClick, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = android.R.string.cancel) + ) + } + } else { + TextButton( + onClick = onMoreOptionsClick, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.TransferOrRestoreFragment__more_options) + ) + } + } + } + } + + private fun restoreFromServer() { + viewModel.restore() + } + + private fun continueRegistration() { + if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) { + val main = MainActivity.clearTop(this) + val profile = CreateProfileActivity.getIntentForUserProfile(this) + profile.putExtra("next_intent", main) + startActivity(profile) + } else { + RegistrationUtil.maybeMarkRegistrationComplete() + AppDependencies.jobManager.add(ProfileUploadJob()) + startActivity(MainActivity.clearTop(this)) + } + finish() + } + + @Composable + private fun StateLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt index da93a068b2..56abd4e0b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt @@ -28,8 +28,10 @@ import org.thoughtcrime.securesms.registration.v2.ui.grantpermissions.GrantPermi import org.thoughtcrime.securesms.restore.RestoreActivity import org.thoughtcrime.securesms.util.BackupUtil import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.visible /** * First screen that is displayed on the very first app launch. @@ -59,6 +61,7 @@ class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome binding.welcomeContinueButton.setOnClickListener { onContinueClicked() } binding.welcomeTermsButton.setOnClickListener { onTermsClicked() } binding.welcomeTransferOrRestore.setOnClickListener { onTransferOrRestoreClicked() } + binding.welcomeTransferOrRestore.visible = !FeatureFlags.restoreAfterRegistration() } private fun onContinueClicked() { @@ -86,7 +89,7 @@ class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome } else { sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED) - val restoreIntent = RestoreActivity.getIntentForRestore(requireActivity()) + val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity()) launchRestoreActivity.launch(restoreIntent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt index eb85a2fd4d..38311bad2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt @@ -9,10 +9,14 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.navigation.findNavController import org.signal.core.util.getParcelableExtraCompat import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme /** @@ -33,6 +37,13 @@ class RestoreActivity : BaseActivity() { intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let { sharedViewModel.setNextIntent(it) } + + val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.NONE.value)) + when (navTarget) { + NavTarget.LOCAL_RESTORE -> findNavController(R.id.nav_host_fragment).navigate(R.id.choose_local_backup_fragment) + NavTarget.TRANSFER -> findNavController(R.id.nav_host_fragment).navigate(R.id.newDeviceTransferInstructions) + else -> Unit + } } override fun onResume() { @@ -46,8 +57,41 @@ class RestoreActivity : BaseActivity() { } companion object { + + enum class NavTarget(val value: Int) { + NONE(0), + TRANSFER(1), + LOCAL_RESTORE(2); + + companion object { + fun deserialize(value: Int): NavTarget { + return values().firstOrNull { it.value == value } ?: NONE + } + } + } + + private const val EXTRA_NAV_TARGET = "nav_target" + @JvmStatic - fun getIntentForRestore(context: Context): Intent { + fun getIntentForTransfer(context: Context): Intent { + return Intent(context, RestoreActivity::class.java).apply { + putExtra(EXTRA_NAV_TARGET, NavTarget.TRANSFER.value) + } + } + + @JvmStatic + fun getIntentForLocalRestore(context: Context): Intent { + return Intent(context, RestoreActivity::class.java).apply { + putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value) + } + } + + @JvmStatic + fun getIntentForTransferOrRestore(context: Context): Intent { + val tier = SignalStore.backup().backupTier + if (tier == MessageBackupTier.PAID) { + return Intent(context, RemoteRestoreActivity::class.java) + } return Intent(context, RestoreActivity::class.java) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteV2Fragment.kt index 2aa6f67135..661d8ddedd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteV2Fragment.kt @@ -10,7 +10,6 @@ import android.view.View import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.restore.RestoreActivity /** @@ -29,7 +28,6 @@ class RestoreCompleteV2Fragment : LoggingFragment(R.layout.fragment_registration private fun onBackupCompletedSuccessfully() { Log.d(TAG, "onBackupCompletedSuccessfully()") - SignalStore.internalValues().setForceEnterRestoreV2Flow(false) val activity = requireActivity() as RestoreActivity activity.finishActivitySuccessfully() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt index 2e46bbf11a..d01d4053c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt @@ -111,7 +111,6 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc private fun onBackupCompletedSuccessfully() { Log.d(TAG, "onBackupCompletedSuccessfully()") - SignalStore.internalValues().setForceEnterRestoreV2Flow(false) val activity = requireActivity() as RestoreActivity navigationViewModel.getNextIntent()?.let { Log.d(TAG, "Launching ${it.component}") diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt new file mode 100644 index 0000000000..39f5f23fdc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.restore.transferorrestore + +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.databinding.TransferOrRestoreOptionsBottomSheetDialogFragmentBinding +import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity +import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity +import org.thoughtcrime.securesms.restore.RestoreActivity +import org.thoughtcrime.securesms.util.visible + +class TransferOrRestoreMoreOptionsDialog : FixedRoundedCornerBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 1f + + private val viewModel by viewModels() + private lateinit var binding: TransferOrRestoreOptionsBottomSheetDialogFragmentBinding + + companion object { + + const val TAG = "TRANSFER_OR_RESTORE_OPTIONS_DIALOG_FRAGMENT" + const val ARG_SKIP_ONLY = "skip_only" + + fun show(fragmentManager: FragmentManager, skipOnly: Boolean) { + TransferOrRestoreMoreOptionsDialog().apply { + arguments = bundleOf(ARG_SKIP_ONLY to skipOnly) + }.show(fragmentManager, TAG) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = TransferOrRestoreOptionsBottomSheetDialogFragmentBinding.inflate(inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)), container, false) + if (arguments?.getBoolean(ARG_SKIP_ONLY, false) ?: false) { + binding.transferCard.visible = false + binding.localRestoreCard.visible = false + } + binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(viewModel.getBackupRestorationType()) } + binding.transferCard.setOnClickListener { viewModel.onTransferFromAndroidDeviceSelected() } + binding.localRestoreCard.setOnClickListener { viewModel.onRestoreFromLocalBackupSelected() } + binding.skipCard.setOnClickListener { viewModel.onSkipRestoreOrTransferSelected() } + binding.cancel.setOnClickListener { dismiss() } + + viewModel.uiState.observe(viewLifecycleOwner) { state -> + updateSelection(state.restorationType) + } + + return binding.root + } + + private fun launchSelection(restorationType: BackupRestorationType?) { + when (restorationType) { + BackupRestorationType.DEVICE_TRANSFER -> { + startActivity(RestoreActivity.getIntentForTransfer(requireContext())) + } + BackupRestorationType.LOCAL_BACKUP -> { + startActivity(RestoreActivity.getIntentForLocalRestore(requireContext())) + } + BackupRestorationType.REMOTE_BACKUP -> { + startActivity(RemoteRestoreActivity.getIntent(requireContext())) + } + BackupRestorationType.NONE -> { + SignalStore.registrationValues().markSkippedTransferOrRestore() + val startIntent = MainActivity.clearTop(requireContext()).apply { + putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(requireContext())) + } + startActivity(startIntent) + } + else -> { + return + } + } + dismiss() + } + + private fun updateSelection(restorationType: BackupRestorationType?) { + binding.transferCard.isSelected = restorationType == BackupRestorationType.DEVICE_TRANSFER + binding.localRestoreCard.isSelected = restorationType == BackupRestorationType.LOCAL_BACKUP + binding.skipCard.isSelected = restorationType == BackupRestorationType.NONE + binding.transferOrRestoreFragmentNext.isEnabled = restorationType != null + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt index 7622f03771..81aaf595fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt @@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.restore.transferorrestore import android.os.Bundle import android.view.View -import android.widget.Toast import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.NavHostFragment import org.signal.core.util.logging.Log @@ -16,7 +15,9 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreV2Binding import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate +import org.thoughtcrime.securesms.registration.v2.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.restore.RestoreViewModel import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.SpanUtil @@ -39,7 +40,11 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener { sharedViewModel.onRestoreFromRemoteBackupSelected() } binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(sharedViewModel.getBackupRestorationType()) } binding.transferOrRestoreFragmentMoreOptions.setOnClickListener { - Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2] + TransferOrRestoreMoreOptionsDialog.show(fragmentManager = childFragmentManager, skipOnly = true) + } + + if (SignalStore.backup().backupTier == null) { + binding.transferOrRestoreFragmentRestoreRemoteCard.visible = false } binding.transferOrRestoreFragmentRestoreRemoteCard.visible = FeatureFlags.messageBackups() @@ -72,9 +77,7 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r NavHostFragment.findNavController(this).safeNavigate(TransferOrRestoreV2FragmentDirections.actionTransferOrRestoreToRestore()) } BackupRestorationType.REMOTE_BACKUP -> { - // TODO [regv2] - Log.w(TAG, "Not yet implemented!", NotImplementedError()) - Toast.makeText(requireContext(), "Not yet implemented!", Toast.LENGTH_LONG).show() + startActivity(RemoteRestoreActivity.getIntent(requireContext())) } else -> { throw IllegalArgumentException() diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt new file mode 100644 index 0000000000..d309c35da1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.restore.transferorrestore + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType + +class TransferOrRestoreViewModel : ViewModel() { + + private val store = MutableStateFlow(State()) + val uiState = store.asLiveData() + + fun onSkipRestoreOrTransferSelected() { + store.update { + it.copy(restorationType = BackupRestorationType.NONE) + } + } + + fun onTransferFromAndroidDeviceSelected() { + store.update { + it.copy(restorationType = BackupRestorationType.DEVICE_TRANSFER) + } + } + + fun onRestoreFromLocalBackupSelected() { + store.update { + it.copy(restorationType = BackupRestorationType.LOCAL_BACKUP) + } + } + + fun getBackupRestorationType(): BackupRestorationType? { + return store.value.restorationType + } +} + +data class State(val restorationType: BackupRestorationType? = null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index eb7676df42..1d9a649383 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -733,7 +733,7 @@ public final class FeatureFlags { /** Whether or not to launch the restore activity after registration is complete, rather than before. */ public static boolean restoreAfterRegistration() { - return getBoolean(RESTORE_POST_REGISTRATION, false); + return BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED || getBoolean(RESTORE_POST_REGISTRATION, false); } /** diff --git a/app/src/main/res/drawable/ic_continue_no_restore_48.xml b/app/src/main/res/drawable/ic_continue_no_restore_48.xml new file mode 100644 index 0000000000..b930186f63 --- /dev/null +++ b/app/src/main/res/drawable/ic_continue_no_restore_48.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/ic_transfer_local_48.xml b/app/src/main/res/drawable/ic_transfer_local_48.xml new file mode 100644 index 0000000000..b290196c53 --- /dev/null +++ b/app/src/main/res/drawable/ic_transfer_local_48.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/activity_remote_restore.xml b/app/src/main/res/layout/activity_remote_restore.xml new file mode 100644 index 0000000000..883b564737 --- /dev/null +++ b/app/src/main/res/layout/activity_remote_restore.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_transfer_restore_v2.xml b/app/src/main/res/layout/fragment_transfer_restore_v2.xml index 883b564737..9d2cd64ebd 100644 --- a/app/src/main/res/layout/fragment_transfer_restore_v2.xml +++ b/app/src/main/res/layout/fragment_transfer_restore_v2.xml @@ -129,7 +129,7 @@ android:layout_height="wrap_content" android:layout_marginStart="18dp" android:layout_marginEnd="16dp" - android:text="@string/TransferOrRestoreFragment__restore_from_backup" + android:text="@string/TransferOrRestoreFragment__restore_from_signal_backup" android:textAppearance="@style/Signal.Text.Body" app:layout_constraintBottom_toTopOf="@+id/transfer_or_restore_fragment_restore_remote_description" app:layout_constraintEnd_toEndOf="parent" @@ -143,7 +143,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:text="@string/TransferOrRestoreFragment__restore_your_messages_from_a_local_backup" + android:text="@string/TransferOrRestoreFragment__restore_your_messages_from_a_signal_backup" android:textColor="@color/signal_colorOnSurfaceVariant" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -173,22 +173,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75cae3272f..b8ebc063ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4143,9 +4143,16 @@ You need access to your old device. Restore from backup Restore your messages from a local backup. If you don’t restore now, you won\'t be able to restore later. + Restore local backup + Restore Signal backup + Restore all your text messages + your last 30 days of media More options + Cancel + Log in without transferring + Continue without transferring your messages and media + Open Signal on your old Android phone Continue diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java index 32cf046f8c..540c66236d 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -28,6 +28,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; @@ -224,6 +225,10 @@ public class SignalServiceMessageReceiver { socket.retrieveBackup(cdnNumber, headers, cdnPath, destination, 1_000_000_000L, listener); } + public boolean checkBackupExistence(int cdnNumber, Map headers, String cdnPath) throws MissingConfigurationException, IOException { + return socket.checkForBackup(cdnNumber, headers, cdnPath); + } + public InputStream retrieveSticker(byte[] packId, byte[] packKey, int stickerId) throws IOException, InvalidMessageException { 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 5ca7baa7b1..e05d72eb40 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 @@ -257,7 +257,7 @@ class ArchiveApi( } } - private fun getZkCredential(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): BackupAuthCredential { + 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 06657cdd5e..bece777efd 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 @@ -948,6 +948,10 @@ public class PushServiceSocket { downloadFromCdn(destination, cdnNumber, headers, cdnPath, maxSizeBytes, listener); } + public boolean checkForBackup(int cdnNumber, Map headers, String cdnPath) throws PushNetworkException, MissingConfigurationException, NonSuccessfulResponseCodeException { + return checkExistsOnCdn(cdnNumber, headers, cdnPath); + } + public void retrieveAttachment(int cdnNumber, Map headers, SignalServiceAttachmentRemoteId cdnPath, File destination, long maxSizeBytes, ProgressListener listener) throws IOException, MissingConfigurationException { @@ -1709,6 +1713,51 @@ public class PushServiceSocket { } } + private boolean checkExistsOnCdn(int cdnNumber, Map headers, String path) throws MissingConfigurationException, PushNetworkException, NonSuccessfulResponseCodeException { + ConnectionHolder[] cdnNumberClients = cdnClientsMap.get(cdnNumber); + if (cdnNumberClients == null) { + throw new MissingConfigurationException("Attempted to download from unsupported CDN number: " + cdnNumber + ", Our configuration supports: " + cdnClientsMap.keySet()); + } + ConnectionHolder connectionHolder = getRandom(cdnNumberClients, random); + OkHttpClient okHttpClient = connectionHolder.getClient() + .newBuilder() + .connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + + Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + "/" + path).get(); + + if (connectionHolder.getHostHeader().isPresent()) { + request.addHeader("Host", connectionHolder.getHostHeader().get()); + } + + for (Map.Entry header : headers.entrySet()) { + request.addHeader(header.getKey(), header.getValue()); + } + + Call call = okHttpClient.newCall(request.build()); + + synchronized (connections) { + connections.add(call); + } + + try (Response response = call.execute()) { + if (response.isSuccessful()) { + return true; + } else { + throw new NonSuccessfulResponseCodeException(response.code(), "Response: " + response); + } + } catch (NonSuccessfulResponseCodeException | PushNetworkException e) { + throw e; + } catch (IOException e) { + throw new PushNetworkException(e); + } finally { + synchronized (connections) { + connections.remove(call); + } + } + } + private AttachmentDigest uploadToCdn0(String path, String acl, String key, String policy, String algorithm, String credential, String date, String signature, InputStream data, String contentType, long length, boolean incremental,