mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 00:01:08 +01:00
Pre-Registration Restoration from Local Unified Backup.
This commit is contained in:
committed by
Cody Henthorne
parent
7e605fb6de
commit
c9dd332abd
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
|
||||
AppSettingsRoute.Empty -> null
|
||||
is AppSettingsRoute.BackupsRoute.Local -> {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
|
||||
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
|
||||
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBa
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
@@ -104,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
},
|
||||
onOnDeviceBackupsRowClick = {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
|
||||
}
|
||||
},
|
||||
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
|
||||
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
|
||||
)
|
||||
}
|
||||
@@ -122,7 +121,6 @@ private fun BackupsSettingsContent(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
onOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onNewOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
@@ -241,16 +239,6 @@ private fun BackupsSettingsContent(
|
||||
onClick = onOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
|
||||
if (backupsSettingsState.showNewLocalBackup) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "INTERNAL ONLY - New Local Backup",
|
||||
label = "Use new local backup format",
|
||||
onClick = onNewOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,5 @@ data class BackupsSettingsState(
|
||||
val backupState: BackupState,
|
||||
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
val showBackupTierInternalOverride: Boolean = false,
|
||||
val backupTierInternalOverride: MessageBackupTier? = null,
|
||||
val showNewLocalBackup: Boolean = false
|
||||
val backupTierInternalOverride: MessageBackupTier? = null
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class BackupsSettingsViewModel : ViewModel() {
|
||||
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
backupState = enabledState,
|
||||
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING,
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
|
||||
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.time.LocalTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* App settings internal screen for enabling and creating new local backups.
|
||||
*/
|
||||
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
|
||||
|
||||
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
|
||||
|
||||
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private var createStatus by mutableStateOf("None")
|
||||
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
chooseBackupLocationLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
|
||||
handleBackupLocationSelected(result.data!!.data!!)
|
||||
} else {
|
||||
Log.w(TAG, "Backup location selection cancelled or failed")
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: LocalBackupV2Event) {
|
||||
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val context = LocalContext.current
|
||||
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
|
||||
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
|
||||
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
|
||||
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
|
||||
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
|
||||
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = backupsEnabled,
|
||||
selectedDirectory = selectedDirectory,
|
||||
lastBackupTimeString = lastBackupTimeString,
|
||||
backupTime = backupTime,
|
||||
createStatus = createStatus,
|
||||
callback = CallbackImpl()
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchBackupDirectoryPicker() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
|
||||
if (latestDirectory != null) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Launching backup directory picker")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to launch backup directory picker", e)
|
||||
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackupLocationSelected(uri: Uri) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
|
||||
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
|
||||
return if (lastBackupTimestamp > 0) {
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
context,
|
||||
Locale.getDefault(),
|
||||
lastBackupTimestamp
|
||||
)
|
||||
|
||||
if (relativeTime.isRelative) {
|
||||
relativeTime.value
|
||||
} else {
|
||||
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
|
||||
val time = relativeTime.value
|
||||
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CallbackImpl : Callback {
|
||||
override fun onNavigationClick() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onToggleBackupsClick(enabled: Boolean) {
|
||||
SignalStore.backup.newLocalBackupsEnabled = enabled
|
||||
if (enabled) {
|
||||
LocalBackupListener.schedule(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectDirectoryClick() {
|
||||
launchBackupDirectoryPicker()
|
||||
}
|
||||
|
||||
override fun onEnqueueBackupClick() {
|
||||
createStatus = "Starting..."
|
||||
LocalBackupJob.enqueueArchive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface Callback {
|
||||
fun onNavigationClick()
|
||||
fun onToggleBackupsClick(enabled: Boolean)
|
||||
fun onSelectDirectoryClick()
|
||||
fun onEnqueueBackupClick()
|
||||
|
||||
object Empty : Callback {
|
||||
override fun onNavigationClick() = Unit
|
||||
override fun onToggleBackupsClick(enabled: Boolean) = Unit
|
||||
override fun onSelectDirectoryClick() = Unit
|
||||
override fun onEnqueueBackupClick() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalLocalBackupScreen(
|
||||
backupsEnabled: Boolean = false,
|
||||
selectedDirectory: String? = null,
|
||||
lastBackupTimeString: String = "Never",
|
||||
backupTime: String = "Unknown",
|
||||
createStatus: String = "None",
|
||||
callback: Callback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "New Local Backups",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
onNavigationClick = callback::onNavigationClick
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = backupsEnabled,
|
||||
text = "Enable New Local Backups",
|
||||
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
|
||||
onCheckChanged = callback::onToggleBackupsClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Last Backup",
|
||||
label = lastBackupTimeString
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Backup Schedule Time (same as v1)",
|
||||
label = backupTime
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Select Backup Directory",
|
||||
label = selectedDirectory ?: "No directory selected",
|
||||
onClick = callback::onSelectDirectoryClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Backup Now",
|
||||
label = "Enqueue LocalArchiveJob",
|
||||
onClick = callback::onEnqueueBackupClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Status",
|
||||
label = createStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun InternalLocalBackupScreenPreview() {
|
||||
Previews.Preview {
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = true,
|
||||
selectedDirectory = "/storage/emulated/0/Signal/Backups",
|
||||
lastBackupTimeString = "1 hour ago",
|
||||
callback = Callback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,11 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -31,6 +29,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
|
||||
@@ -158,11 +157,10 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
|
||||
val context = LocalContext.current
|
||||
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
|
||||
if (uri != null) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.net.Uri
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
|
||||
|
||||
class DefaultLocalBackupsSettingsCallback(
|
||||
private val fragment: LocalBackupsFragment,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
|
||||
private val viewModel: LocalBackupsViewModel
|
||||
) : LocalBackupsSettingsCallback {
|
||||
|
||||
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
}
|
||||
|
||||
override fun onLaunchBackupLocationPickerClick() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting choose backup location dialog")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.text.NumberFormat
|
||||
@@ -96,7 +95,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
val clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
|
||||
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
|
||||
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
|
||||
|
||||
if (SignalStore.backup.newLocalBackupsEnabled) {
|
||||
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {
|
||||
|
||||
@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
|
||||
|
||||
class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setTitle("Are you sure?")
|
||||
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
|
||||
.setPositiveButton("Wipe and restore") { _, _ ->
|
||||
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
|
||||
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
|
||||
}
|
||||
.show()
|
||||
},
|
||||
|
||||
@@ -103,6 +103,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_ENABLED = "backup.new_local_backups_enabled"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_DIRECTORY = "backup.new_local_backups_directory"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time"
|
||||
private const val KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP = "backup.new_local_backups_selected_snapshot_timestamp"
|
||||
|
||||
private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible"
|
||||
|
||||
@@ -486,6 +487,11 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
var newLocalBackupsLastBackupTime: Long by newLocalBackupsLastBackupTimeValue
|
||||
val newLocalBackupsLastBackupTimeFlow: Flow<Long> by lazy { newLocalBackupsLastBackupTimeValue.toFlow() }
|
||||
|
||||
/**
|
||||
* The snapshot timestamp selected for restore. Set before launching restore, cleared after completion.
|
||||
*/
|
||||
var newLocalBackupsSelectedSnapshotTimestamp: Long by longValue(KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP, -1L)
|
||||
|
||||
/**
|
||||
* When we are told by the server that we are out of storage space, we should show
|
||||
* UX treatment to make the user aware of this.
|
||||
|
||||
@@ -596,7 +596,7 @@ public final class Megaphones {
|
||||
}
|
||||
|
||||
private static boolean shouldShowUseNewOnDeviceBackupsMegaphone() {
|
||||
return RemoteConfig.unifiedLocalBackups() && SignalStore.settings().isBackupEnabled();
|
||||
return Environment.Backups.isNewFormatSupportedForLocalBackup() && SignalStore.settings().isBackupEnabled();
|
||||
}
|
||||
|
||||
private static boolean shouldShowGrantFullScreenIntentPermission(@NonNull Context context) {
|
||||
|
||||
@@ -29,22 +29,22 @@ import com.google.android.material.timepicker.TimeFormat;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.signal.core.ui.util.StorageUtil;
|
||||
import org.signal.core.util.NoExternalStorageException;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.backup.BackupDialog;
|
||||
import org.thoughtcrime.securesms.backup.BackupEvent;
|
||||
import org.signal.core.util.NoExternalStorageException;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.UpgradeLocalBackupCard;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.JavaTimeExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.signal.core.ui.util.StorageUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
@@ -231,7 +231,7 @@ public class BackupsPreferenceFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void setUpdateState() {
|
||||
if (SignalStore.settings().isBackupEnabled() && RemoteConfig.unifiedLocalBackups()) {
|
||||
if (SignalStore.settings().isBackupEnabled() && Environment.Backups.isNewFormatSupportedForLocalBackup()) {
|
||||
UpgradeLocalBackupCard.bind(upgradeCard, () -> {
|
||||
Navigation.findNavController(requireView())
|
||||
.navigate(BackupsPreferenceFragmentDirections.actionBackupsPreferenceFragmentToLocalBackupsFragment()
|
||||
|
||||
@@ -562,7 +562,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
|
||||
|
||||
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
|
||||
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToInternalNewLocalBackupRestore())
|
||||
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToRestoreLocalBackupFragment())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog
|
||||
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel
|
||||
import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
|
||||
@@ -218,30 +217,12 @@ private fun ErrorContent(
|
||||
onDismiss = onBackupTierNotRestoredDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
} else if (state.showRegistrationError) {
|
||||
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
|
||||
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
|
||||
confirm = stringResource(R.string.EnterBackupKey_try_again),
|
||||
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
|
||||
onConfirm = {},
|
||||
onDeny = onBackupKeyHelp,
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
} else {
|
||||
val message = when (state.registerAccountResult) {
|
||||
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
|
||||
}
|
||||
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RegistrationErrorDialogs(
|
||||
showRegistrationError = state.showRegistrationError,
|
||||
registerAccountResult = state.registerAccountResult,
|
||||
onRegistrationErrorDismiss = onRegistrationErrorDismiss,
|
||||
onBackupKeyHelp = onBackupKeyHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
|
||||
/**
|
||||
* Shared error dialogs for registration failures during backup key entry.
|
||||
* Used by both remote and local backup restore flows.
|
||||
*/
|
||||
@Composable
|
||||
fun RegistrationErrorDialogs(
|
||||
showRegistrationError: Boolean,
|
||||
registerAccountResult: RegisterAccountResult?,
|
||||
onRegistrationErrorDismiss: () -> Unit,
|
||||
onBackupKeyHelp: () -> Unit
|
||||
) {
|
||||
if (!showRegistrationError) return
|
||||
|
||||
if (registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
|
||||
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
|
||||
confirm = stringResource(R.string.EnterBackupKey_try_again),
|
||||
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
|
||||
onConfirm = {},
|
||||
onDeny = onBackupKeyHelp,
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
} else {
|
||||
val message = when (registerAccountResult) {
|
||||
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
|
||||
}
|
||||
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,18 @@ import org.thoughtcrime.securesms.R
|
||||
enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
|
||||
FROM_SIGNAL_BACKUPS(
|
||||
iconRes = R.drawable.symbol_signal_backups_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
|
||||
titleRes = R.string.SelectRestoreMethodFragment__restore_signal_backup,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_text_messages_and_media_from
|
||||
),
|
||||
FROM_LOCAL_BACKUP_V1(
|
||||
iconRes = R.drawable.symbol_file_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
|
||||
titleRes = R.string.SelectRestoreMethodFragment__restore_on_device_backup,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_messages_from
|
||||
),
|
||||
FROM_LOCAL_BACKUP_V2(
|
||||
iconRes = R.drawable.symbol_folder_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
|
||||
titleRes = R.string.SelectRestoreMethodFragment__restore_on_device_backup,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_messages_from
|
||||
),
|
||||
FROM_OLD_DEVICE(
|
||||
iconRes = R.drawable.symbol_transfer_24,
|
||||
|
||||
@@ -79,8 +79,8 @@ private fun RestoreMethodRowPreview() {
|
||||
Previews.Preview {
|
||||
RestoreRow(
|
||||
icon = SignalIcons.Backup.painter,
|
||||
title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
|
||||
subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
|
||||
title = stringResource(R.string.SelectRestoreMethodFragment__restore_signal_backup),
|
||||
subtitle = stringResource(R.string.SelectRestoreMethodFragment__restore_your_text_messages_and_media_from)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
|
||||
@@ -58,8 +57,8 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
|
||||
var showSkipRestoreWarning by remember { mutableStateOf(false) }
|
||||
|
||||
val restoreMethods = remember {
|
||||
if (Environment.IS_NIGHTLY || BuildConfig.DEBUG) {
|
||||
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2)
|
||||
if (Environment.Backups.isNewFormatSupportedForLocalBackup()) {
|
||||
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V2)
|
||||
} else {
|
||||
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.CircularProgressWrapper
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.RegistrationErrorDialogs
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
|
||||
@Composable
|
||||
fun EnterLocalBackupKeyScreen(
|
||||
backupKey: String,
|
||||
isRegistrationInProgress: Boolean,
|
||||
isBackupKeyValid: Boolean,
|
||||
aepValidationError: AccountEntropyPoolVerification.AEPValidationError?,
|
||||
onBackupKeyChanged: (String) -> Unit,
|
||||
onNextClicked: () -> Unit,
|
||||
onNoBackupKeyClick: () -> Unit,
|
||||
showRegistrationError: Boolean = false,
|
||||
registerAccountResult: RegisterAccountResult? = null,
|
||||
onRegistrationErrorDismiss: () -> Unit = {},
|
||||
onBackupKeyHelp: () -> Unit = {}
|
||||
) {
|
||||
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var requestFocus by remember { mutableStateOf(true) }
|
||||
|
||||
val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) }
|
||||
|
||||
RegistrationScreen(
|
||||
title = stringResource(R.string.EnterLocalBackupKeyScreen__enter_your_recovery_key),
|
||||
subtitle = stringResource(R.string.EnterLocalBackupKeyScreen__your_recovery_key_is_a_64_character_code),
|
||||
bottomContent = {
|
||||
Row {
|
||||
TextButton(
|
||||
onClick = onNoBackupKeyClick,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.outline)
|
||||
) {
|
||||
Text(text = stringResource(R.string.EnterLocalBackupKeyScreen__no_backup_key))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CircularProgressWrapper(
|
||||
isLoading = isRegistrationInProgress
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClicked,
|
||||
enabled = isBackupKeyValid && aepValidationError == null
|
||||
) {
|
||||
Text(text = stringResource(R.string.EnterLocalBackupKeyScreen__next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = backupKey,
|
||||
onValueChange = { value ->
|
||||
onBackupKeyChanged(value)
|
||||
autoFillHelper.onValueChanged(value)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (isBackupKeyValid && aepValidationError == null) {
|
||||
keyboardController?.hide()
|
||||
onNextClicked()
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = { aepValidationError?.let { ValidationErrorMessage(it) } },
|
||||
isError = aepValidationError != null,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.attachBackupKeyAutoFillHelper(autoFillHelper)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
RegistrationErrorDialogs(
|
||||
showRegistrationError = showRegistrationError,
|
||||
registerAccountResult = registerAccountResult,
|
||||
onRegistrationErrorDismiss = onRegistrationErrorDismiss,
|
||||
onBackupKeyHelp = onBackupKeyHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValidationErrorMessage(error: AccountEntropyPoolVerification.AEPValidationError) {
|
||||
when (error) {
|
||||
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, error.count, error.max))
|
||||
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
|
||||
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun EnterLocalBackupKeyScreenPreview() {
|
||||
Previews.Preview {
|
||||
EnterLocalBackupKeyScreen(
|
||||
backupKey = "",
|
||||
isRegistrationInProgress = false,
|
||||
isBackupKeyValid = false,
|
||||
aepValidationError = null,
|
||||
onBackupKeyChanged = {},
|
||||
onNextClicked = {},
|
||||
onNoBackupKeyClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.signal.core.models.AccountEntropyPool
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* Internal only registration screen to collect backup folder and AEP. Actual restore will happen
|
||||
* post-registration when the app re-routes to [org.thoughtcrime.securesms.restore.RestoreActivity] and then
|
||||
* [InternalNewLocalRestoreActivity]. Yay implicit navigation!
|
||||
*/
|
||||
class InternalNewLocalBackupRestore : ComposeFragment() {
|
||||
|
||||
private val TAG = Log.tag(InternalNewLocalBackupRestore::class)
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
|
||||
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
chooseBackupLocationLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
|
||||
handleBackupLocationSelected(result.data!!.data!!)
|
||||
} else {
|
||||
Log.w(TAG, "Backup location selection cancelled or failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val selectedDirectory: String? by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
|
||||
|
||||
InternalNewLocalBackupRestoreScreen(
|
||||
selectedDirectory = selectedDirectory,
|
||||
callback = CallbackImpl()
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchBackupDirectoryPicker() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val currentDirectory = SignalStore.backup.newLocalBackupsDirectory
|
||||
if (currentDirectory != null) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(currentDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Launching backup directory picker")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to launch backup directory picker", e)
|
||||
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackupLocationSelected(uri: Uri) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
|
||||
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private inner class CallbackImpl : Callback {
|
||||
override fun onSelectDirectoryClick() {
|
||||
launchBackupDirectoryPicker()
|
||||
}
|
||||
|
||||
override fun onRestoreClick(backupKey: String) {
|
||||
sharedViewModel.registerWithBackupKey(
|
||||
context = requireContext(),
|
||||
backupKey = backupKey,
|
||||
e164 = null,
|
||||
pin = null,
|
||||
aciIdentityKeyPair = null,
|
||||
pniIdentityKeyPair = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface Callback {
|
||||
fun onSelectDirectoryClick()
|
||||
fun onRestoreClick(backupKey: String)
|
||||
|
||||
object Empty : Callback {
|
||||
override fun onSelectDirectoryClick() = Unit
|
||||
override fun onRestoreClick(backupKey: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalNewLocalBackupRestoreScreen(
|
||||
selectedDirectory: String? = null,
|
||||
callback: Callback
|
||||
) {
|
||||
var backupKey by remember { mutableStateOf("") }
|
||||
var isBackupKeyValid by remember { mutableStateOf(false) }
|
||||
var aepValidationError by remember { mutableStateOf<AccountEntropyPoolVerification.AEPValidationError?>(null) }
|
||||
|
||||
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var requestFocus by remember { mutableStateOf(true) }
|
||||
|
||||
val autoFillHelper = backupKeyAutoFillHelper { newValue ->
|
||||
backupKey = newValue
|
||||
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
|
||||
backupKey = newValue,
|
||||
changed = true,
|
||||
previousAEPValidationError = aepValidationError
|
||||
)
|
||||
isBackupKeyValid = valid
|
||||
aepValidationError = error
|
||||
}
|
||||
|
||||
RegistrationScreen(
|
||||
title = "Local Backup V2 Restore",
|
||||
subtitle = null,
|
||||
bottomContent = {
|
||||
Buttons.LargeTonal(
|
||||
onClick = { callback.onRestoreClick(backupKey) },
|
||||
enabled = isBackupKeyValid && aepValidationError == null && selectedDirectory != null,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Text(text = "Restore")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DirectorySelectionRow(
|
||||
selectedDirectory = selectedDirectory,
|
||||
onClick = callback::onSelectDirectoryClick
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
TextField(
|
||||
value = backupKey,
|
||||
onValueChange = { value ->
|
||||
val newKey = AccountEntropyPool.removeIllegalCharacters(value).take(AccountEntropyPool.LENGTH + 16).lowercase()
|
||||
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
|
||||
backupKey = newKey,
|
||||
changed = backupKey != newKey,
|
||||
previousAEPValidationError = aepValidationError
|
||||
)
|
||||
backupKey = newKey
|
||||
isBackupKeyValid = valid
|
||||
aepValidationError = error
|
||||
autoFillHelper.onValueChanged(newKey)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (isBackupKeyValid && aepValidationError == null && selectedDirectory != null) {
|
||||
keyboardController?.hide()
|
||||
callback.onRestoreClick(backupKey)
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = { aepValidationError?.ValidationErrorMessage() },
|
||||
isError = aepValidationError != null,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.attachBackupKeyAutoFillHelper(autoFillHelper)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectorySelectionRow(
|
||||
selectedDirectory: String?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Select Backup Directory",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = selectedDirectory ?: "No directory selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
|
||||
when (this) {
|
||||
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
|
||||
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
|
||||
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun InternalNewLocalBackupRestoreScreenPreview() {
|
||||
Previews.Preview {
|
||||
InternalNewLocalBackupRestoreScreen(
|
||||
selectedDirectory = "/storage/emulated/0/Signal/Backups",
|
||||
callback = Callback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
|
||||
import org.thoughtcrime.securesms.keyvalue.Completed
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
|
||||
/**
|
||||
* Internal only. On launch, attempt to import the most recent backup located in [SignalStore.backup].newLocalBackupsDirectory.
|
||||
*/
|
||||
class InternalNewLocalRestoreActivity : BaseActivity() {
|
||||
companion object {
|
||||
fun getIntent(context: Context, finish: Boolean = true): Intent = Intent(context, InternalNewLocalRestoreActivity::class.java).apply { putExtra("finish", finish) }
|
||||
}
|
||||
|
||||
private var restoreStatus by mutableStateOf<String>("Unknown")
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
restoreStatus = "Starting..."
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(SignalStore.backup.newLocalBackupsDirectory!!))!!
|
||||
val snapshotInfo = archiveFileSystem.listSnapshots().first()
|
||||
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
|
||||
|
||||
val result = LocalArchiver.import(snapshotFileSystem, selfData)
|
||||
|
||||
if (result is Result.Success) {
|
||||
restoreStatus = "Success! Finishing"
|
||||
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
|
||||
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
|
||||
|
||||
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
|
||||
|
||||
SignalStore.backup.backupSecretRestoreRequired = false
|
||||
StorageServiceRestore.restore()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup restored!", Toast.LENGTH_SHORT).show()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
startActivity(MainActivity.clearTop(this@InternalNewLocalRestoreActivity))
|
||||
if (intent.getBooleanExtra("finish", false)) {
|
||||
finishAffinity()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreStatus = "Backup failed"
|
||||
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup failed", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
InternalNewLocalRestoreScreen(
|
||||
status = restoreStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(restoreEvent: RestoreV2Event) {
|
||||
this.restoreStatus = "${restoreEvent.type}: ${restoreEvent.count} / ${restoreEvent.estimatedTotalCount}"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalNewLocalRestoreScreen(
|
||||
status: String = ""
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = "Internal - Local Restore",
|
||||
subtitle = null,
|
||||
bottomContent = { }
|
||||
) {
|
||||
Text(
|
||||
text = status,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun InternalNewLocalRestorePreview() {
|
||||
Previews.Preview {
|
||||
InternalNewLocalRestoreScreen(status = "Importing...")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Displayed when the user presses the 'No recovery key?' button on the
|
||||
* [EnterLocalBackupKeyScreen].
|
||||
*/
|
||||
@Composable
|
||||
fun NoRecoveryKeySheetContent(
|
||||
onSkipAndDontRestoreClick: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_key_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 56.dp)
|
||||
.size(88.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape)
|
||||
.padding(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "No recovery key?",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Backups can’t be recovered without their 64-character recovery key. If you’ve lost your recovery key Signal can’t help restore your backup.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "If you have your old device you can view your recovery key in Settings > Backups.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.padding(top = 24.dp, bottom = 56.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onLearnMoreClick
|
||||
) {
|
||||
Text(text = "Learn more")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = onSkipAndDontRestoreClick
|
||||
) {
|
||||
Text(text = "Skip and don't restore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun NoRecoveryKeySheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
NoRecoveryKeySheetContent(
|
||||
onSkipAndDontRestoreClick = {},
|
||||
onLearnMoreClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Handles the synchronous restoration of the proto files from a V2 backup. Media is
|
||||
* handled by background tasks.
|
||||
*/
|
||||
class RestoreLocalBackupActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val KEY_FINISH = "finish"
|
||||
|
||||
fun getIntent(context: Context, finish: Boolean = true): Intent {
|
||||
return Intent(context, RestoreLocalBackupActivity::class.java).apply {
|
||||
putExtra(KEY_FINISH, finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel: RestoreLocalBackupActivityViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.restorePhase) {
|
||||
when (state.restorePhase) {
|
||||
RestorePhase.COMPLETE -> {
|
||||
startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity))
|
||||
if (intent.getBooleanExtra(KEY_FINISH, false)) {
|
||||
finishAffinity()
|
||||
}
|
||||
}
|
||||
RestorePhase.FAILED -> {
|
||||
Toast.makeText(this@RestoreLocalBackupActivity, getString(R.string.RestoreLocalBackupActivity__backup_restore_failed), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
SignalTheme {
|
||||
RestoreLocalBackupScreen(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreLocalBackupScreen(state: RestoreLocalBackupScreenState) {
|
||||
val density = LocalDensity.current
|
||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
var contentHeightPx by remember { mutableIntStateOf(0) }
|
||||
|
||||
Surface {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.horizontalGutters()
|
||||
) {
|
||||
val totalHeightPx = with(density) { maxHeight.roundToPx() }
|
||||
val screenCenterTop = with(density) { max((totalHeightPx - contentHeightPx) / 2, headerHeightPx).toDp() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { headerHeightPx = it.height }
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.RestoreLocalBackupActivity__restoring_backup),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.RestoreLocalBackupActivity__depending_on_the_size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = screenCenterTop)
|
||||
.onSizeChanged { contentHeightPx = it.height }
|
||||
) {
|
||||
if (state.progress > 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { state.progress },
|
||||
modifier = Modifier.size(60.dp),
|
||||
strokeWidth = 5.dp,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
gapSize = 0.dp
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(60.dp),
|
||||
strokeWidth = 5.dp,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
gapSize = 0.dp
|
||||
)
|
||||
}
|
||||
|
||||
val statusText = when (state.restorePhase) {
|
||||
RestorePhase.RESTORING -> stringResource(R.string.RestoreLocalBackupActivity__restoring_messages)
|
||||
RestorePhase.FINALIZING -> stringResource(R.string.RestoreLocalBackupActivity__finalizing)
|
||||
RestorePhase.COMPLETE -> stringResource(R.string.RestoreLocalBackupActivity__restore_complete)
|
||||
RestorePhase.FAILED -> stringResource(R.string.RestoreLocalBackupActivity__restore_failed)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = statusText,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
if (state.restorePhase == RestorePhase.RESTORING && state.totalBytes.inWholeBytes > 0) {
|
||||
val progressPercent = (state.progress * 100).toInt()
|
||||
Text(
|
||||
text = stringResource(R.string.RestoreLocalBackupActivity__s_of_s_d_percent, state.bytesRead.toUnitString(), state.totalBytes.toUnitString(), progressPercent),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RestoreLocalBackupScreenPreview() {
|
||||
Previews.Preview {
|
||||
RestoreLocalBackupScreen(state = RestoreLocalBackupScreenState())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
|
||||
import org.thoughtcrime.securesms.keyvalue.Completed
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
|
||||
class RestoreLocalBackupActivityViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RestoreLocalBackupActivityViewModel::class)
|
||||
}
|
||||
|
||||
private val internalState = MutableStateFlow(RestoreLocalBackupScreenState())
|
||||
val state: StateFlow<RestoreLocalBackupScreenState> = internalState
|
||||
|
||||
init {
|
||||
EventBus.getDefault().register(this)
|
||||
beginRestore()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
EventBus.getDefault().unregister(this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onRestoreEvent(event: RestoreV2Event) {
|
||||
internalState.update {
|
||||
when (event.type) {
|
||||
RestoreV2Event.Type.PROGRESS_RESTORE -> it.copy(
|
||||
restorePhase = RestorePhase.RESTORING,
|
||||
bytesRead = event.count,
|
||||
totalBytes = event.estimatedTotalCount,
|
||||
progress = event.getProgress()
|
||||
)
|
||||
|
||||
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> it.copy(
|
||||
restorePhase = RestorePhase.RESTORING,
|
||||
bytesRead = event.count,
|
||||
totalBytes = event.estimatedTotalCount,
|
||||
progress = event.getProgress()
|
||||
)
|
||||
|
||||
RestoreV2Event.Type.PROGRESS_FINALIZING -> it.copy(
|
||||
restorePhase = RestorePhase.FINALIZING
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginRestore() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.RESTORING) }
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
val backupDirectory = SignalStore.backup.newLocalBackupsDirectory
|
||||
if (backupDirectory == null) {
|
||||
Log.w(TAG, "No backup directory set")
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(backupDirectory))
|
||||
if (archiveFileSystem == null) {
|
||||
Log.w(TAG, "Unable to access backup directory: $backupDirectory")
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val selectedTimestamp = SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp
|
||||
val snapshots = archiveFileSystem.listSnapshots()
|
||||
val snapshotInfo = snapshots.firstOrNull { it.timestamp == selectedTimestamp } ?: snapshots.firstOrNull()
|
||||
|
||||
if (snapshotInfo == null) {
|
||||
Log.w(TAG, "No snapshots found in backup directory")
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
|
||||
val result = LocalArchiver.import(snapshotFileSystem, selfData)
|
||||
|
||||
if (result is Result.Success) {
|
||||
Log.i(TAG, "Local backup import succeeded")
|
||||
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
|
||||
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
|
||||
|
||||
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
|
||||
SignalStore.backup.backupSecretRestoreRequired = false
|
||||
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = -1L
|
||||
SignalStore.backup.newLocalBackupsEnabled = true
|
||||
LocalBackupJob.enqueueArchive(false)
|
||||
StorageServiceRestore.restore()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.COMPLETE) }
|
||||
} else {
|
||||
Log.w(TAG, "Local backup import failed")
|
||||
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RestoreLocalBackupScreenState(
|
||||
val restorePhase: RestorePhase = RestorePhase.RESTORING,
|
||||
val bytesRead: ByteSize = 0L.bytes,
|
||||
val totalBytes: ByteSize = 0L.bytes,
|
||||
val progress: Float = 0f
|
||||
)
|
||||
|
||||
enum class RestorePhase {
|
||||
RESTORING,
|
||||
FINALIZING,
|
||||
COMPLETE,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
enum class RestoreLocalBackupDialog {
|
||||
FAILED_TO_LOAD_ARCHIVE,
|
||||
SKIP_RESTORE_WARNING
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RestoreLocalBackupDialogDisplay(
|
||||
dialog: RestoreLocalBackupDialog?,
|
||||
onDialogConfirmed: (RestoreLocalBackupDialog) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
when (dialog) {
|
||||
RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE -> {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.RestoreLocalBackupDialog__failed_to_load_archive),
|
||||
onDismiss = onDismiss,
|
||||
dismiss = stringResource(R.string.RestoreLocalBackupDialog__ok)
|
||||
)
|
||||
}
|
||||
|
||||
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "Skip restore?",
|
||||
body = "If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history.",
|
||||
confirm = "Skip restore",
|
||||
confirmColor = MaterialTheme.colorScheme.error,
|
||||
onConfirm = {
|
||||
onDialogConfirmed(RestoreLocalBackupDialog.SKIP_RESTORE_WARNING)
|
||||
},
|
||||
dismiss = stringResource(android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
null -> return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Restore an on-device backup during registration
|
||||
*/
|
||||
class RestoreLocalBackupFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RestoreLocalBackupFragment::class)
|
||||
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val enterBackupKeyViewModel by viewModels<EnterBackupKeyViewModel>()
|
||||
private lateinit var restoreLocalBackupViewModel: RestoreLocalBackupViewModel
|
||||
|
||||
private val localBackupRestore = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
when (val resultCode = result.resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
sharedViewModel.onBackupSuccessfullyRestored()
|
||||
findNavController().safeNavigate(RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
|
||||
}
|
||||
|
||||
Activity.RESULT_CANCELED -> {
|
||||
Log.w(TAG, "Backup restoration canceled.")
|
||||
}
|
||||
|
||||
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
sharedViewModel
|
||||
.state
|
||||
.map { it.registerAccountError }
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
sharedViewModel.registerAccountErrorShown()
|
||||
enterBackupKeyViewModel.handleRegistrationFailure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val viewModel = viewModel<RestoreLocalBackupViewModel>()
|
||||
restoreLocalBackupViewModel = viewModel
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val registrationState by sharedViewModel.state.collectAsStateWithLifecycle()
|
||||
val enterBackupKeyState by enterBackupKeyViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SignalTheme {
|
||||
val activity = LocalActivity.current as FragmentActivity
|
||||
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides activity) {
|
||||
RestoreLocalBackupNavDisplay(
|
||||
state = state,
|
||||
callback = remember { RestoreBackupCallback() },
|
||||
isRegistrationInProgress = registrationState.inProgress,
|
||||
enterBackupKeyState = enterBackupKeyState,
|
||||
backupKey = enterBackupKeyViewModel.backupKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class RestoreBackupCallback : RestoreLocalBackupCallback {
|
||||
override fun setSelectedBackup(backup: SelectableBackup) {
|
||||
restoreLocalBackupViewModel.setSelectedBackup(backup)
|
||||
}
|
||||
|
||||
override fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
|
||||
return restoreLocalBackupViewModel.setSelectedBackupDirectory(context, uri)
|
||||
}
|
||||
|
||||
override fun displaySkipRestoreWarning() {
|
||||
restoreLocalBackupViewModel.displaySkipRestoreWarning()
|
||||
}
|
||||
|
||||
override fun clearDialog() {
|
||||
restoreLocalBackupViewModel.clearDialog()
|
||||
}
|
||||
|
||||
override fun skipRestore() {
|
||||
sharedViewModel.skipRestore()
|
||||
findNavController().safeNavigate(RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
|
||||
}
|
||||
|
||||
override fun routeToLegacyBackupRestoration(uri: Uri) {
|
||||
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = false)
|
||||
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext(), uri))
|
||||
}
|
||||
|
||||
override fun submitBackupKey() {
|
||||
enterBackupKeyViewModel.registering()
|
||||
|
||||
val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L
|
||||
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp
|
||||
|
||||
sharedViewModel.registerWithBackupKey(
|
||||
context = requireContext(),
|
||||
backupKey = enterBackupKeyViewModel.backupKey,
|
||||
e164 = null,
|
||||
pin = null,
|
||||
aciIdentityKeyPair = null,
|
||||
pniIdentityKeyPair = null
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBackupKeyChanged(key: String) {
|
||||
enterBackupKeyViewModel.updateBackupKey(key)
|
||||
}
|
||||
|
||||
override fun clearRegistrationError() {
|
||||
enterBackupKeyViewModel.clearRegistrationError()
|
||||
}
|
||||
|
||||
override fun onBackupKeyHelp() {
|
||||
CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.signal.core.ui.contracts.OpenDocumentContract
|
||||
import org.signal.core.ui.navigation.BottomSheetSceneStrategy
|
||||
import org.signal.core.ui.navigation.LocalBottomSheetDismiss
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel
|
||||
|
||||
/**
|
||||
* Handles the restoration flow for V2 backups. Can also launch into V1 backup flow if needed.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RestoreLocalBackupNavDisplay(
|
||||
state: RestoreLocalBackupState,
|
||||
callback: RestoreLocalBackupCallback,
|
||||
isRegistrationInProgress: Boolean,
|
||||
enterBackupKeyState: EnterBackupKeyViewModel.EnterBackupKeyState,
|
||||
backupKey: String
|
||||
) {
|
||||
val backstack = rememberNavBackStack(RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen)
|
||||
val bottomSheetStrategy = remember { BottomSheetSceneStrategy<NavKey>() }
|
||||
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val folderLauncher = Launchers.rememberOpenDocumentTreeLauncher {
|
||||
if (it != null) {
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(it, takeFlags)
|
||||
|
||||
if (callback.setSelectedBackupDirectory(context, it)) {
|
||||
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fileLauncher = Launchers.rememberOpenDocumentLauncher {
|
||||
if (it != null) {
|
||||
callback.routeToLegacyBackupRestoration(it)
|
||||
}
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backstack,
|
||||
sceneStrategy = bottomSheetStrategy,
|
||||
entryProvider = entryProvider {
|
||||
entry<RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen> {
|
||||
SelectLocalBackupTypeScreen(
|
||||
onSelectBackupFolderClick = {
|
||||
backstack.add(RestoreLocalBackupNavKey.FolderInstructionSheet)
|
||||
},
|
||||
onSelectBackupFileClick = {
|
||||
backstack.add(RestoreLocalBackupNavKey.FileInstructionSheet)
|
||||
},
|
||||
onCancelClick = {
|
||||
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.FileInstructionSheet>(
|
||||
metadata = BottomSheetSceneStrategy.bottomSheet()
|
||||
) {
|
||||
SelectYourBackupFileSheetContent(onContinueClick = {
|
||||
fileLauncher.launch(OpenDocumentContract.Input())
|
||||
})
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.FolderInstructionSheet>(
|
||||
metadata = BottomSheetSceneStrategy.bottomSheet()
|
||||
) {
|
||||
SelectYourBackupFolderSheetContent(onContinueClick = {
|
||||
folderLauncher.launch(null)
|
||||
})
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.SelectLocalBackupScreen> {
|
||||
SelectLocalBackupScreen(
|
||||
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
|
||||
isSelectedBackupLatest = state.selectedBackup == state.selectableBackups.firstOrNull(),
|
||||
onRestoreBackupClick = {
|
||||
backstack.add(RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen)
|
||||
},
|
||||
onCancelClick = {
|
||||
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
|
||||
},
|
||||
onChooseADifferentBackupClick = {
|
||||
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupSheet)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.SelectLocalBackupSheet>(
|
||||
metadata = BottomSheetSceneStrategy.bottomSheet()
|
||||
) {
|
||||
val dismissSheet = LocalBottomSheetDismiss.current
|
||||
SelectLocalBackupSheetContent(
|
||||
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
|
||||
selectableBackups = state.selectableBackups,
|
||||
onBackupSelected = {
|
||||
callback.setSelectedBackup(it)
|
||||
dismissSheet()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen> {
|
||||
EnterLocalBackupKeyScreen(
|
||||
backupKey = backupKey,
|
||||
isRegistrationInProgress = isRegistrationInProgress,
|
||||
isBackupKeyValid = enterBackupKeyState.backupKeyValid,
|
||||
aepValidationError = enterBackupKeyState.aepValidationError,
|
||||
onBackupKeyChanged = callback::onBackupKeyChanged,
|
||||
onNextClicked = callback::submitBackupKey,
|
||||
onNoBackupKeyClick = {
|
||||
backstack.add(RestoreLocalBackupNavKey.NoRecoveryKeySheet)
|
||||
},
|
||||
showRegistrationError = enterBackupKeyState.showRegistrationError,
|
||||
registerAccountResult = enterBackupKeyState.registerAccountResult,
|
||||
onRegistrationErrorDismiss = callback::clearRegistrationError,
|
||||
onBackupKeyHelp = callback::onBackupKeyHelp
|
||||
)
|
||||
}
|
||||
|
||||
entry<RestoreLocalBackupNavKey.NoRecoveryKeySheet>(
|
||||
metadata = BottomSheetSceneStrategy.bottomSheet()
|
||||
) {
|
||||
val dismissSheet = LocalBottomSheetDismiss.current
|
||||
NoRecoveryKeySheetContent(
|
||||
onSkipAndDontRestoreClick = {
|
||||
dismissSheet()
|
||||
callback.displaySkipRestoreWarning()
|
||||
},
|
||||
onLearnMoreClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
RestoreLocalBackupDialogDisplay(state.dialog, {
|
||||
if (it == RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) {
|
||||
callback.skipRestore()
|
||||
}
|
||||
}, callback::clearDialog)
|
||||
}
|
||||
|
||||
data class RestoreLocalBackupState(
|
||||
val dialog: RestoreLocalBackupDialog? = null,
|
||||
val selectedBackup: SelectableBackup? = null,
|
||||
val selectableBackups: PersistentList<SelectableBackup> = persistentListOf()
|
||||
)
|
||||
|
||||
interface RestoreLocalBackupCallback {
|
||||
fun setSelectedBackup(backup: SelectableBackup)
|
||||
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean
|
||||
fun displaySkipRestoreWarning()
|
||||
fun clearDialog()
|
||||
fun skipRestore()
|
||||
fun submitBackupKey()
|
||||
fun routeToLegacyBackupRestoration(uri: Uri)
|
||||
fun onBackupKeyChanged(key: String)
|
||||
fun clearRegistrationError()
|
||||
fun onBackupKeyHelp()
|
||||
|
||||
object Empty : RestoreLocalBackupCallback {
|
||||
override fun setSelectedBackup(backup: SelectableBackup) = Unit
|
||||
override fun setSelectedBackupDirectory(context: Context, uri: Uri) = false
|
||||
override fun displaySkipRestoreWarning() = Unit
|
||||
override fun clearDialog() = Unit
|
||||
override fun skipRestore() = Unit
|
||||
override fun submitBackupKey() = Unit
|
||||
override fun routeToLegacyBackupRestoration(uri: Uri) = Unit
|
||||
override fun onBackupKeyChanged(key: String) = Unit
|
||||
override fun clearRegistrationError() = Unit
|
||||
override fun onBackupKeyHelp() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface RestoreLocalBackupNavKey : NavKey {
|
||||
@Serializable
|
||||
object SelectLocalBackupTypeScreen : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object FolderInstructionSheet : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object FileInstructionSheet : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object SelectLocalBackupScreen : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object SelectLocalBackupSheet : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object EnterLocalBackupKeyScreen : RestoreLocalBackupNavKey
|
||||
|
||||
@Serializable
|
||||
object NoRecoveryKeySheet : RestoreLocalBackupNavKey
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
|
||||
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import java.util.Locale
|
||||
|
||||
class RestoreLocalBackupViewModel : ViewModel() {
|
||||
companion object {
|
||||
private val TAG = Log.tag(RestoreLocalBackupViewModel::class.java)
|
||||
}
|
||||
|
||||
private val internalState = MutableStateFlow(RestoreLocalBackupState())
|
||||
|
||||
val state: StateFlow<RestoreLocalBackupState> = internalState
|
||||
|
||||
fun setSelectedBackup(backup: SelectableBackup) {
|
||||
internalState.update { it.copy(selectedBackup = backup) }
|
||||
}
|
||||
|
||||
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
|
||||
val archiveFileSystem = ArchiveFileSystem.fromUri(context, uri)
|
||||
|
||||
if (archiveFileSystem == null) {
|
||||
Log.w(TAG, "Unable to access backup directory: $uri")
|
||||
internalState.update { it.copy(selectedBackup = null, selectableBackups = persistentListOf(), dialog = RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE) }
|
||||
return false
|
||||
}
|
||||
|
||||
val selectableBackups = archiveFileSystem
|
||||
.listSnapshots()
|
||||
.take(2)
|
||||
.map { snapshot ->
|
||||
val dateLabel = if (DateUtils.isSameDay(System.currentTimeMillis(), snapshot.timestamp)) {
|
||||
context.getString(R.string.DateUtils_today)
|
||||
} else {
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), snapshot.timestamp)
|
||||
}
|
||||
val timeLabel = DateUtils.getOnlyTimeString(context, snapshot.timestamp)
|
||||
val sizeBytes = SnapshotFileSystem(context, snapshot.file).mainLength() ?: 0L
|
||||
|
||||
SelectableBackup(
|
||||
timestamp = snapshot.timestamp,
|
||||
backupTime = "$dateLabel • $timeLabel",
|
||||
backupSize = sizeBytes.bytes.toUnitString()
|
||||
)
|
||||
}
|
||||
.toPersistentList()
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
selectableBackups = selectableBackups,
|
||||
selectedBackup = selectableBackups.firstOrNull()
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun displaySkipRestoreWarning() {
|
||||
internalState.update { it.copy(dialog = RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) }
|
||||
}
|
||||
|
||||
fun clearDialog() {
|
||||
internalState.update { it.copy(dialog = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun SelectYourBackupFolderSheetContent(
|
||||
onContinueClick: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
SelectInstructionsSheetContent(
|
||||
title = stringResource(R.string.SelectInstructionsSheet__select_your_backup_folder),
|
||||
onContinueClick = onContinueClick
|
||||
) {
|
||||
InstructionRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_tap_outline_24),
|
||||
text = stringResource(R.string.SelectInstructionsSheet__tap_select_this_folder)
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_error_circle_24),
|
||||
text = stringResource(R.string.SelectInstructionsSheet__do_not_select_individual_files)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectYourBackupFileSheetContent(
|
||||
onContinueClick: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
SelectInstructionsSheetContent(
|
||||
title = stringResource(R.string.SelectInstructionsSheet__select_your_backup_file),
|
||||
onContinueClick = onContinueClick
|
||||
) {
|
||||
InstructionRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_tap_outline_24),
|
||||
text = stringResource(R.string.SelectInstructionsSheet__tap_on_the_backup_file)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SelectInstructionsSheetContent(
|
||||
title: String,
|
||||
onContinueClick: () -> Unit,
|
||||
instructionRows: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
.padding(bottom = 8.dp, top = 38.dp)
|
||||
.padding(horizontal = 40.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SelectInstructionsSheet__after_tapping_continue),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
.padding(bottom = 36.dp)
|
||||
.padding(horizontal = 40.dp)
|
||||
)
|
||||
|
||||
Column(verticalArrangement = spacedBy(24.dp)) {
|
||||
InstructionRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
|
||||
text = stringResource(R.string.SelectInstructionsSheet__select_the_top_level_folder)
|
||||
)
|
||||
|
||||
instructionRows()
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 220.dp)
|
||||
.padding(vertical = 56.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(text = stringResource(R.string.SelectInstructionsSheet__continue_button))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstructionRow(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 64.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SelectYourBackupFolderSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Column {
|
||||
SelectYourBackupFolderSheetContent(
|
||||
onContinueClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SelectYourBackupFileSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Column {
|
||||
SelectYourBackupFileSheetContent(
|
||||
onContinueClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* Allows the user to select a specific on-device backup to restore.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectLocalBackupScreen(
|
||||
selectedBackup: SelectableBackup,
|
||||
isSelectedBackupLatest: Boolean,
|
||||
onRestoreBackupClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onChooseADifferentBackupClick: () -> Unit
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = stringResource(R.string.SelectLocalBackupScreen__restore_on_device_backup),
|
||||
subtitle = stringResource(R.string.SelectLocalBackupScreen__restore_your_messages_from_the_backup_folder),
|
||||
bottomContent = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRestoreBackupClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupScreen__restore_backup)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
YourBackupCard(
|
||||
selectedBackup = selectedBackup,
|
||||
isSelectedBackupLatest = isSelectedBackupLatest
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onChooseADifferentBackupClick,
|
||||
modifier = Modifier
|
||||
.padding(top = 28.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Backup.imageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
if (isSelectedBackupLatest) {
|
||||
Text(text = stringResource(R.string.SelectLocalBackupScreen__choose_an_earlier_backup))
|
||||
} else {
|
||||
Text(text = stringResource(R.string.SelectLocalBackupScreen__choose_a_different_backup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun YourBackupCard(
|
||||
selectedBackup: SelectableBackup,
|
||||
isSelectedBackupLatest: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isSelectedBackupLatest) {
|
||||
stringResource(R.string.SelectLocalBackupScreen__your_latest_backup)
|
||||
} else {
|
||||
stringResource(R.string.SelectLocalBackupScreen__your_backup)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
BackupInfoRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_recent_24),
|
||||
text = selectedBackup.backupTime
|
||||
)
|
||||
|
||||
BackupInfoRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_file_24),
|
||||
text = selectedBackup.backupSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackupInfoRow(
|
||||
icon: ImageVector,
|
||||
text: String
|
||||
) {
|
||||
Row {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun SelectLocalBackupScreenPreview() {
|
||||
Previews.Preview {
|
||||
SelectLocalBackupScreen(
|
||||
selectedBackup = SelectableBackup(
|
||||
timestamp = 0L,
|
||||
backupTime = "Today \u2022 12:34 PM",
|
||||
backupSize = "1.38 GB"
|
||||
),
|
||||
isSelectedBackupLatest = true,
|
||||
onRestoreBackupClick = {},
|
||||
onCancelClick = {},
|
||||
onChooseADifferentBackupClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun SelectLocalBackupSheetContent(
|
||||
selectedBackup: SelectableBackup,
|
||||
selectableBackups: PersistentList<SelectableBackup>,
|
||||
onBackupSelected: (SelectableBackup) -> Unit
|
||||
) {
|
||||
var selection by remember { mutableStateOf(selectedBackup) }
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupSheet__choose_a_backup_to_restore),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 6.dp, top = 20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupSheet__choosing_an_older_backup),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 38.dp)
|
||||
)
|
||||
|
||||
val backups = remember(selectableBackups) {
|
||||
selectableBackups.take(2)
|
||||
}
|
||||
|
||||
backups.forEachIndexed { idx, backup ->
|
||||
val shape = if (idx == 0) {
|
||||
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
} else {
|
||||
RoundedCornerShape(bottomStart = 18.dp, bottomEnd = 18.dp)
|
||||
}
|
||||
|
||||
Rows.RadioRow(
|
||||
text = backup.backupTime,
|
||||
label = backup.backupSize,
|
||||
selected = selection == backup,
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = shape
|
||||
)
|
||||
.clickable(onClick = {
|
||||
selection = backup
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onBackupSelected(selection) },
|
||||
enabled = selectedBackup != selection,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 56.dp)
|
||||
.widthIn(min = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.SelectLocalBackupSheet__continue_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SelectLocalBackupSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
SelectLocalBackupSheetContent(
|
||||
selectedBackup = SelectableBackup(
|
||||
timestamp = 0L,
|
||||
backupTime = "Today \u2022 3:38am",
|
||||
backupSize = "1.38 GB"
|
||||
),
|
||||
selectableBackups = persistentListOf(
|
||||
SelectableBackup(
|
||||
timestamp = 0L,
|
||||
backupTime = "Today \u2022 3:38am",
|
||||
backupSize = "1.38 GB"
|
||||
),
|
||||
SelectableBackup(
|
||||
timestamp = 1L,
|
||||
backupTime = "August 13, 2024 \u2022 3:21am",
|
||||
backupSize = "1.34 GB"
|
||||
)
|
||||
),
|
||||
onBackupSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* User can select either a folder-based or single backup file for restoration during registration.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectLocalBackupTypeScreen(
|
||||
onSelectBackupFolderClick: () -> Unit,
|
||||
onSelectBackupFileClick: () -> Unit,
|
||||
onCancelClick: () -> Unit
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = stringResource(R.string.SelectLocalBackupTypeScreen__restore_on_device_backup),
|
||||
subtitle = stringResource(R.string.SelectLocalBackupTypeScreen__restore_your_messages_from_the_backup),
|
||||
bottomContent = {
|
||||
TextButton(onClick = onCancelClick, modifier = Modifier.align(Alignment.Center)) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
) {
|
||||
ChooseBackupFolderCard(
|
||||
onClick = onSelectBackupFolderClick,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onSelectBackupFileClick,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupTypeScreen__i_saved_my_backup_as_a_single_file)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseBackupFolderCard(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick, role = Role.Button)
|
||||
.padding(end = 24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 21.dp)
|
||||
.size(40.dp)
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupTypeScreen__choose_backup_folder)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SelectLocalBackupTypeScreen__select_the_folder_on_your_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SelectLocalBackupTypeScreenPreview() {
|
||||
Previews.Preview {
|
||||
SelectLocalBackupTypeScreen(
|
||||
onSelectBackupFolderClick = {},
|
||||
onSelectBackupFileClick = {},
|
||||
onCancelClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ChooseBackupFolderCardPreview() {
|
||||
Previews.Preview {
|
||||
ChooseBackupFolderCard(onClick = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore.local
|
||||
|
||||
data class SelectableBackup(
|
||||
val timestamp: Long,
|
||||
val backupTime: String,
|
||||
val backupSize: String
|
||||
)
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.restore
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
@@ -24,7 +25,6 @@ import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.isWantingManualRemoteRestore
|
||||
import org.thoughtcrime.securesms.keyvalue.isWantingNewLocalBackupRestore
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
@@ -83,8 +83,8 @@ class RestoreActivity : BaseActivity() {
|
||||
if (SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore) {
|
||||
Log.i(TAG, "User has no available restore methods but previously wanted a remote restore, navigating immediately.")
|
||||
startActivity(RemoteRestoreActivity.getIntent(this, isOnlyOption = true))
|
||||
} else if (SignalStore.registration.restoreDecisionState.isWantingNewLocalBackupRestore && (BuildConfig.DEBUG || Environment.IS_NIGHTLY)) {
|
||||
startActivity(InternalNewLocalRestoreActivity.getIntent(this))
|
||||
} else if (SignalStore.registration.restoreDecisionState.isWantingNewLocalBackupRestore && Environment.Backups.isNewFormatSupportedForLocalBackup()) {
|
||||
startActivity(RestoreLocalBackupActivity.getIntent(this))
|
||||
} else {
|
||||
Log.i(TAG, "No restore methods available, skipping")
|
||||
sharedViewModel.skipRestore()
|
||||
@@ -103,7 +103,14 @@ class RestoreActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
|
||||
NavTarget.LOCAL_RESTORE -> {
|
||||
if (intent.data != null) {
|
||||
sharedViewModel.setBackupFileUri(intent.data!!)
|
||||
navController.safeNavigate(RestoreDirections.goDirectlyToRestoreLocalBackup())
|
||||
} else {
|
||||
navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
|
||||
}
|
||||
}
|
||||
NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer())
|
||||
}
|
||||
|
||||
@@ -185,9 +192,11 @@ class RestoreActivity : BaseActivity() {
|
||||
private const val EXTRA_NAV_TARGET = "nav_target"
|
||||
|
||||
@JvmStatic
|
||||
fun getLocalRestoreIntent(context: Context): Intent {
|
||||
@JvmOverloads
|
||||
fun getLocalRestoreIntent(context: Context, uri: Uri? = null): Intent {
|
||||
return Intent(context, RestoreActivity::class.java).apply {
|
||||
putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value)
|
||||
setData(uri)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ object Environment {
|
||||
fun supportsGooglePlayBilling(): Boolean {
|
||||
return BuildConfig.APPLICATION_ID == GOOGLE_PLAY_BILLING_APPLICATION_ID
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isNewFormatSupportedForLocalBackup(): Boolean {
|
||||
return BuildConfig.DEBUG || IS_NIGHTLY
|
||||
}
|
||||
}
|
||||
|
||||
object Donations {
|
||||
|
||||
@@ -1237,17 +1237,6 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether or not the new UX for unified local backups is enabled
|
||||
*/
|
||||
@JvmStatic
|
||||
@get:JvmName("unifiedLocalBackups")
|
||||
val unifiedLocalBackups: Boolean by remoteBoolean(
|
||||
key = "android.unifiedLocalBackups",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to receive and display group member labels.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user