When restoring, ask for location first and then restore code
This also checks if there's a backup at the chosen location and requires the user to select another once, if we can not find a backup.
This commit is contained in:
parent
af43c6154d
commit
9cede639f3
9 changed files with 180 additions and 112 deletions
|
@ -1,10 +1,14 @@
|
|||
package com.stevesoltys.backup.restore
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
|
||||
import com.stevesoltys.backup.ui.BackupActivity
|
||||
import com.stevesoltys.backup.ui.BackupLocationFragment
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
class RestoreActivity : BackupActivity() {
|
||||
|
@ -15,8 +19,6 @@ class RestoreActivity : BackupActivity() {
|
|||
|
||||
override fun getInitialFragment() = RestoreSetFragment()
|
||||
|
||||
override fun isRestoreOperation() = true
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -32,8 +34,25 @@ class RestoreActivity : BackupActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (isFinishing) return
|
||||
|
||||
// check that backup is provisioned
|
||||
if (!viewModel.validLocationIsSet()) {
|
||||
showFragment(BackupLocationFragment())
|
||||
} else if (!viewModel.recoveryCodeIsSet()) {
|
||||
showRecoveryCodeActivity()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInvalidLocation() {
|
||||
// TODO alert dialog?
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.restore_invalid_location_title))
|
||||
.setMessage(getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT))
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -4,16 +4,23 @@ import android.app.Application
|
|||
import android.app.backup.IRestoreObserver
|
||||
import android.app.backup.IRestoreSession
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.session.backup.BackupMonitor
|
||||
import com.stevesoltys.backup.settings.setBackupFolderUri
|
||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.backup.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.backup.transport.backup.plugins.DIRECTORY_ROOT
|
||||
import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin.Companion.getBackups
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
import com.stevesoltys.backup.ui.LocationResult
|
||||
|
||||
private val TAG = RestoreViewModel::class.java.simpleName
|
||||
|
||||
|
@ -21,6 +28,8 @@ class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClick
|
|||
|
||||
private val backupManager = Backup.backupManager
|
||||
|
||||
override val isRestoreOperation = true
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
private var observer: RestoreObserver? = null
|
||||
private val monitor = BackupMonitor()
|
||||
|
@ -41,9 +50,39 @@ class RestoreViewModel(app: Application) : BackupViewModel(app), RestoreSetClick
|
|||
// Zero on success; a nonzero error code if the restore operation as a whole failed.
|
||||
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
|
||||
|
||||
override fun acceptBackupLocation(folderUri: Uri): Boolean {
|
||||
// TODO search if there's really a backup available in this location and see if we can decrypt it
|
||||
return true
|
||||
override fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean) {
|
||||
if (hasBackup(folderUri)) {
|
||||
// store backup folder location in settings
|
||||
setBackupFolderUri(app, folderUri)
|
||||
|
||||
// stop backup service to be sure the old location will get updated
|
||||
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
||||
|
||||
Log.d(TAG, "New storage location chosen: $folderUri")
|
||||
|
||||
mLocationSet.setEvent(LocationResult(false, isInitialSetup))
|
||||
} else {
|
||||
Log.w(TAG, "Location was rejected: $folderUri")
|
||||
|
||||
// notify the UI that the location was invalid
|
||||
mLocationSet.setEvent(LocationResult(false, isInitialSetup))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches if there's really a backup available in the given location.
|
||||
* Returns true if at least one was found and false otherwise.
|
||||
*
|
||||
* This method is not plugin-agnostic and breaks encapsulation.
|
||||
* It is specific to the (currently only) DocumentsProvider plugin.
|
||||
*
|
||||
* TODO maybe move this to the RestoreCoordinator once we can inject it
|
||||
*/
|
||||
private fun hasBackup(folderUri: Uri): Boolean {
|
||||
val parent = DocumentFile.fromTreeUri(app, folderUri) ?: throw AssertionError()
|
||||
val rootDir = parent.findFile(DIRECTORY_ROOT) ?: return false
|
||||
val backupSets = getBackups(rootDir)
|
||||
return backupSets.isNotEmpty()
|
||||
}
|
||||
|
||||
internal fun loadRestoreSets() {
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.ui.BackupActivity
|
||||
import com.stevesoltys.backup.ui.BackupLocationFragment
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
class SettingsActivity : BackupActivity() {
|
||||
|
@ -14,8 +17,6 @@ class SettingsActivity : BackupActivity() {
|
|||
|
||||
override fun getInitialFragment() = SettingsFragment()
|
||||
|
||||
override fun isRestoreOperation() = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -27,4 +28,19 @@ class SettingsActivity : BackupActivity() {
|
|||
if (savedInstanceState == null) showFragment(getInitialFragment())
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (isFinishing) return
|
||||
|
||||
// check that backup is provisioned
|
||||
if (!viewModel.recoveryCodeIsSet()) {
|
||||
showRecoveryCodeActivity()
|
||||
} else if (!viewModel.validLocationIsSet()) {
|
||||
showFragment(BackupLocationFragment())
|
||||
// remove potential error notifications
|
||||
(application as Backup).notificationManager.onBackupErrorSeen()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,11 +1,61 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupProgress
|
||||
import android.app.backup.IBackupObserver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.backup.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.backup.transport.requestBackup
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
import com.stevesoltys.backup.ui.LocationResult
|
||||
|
||||
private val TAG = SettingsViewModel::class.java.simpleName
|
||||
|
||||
class SettingsViewModel(app: Application) : BackupViewModel(app) {
|
||||
|
||||
override val isRestoreOperation = false
|
||||
|
||||
fun backupNow() = Thread { requestBackup(app) }.start()
|
||||
|
||||
override fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean) {
|
||||
// store backup folder location in settings
|
||||
setBackupFolderUri(app, folderUri)
|
||||
|
||||
// stop backup service to be sure the old location will get updated
|
||||
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
||||
|
||||
Log.d(TAG, "New storage location chosen: $folderUri")
|
||||
|
||||
// initialize the new location
|
||||
val observer = InitializationObserver(isInitialSetup)
|
||||
Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), observer)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class InitializationObserver(private val initialSetUp: Boolean) : IBackupObserver.Stub() {
|
||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||
// noop
|
||||
}
|
||||
override fun onResult(target: String, status: Int) {
|
||||
// noop
|
||||
}
|
||||
override fun backupFinished(status: Int) {
|
||||
if (Log.isLoggable(TAG, Log.INFO)) {
|
||||
Log.i(TAG, "Initialization finished. Status: $status")
|
||||
}
|
||||
if (status == 0) {
|
||||
// notify the UI that the location has been set
|
||||
mLocationSet.postEvent(LocationResult(true, initialSetUp))
|
||||
} else {
|
||||
// notify the UI that the location was invalid
|
||||
mLocationSet.postEvent(LocationResult(false, initialSetUp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,11 +10,11 @@ import java.io.IOException
|
|||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
const val DIRECTORY_ROOT = ".AndroidBackup"
|
||||
const val DIRECTORY_FULL_BACKUP = "full"
|
||||
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
|
||||
const val FILE_BACKUP_METADATA = ".backup.metadata"
|
||||
const val FILE_NO_MEDIA = ".nomedia"
|
||||
private const val ROOT_DIR_NAME = ".AndroidBackup"
|
||||
private const val MIME_TYPE = "application/octet-stream"
|
||||
|
||||
private val TAG = DocumentsStorage::class.java.simpleName
|
||||
|
@ -26,7 +26,7 @@ class DocumentsStorage(private val context: Context, parentFolder: Uri?, token:
|
|||
// [fromTreeUri] should only return null when SDK_INT < 21
|
||||
val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError()
|
||||
try {
|
||||
val rootDir = parent.createOrGetDirectory(ROOT_DIR_NAME)
|
||||
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
|
||||
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
|
||||
rootDir.createOrGetFile(FILE_NO_MEDIA)
|
||||
rootDir
|
||||
|
|
|
@ -25,41 +25,48 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
|
|||
|
||||
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
|
||||
val rootDir = storage.rootBackupDir ?: return null
|
||||
val files = ArrayList<Pair<Long, DocumentFile>>()
|
||||
for (set in rootDir.listFiles()) {
|
||||
if (!set.isDirectory || set.name == null) {
|
||||
if (set.name != FILE_NO_MEDIA) {
|
||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val token = try {
|
||||
set.name!!.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}", e)
|
||||
continue
|
||||
}
|
||||
val metadata = set.findFile(FILE_BACKUP_METADATA)
|
||||
if (metadata == null) {
|
||||
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
|
||||
} else {
|
||||
files.add(Pair(token, metadata))
|
||||
}
|
||||
}
|
||||
val iterator = files.iterator()
|
||||
val backupSets = getBackups(rootDir)
|
||||
val iterator = backupSets.iterator()
|
||||
return generateSequence {
|
||||
if (!iterator.hasNext()) return@generateSequence null // end sequence
|
||||
val pair = iterator.next()
|
||||
val token = pair.first
|
||||
val metadata = pair.second
|
||||
val backupSet = iterator.next()
|
||||
try {
|
||||
val stream = storage.getInputStream(metadata)
|
||||
EncryptedBackupMetadata(token, stream)
|
||||
val stream = storage.getInputStream(backupSet.metadataFile)
|
||||
EncryptedBackupMetadata(backupSet.token, stream)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error getting InputStream for backup metadata.", e)
|
||||
EncryptedBackupMetadata(token)
|
||||
EncryptedBackupMetadata(backupSet.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getBackups(rootDir: DocumentFile): List<BackupSet> {
|
||||
val backupSets = ArrayList<BackupSet>()
|
||||
for (set in rootDir.listFiles()) {
|
||||
if (!set.isDirectory || set.name == null) {
|
||||
if (set.name != FILE_NO_MEDIA) {
|
||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val token = try {
|
||||
set.name!!.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}", e)
|
||||
continue
|
||||
}
|
||||
val metadata = set.findFile(FILE_BACKUP_METADATA)
|
||||
if (metadata == null) {
|
||||
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
|
||||
} else {
|
||||
backupSets.add(BackupSet(token, metadata))
|
||||
}
|
||||
}
|
||||
return backupSets
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
||||
|
|
|
@ -9,7 +9,6 @@ import android.widget.Toast.LENGTH_LONG
|
|||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
|
||||
|
@ -27,13 +26,11 @@ abstract class BackupActivity : AppCompatActivity() {
|
|||
|
||||
protected abstract fun getInitialFragment(): Fragment
|
||||
|
||||
protected abstract fun isRestoreOperation(): Boolean
|
||||
|
||||
@CallSuper
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
getViewModel().onLocationSet.observeEvent(this, LiveEventHandler { result ->
|
||||
getViewModel().locationSet.observeEvent(this, LiveEventHandler { result ->
|
||||
if (result.validLocation) {
|
||||
if (result.initialSetup) showFragment(getInitialFragment())
|
||||
else supportFragmentManager.popBackStack()
|
||||
|
@ -44,21 +41,6 @@ abstract class BackupActivity : AppCompatActivity() {
|
|||
})
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (isFinishing) return
|
||||
|
||||
// check that backup is provisioned
|
||||
if (!getViewModel().recoveryCodeIsSet()) {
|
||||
showRecoveryCodeActivity()
|
||||
} else if (!getViewModel().validLocationIsSet()) {
|
||||
showFragment(BackupLocationFragment())
|
||||
// remove potential error notifications
|
||||
(application as Backup).notificationManager.onBackupErrorSeen()
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
|
||||
if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) {
|
||||
|
@ -80,9 +62,9 @@ abstract class BackupActivity : AppCompatActivity() {
|
|||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun showRecoveryCodeActivity() {
|
||||
protected fun showRecoveryCodeActivity() {
|
||||
val intent = Intent(this, RecoveryCodeActivity::class.java)
|
||||
intent.putExtra(INTENT_EXTRA_IS_RESTORE, isRestoreOperation())
|
||||
intent.putExtra(INTENT_EXTRA_IS_RESTORE, getViewModel().isRestoreOperation)
|
||||
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,26 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupProgress
|
||||
import android.app.backup.IBackupObserver
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.isOnExternalStorage
|
||||
import com.stevesoltys.backup.settings.getBackupFolderUri
|
||||
import com.stevesoltys.backup.settings.setBackupFolderUri
|
||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.backup.transport.TRANSPORT_ID
|
||||
|
||||
private val TAG = BackupViewModel::class.java.simpleName
|
||||
|
||||
abstract class BackupViewModel(protected val app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val locationWasSet = MutableLiveEvent<LocationResult>()
|
||||
protected val mLocationSet = MutableLiveEvent<LocationResult>()
|
||||
/**
|
||||
* Will be set to true if this is the initial location.
|
||||
* It will be false if an existing location was changed.
|
||||
*/
|
||||
internal val onLocationSet: LiveEvent<LocationResult> get() = locationWasSet
|
||||
internal val locationSet: LiveEvent<LocationResult> get() = mLocationSet
|
||||
|
||||
private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
|
||||
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
|
||||
|
@ -41,6 +35,8 @@ abstract class BackupViewModel(protected val app: Application) : AndroidViewMode
|
|||
return file.isDirectory
|
||||
}
|
||||
|
||||
abstract val isRestoreOperation: Boolean
|
||||
|
||||
internal fun handleChooseFolderResult(result: Intent?) {
|
||||
val folderUri = result?.data ?: return
|
||||
|
||||
|
@ -48,53 +44,10 @@ abstract class BackupViewModel(protected val app: Application) : AndroidViewMode
|
|||
val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
app.contentResolver.takePersistableUriPermission(folderUri, takeFlags)
|
||||
|
||||
// check if this is initial set-up or a later change
|
||||
val initialSetUp = !validLocationIsSet()
|
||||
|
||||
if (acceptBackupLocation(folderUri)) {
|
||||
// store backup folder location in settings
|
||||
setBackupFolderUri(app, folderUri)
|
||||
|
||||
// stop backup service to be sure the old location will get updated
|
||||
app.stopService(Intent(app, ConfigurableBackupTransportService::class.java))
|
||||
|
||||
Log.d(TAG, "New storage location chosen: $folderUri")
|
||||
|
||||
// initialize the new location
|
||||
// TODO don't do this when restoring
|
||||
Backup.backupManager.initializeTransports(arrayOf(TRANSPORT_ID), InitializationObserver(initialSetUp))
|
||||
} else {
|
||||
Log.w(TAG, "Location was rejected: $folderUri")
|
||||
|
||||
// notify the UI that the location was invalid
|
||||
locationWasSet.setEvent(LocationResult(false, initialSetUp))
|
||||
}
|
||||
onLocationSet(folderUri, !validLocationIsSet())
|
||||
}
|
||||
|
||||
protected open fun acceptBackupLocation(folderUri: Uri): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
private inner class InitializationObserver(private val initialSetUp: Boolean) : IBackupObserver.Stub() {
|
||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||
// noop
|
||||
}
|
||||
override fun onResult(target: String, status: Int) {
|
||||
// noop
|
||||
}
|
||||
override fun backupFinished(status: Int) {
|
||||
if (Log.isLoggable(TAG, Log.INFO)) {
|
||||
Log.i(TAG, "Initialization finished. Status: $status")
|
||||
}
|
||||
if (status == 0) {
|
||||
// notify the UI that the location has been set
|
||||
locationWasSet.postEvent(LocationResult(true, initialSetUp))
|
||||
} else {
|
||||
// notify the UI that the location was invalid
|
||||
locationWasSet.postEvent(LocationResult(false, initialSetUp))
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract fun onLocationSet(folderUri: Uri, isInitialSetup: Boolean)
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -75,6 +75,8 @@
|
|||
<string name="restore_title">Restore from Backup</string>
|
||||
<string name="restore_choose_restore_set">Choose a backup to restore</string>
|
||||
<string name="restore_back">Don\'t restore</string>
|
||||
<string name="restore_invalid_location_title">No backups found</string>
|
||||
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
|
||||
<string name="restore_set_error">An error occurred loading the backups.</string>
|
||||
<string name="restore_set_empty_result">No backups found at given location.</string>
|
||||
<string name="restore_restoring">Restoring Backup</string>
|
||||
|
|
Loading…
Reference in a new issue