Add a RestoreActivity that lists available RestoreSets (backups)
and allows the user to select one to get restored.
This commit is contained in:
parent
aa3aad8fb3
commit
491789e8e0
28 changed files with 640 additions and 147 deletions
|
@ -32,9 +32,20 @@
|
|||
android:exported="true" />
|
||||
|
||||
<activity
|
||||
android:name=".settings.RecoveryCodeActivity"
|
||||
android:name=".ui.RecoveryCodeActivity"
|
||||
android:label="@string/recovery_code_title" />
|
||||
|
||||
<activity
|
||||
android:name=".restore.RestoreActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/restore_title"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="com.stevesoltys.backup.restore.RESTORE_BACKUP" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.stevesoltys.backup.activity.MainActivity"
|
||||
android:label="@string/app_name" />
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package com.stevesoltys.backup.restore
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.ui.BackupActivity
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
class RestoreActivity : BackupActivity() {
|
||||
|
||||
private lateinit var viewModel: RestoreViewModel
|
||||
|
||||
override fun getViewModel(): BackupViewModel = viewModel
|
||||
|
||||
override fun getInitialFragment() = RestoreSetFragment()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProviders.of(this).get(RestoreViewModel::class.java)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_fragment_container)
|
||||
|
||||
if (savedInstanceState == null) showFragment(getInitialFragment())
|
||||
}
|
||||
|
||||
override fun onInvalidLocation() {
|
||||
// TODO alert dialog?
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package com.stevesoltys.backup.restore
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.restore.RestoreSetAdapter.RestoreSetViewHolder
|
||||
|
||||
internal class RestoreSetAdapter(
|
||||
private val listener: RestoreSetClickListener,
|
||||
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.list_item_restore_set, parent, false) as View
|
||||
return RestoreSetViewHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RestoreSetViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
||||
|
||||
private val titleView = v.findViewById<TextView>(R.id.titleView)
|
||||
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
|
||||
|
||||
internal fun bind(item: RestoreSet) {
|
||||
v.setOnClickListener { listener.onRestoreSetClicked(item) }
|
||||
titleView.text = item.name
|
||||
subtitleView.text = item.device
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
package com.stevesoltys.backup.restore
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.R
|
||||
import kotlinx.android.synthetic.main.fragment_restore_set.*
|
||||
|
||||
class RestoreSetFragment : Fragment(), RestoreSetClickListener {
|
||||
|
||||
private lateinit var viewModel: RestoreViewModel
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_restore_set, container, false)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(RestoreViewModel::class.java)
|
||||
|
||||
viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
|
||||
|
||||
backView.setOnClickListener { requireActivity().finishAfterTransition() }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.loadRestoreSets()
|
||||
}
|
||||
|
||||
private fun onRestoreSetsLoaded(result: RestoreSetResult) {
|
||||
if (result.hasError()) {
|
||||
errorView.visibility = VISIBLE
|
||||
listView.visibility = INVISIBLE
|
||||
progressBar.visibility = INVISIBLE
|
||||
|
||||
errorView.text = result.errorMsg
|
||||
} else {
|
||||
errorView.visibility = INVISIBLE
|
||||
listView.visibility = VISIBLE
|
||||
progressBar.visibility = INVISIBLE
|
||||
|
||||
listView.adapter = RestoreSetAdapter(this, result.sets)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreSetClicked(set: RestoreSet) {
|
||||
Log.e("TEST", "RESTORE SET CLICKED: ${set.name} ${set.device} ${set.token}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal interface RestoreSetClickListener {
|
||||
fun onRestoreSetClicked(set: RestoreSet)
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
package com.stevesoltys.backup.restore
|
||||
|
||||
import android.app.Application
|
||||
import android.app.backup.IRestoreObserver
|
||||
import android.app.backup.IRestoreSession
|
||||
import android.app.backup.RestoreSet
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
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.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
private val TAG = RestoreViewModel::class.java.simpleName
|
||||
|
||||
class RestoreViewModel(app: Application) : BackupViewModel(app) {
|
||||
|
||||
private val backupManager = Backup.backupManager
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
private var observer: RestoreObserver? = null
|
||||
private val monitor = BackupMonitor()
|
||||
|
||||
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
|
||||
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
|
||||
|
||||
override fun acceptBackupLocation(folderUri: Uri): Boolean {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun loadRestoreSets() {
|
||||
val session = this.session ?: backupManager.beginRestoreSession(null, TRANSPORT_ID)
|
||||
this.session = session
|
||||
|
||||
if (session == null) {
|
||||
Log.e(TAG, "beginRestoreSession() returned null session")
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
return
|
||||
}
|
||||
val observer = this.observer ?: RestoreObserver()
|
||||
this.observer = observer
|
||||
|
||||
val setResult = session.getAvailableRestoreSets(observer, monitor)
|
||||
if (setResult != 0) {
|
||||
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
observer = null
|
||||
}
|
||||
|
||||
private inner class RestoreObserver : IRestoreObserver.Stub() {
|
||||
|
||||
/**
|
||||
* Supply a list of the restore datasets available from the current transport.
|
||||
* This method is invoked as a callback following the application's use of the
|
||||
* [IRestoreSession.getAvailableRestoreSets] method.
|
||||
*
|
||||
* @param restoreSets An array of [RestoreSet] objects
|
||||
* describing all of the available datasets that are candidates for restoring to
|
||||
* the current device. If no applicable datasets exist, restoreSets will be null.
|
||||
*/
|
||||
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
|
||||
if (restoreSets == null || restoreSets.isEmpty()) {
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_empty_result))
|
||||
} else {
|
||||
mRestoreSets.postValue(RestoreSetResult(restoreSets))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has begun.
|
||||
*
|
||||
* @param numPackages The total number of packages being processed in this restore operation.
|
||||
*/
|
||||
override fun restoreStarting(numPackages: Int) {
|
||||
Log.e(TAG, "RESTORE STARTING $numPackages")
|
||||
}
|
||||
|
||||
/**
|
||||
* An indication of which package is being restored currently,
|
||||
* out of the total number provided in the [restoreStarting] callback.
|
||||
* This method is not guaranteed to be called.
|
||||
*
|
||||
* @param nowBeingRestored The index, between 1 and the numPackages parameter
|
||||
* to the [restoreStarting] callback, of the package now being restored.
|
||||
* @param currentPackage The name of the package now being restored.
|
||||
*/
|
||||
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
|
||||
Log.e(TAG, "RESTORE UPDATE $nowBeingRestored $currentPackage")
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has completed.
|
||||
*
|
||||
* @param error Zero on success; a nonzero error code if the restore operation
|
||||
* as a whole failed.
|
||||
*/
|
||||
override fun restoreFinished(error: Int) {
|
||||
Log.e(TAG, "RESTORE FINISHED $error")
|
||||
endSession()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal class RestoreSetResult(
|
||||
internal val sets: Array<out RestoreSet>,
|
||||
internal val errorMsg: String?) {
|
||||
|
||||
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
|
||||
|
||||
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
|
||||
|
||||
internal fun hasError(): Boolean = errorMsg != null
|
||||
}
|
|
@ -1,85 +1,28 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.LiveEventHandler
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.ui.BackupActivity
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
private val TAG = SettingsActivity::class.java.name
|
||||
|
||||
const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1
|
||||
const val REQUEST_CODE_RECOVERY_CODE = 2
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
class SettingsActivity : BackupActivity() {
|
||||
|
||||
private lateinit var viewModel: SettingsViewModel
|
||||
|
||||
override fun getViewModel(): BackupViewModel = viewModel
|
||||
|
||||
override fun getInitialFragment() = SettingsFragment()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
|
||||
viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp ->
|
||||
if (initialSetUp) showFragment(SettingsFragment())
|
||||
else supportFragmentManager.popBackStack()
|
||||
})
|
||||
viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
|
||||
if (show) showFragment(BackupLocationFragment(), true)
|
||||
})
|
||||
setContentView(R.layout.activity_fragment_container)
|
||||
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (savedInstanceState == null) showFragment(SettingsFragment())
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) {
|
||||
if (resultCode != RESULT_OK) {
|
||||
Log.w(TAG, "Error in activity result: $requestCode")
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, result)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
|
||||
item.itemId == android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun showRecoveryCodeActivity() {
|
||||
val intent = Intent(this, RecoveryCodeActivity::class.java)
|
||||
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
|
||||
}
|
||||
|
||||
private fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
|
||||
val fragmentTransaction = supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, f)
|
||||
if (addToBackStack) fragmentTransaction.addToBackStack(null)
|
||||
fragmentTransaction.commit()
|
||||
if (savedInstanceState == null) showFragment(getInitialFragment())
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.content.Context.BACKUP_SERVICE
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.Settings
|
||||
|
@ -17,6 +18,7 @@ import androidx.preference.PreferenceFragmentCompat
|
|||
import androidx.preference.TwoStatePreference
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.R
|
||||
import com.stevesoltys.backup.restore.RestoreActivity
|
||||
|
||||
private val TAG = SettingsFragment::class.java.name
|
||||
|
||||
|
@ -100,7 +102,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
true
|
||||
}
|
||||
item.itemId == R.id.action_restore -> {
|
||||
Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show()
|
||||
startActivity(Intent(requireContext(), RestoreActivity::class.java))
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
|
|
|
@ -1,66 +1,10 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.LiveEvent
|
||||
import com.stevesoltys.backup.MutableLiveEvent
|
||||
import com.stevesoltys.backup.isOnExternalStorage
|
||||
import com.stevesoltys.backup.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.backup.transport.requestBackup
|
||||
import com.stevesoltys.backup.ui.BackupViewModel
|
||||
|
||||
private val TAG = SettingsViewModel::class.java.simpleName
|
||||
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val app = application
|
||||
|
||||
private val locationWasSet = MutableLiveEvent<Boolean>()
|
||||
/**
|
||||
* 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<Boolean> = locationWasSet
|
||||
|
||||
private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
|
||||
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
|
||||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||
|
||||
fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
|
||||
|
||||
fun validLocationIsSet(): Boolean {
|
||||
val uri = getBackupFolderUri(app) ?: return false
|
||||
if (uri.isOnExternalStorage()) return true // might be a temporary failure
|
||||
val file = DocumentFile.fromTreeUri(app, uri) ?: return false
|
||||
return file.isDirectory
|
||||
}
|
||||
|
||||
fun handleChooseFolderResult(result: Intent?) {
|
||||
val folderUri = result?.data ?: return
|
||||
|
||||
// persist permission to access backup folder across reboots
|
||||
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()
|
||||
|
||||
// store backup folder location in settings
|
||||
setBackupFolderUri(app, folderUri)
|
||||
|
||||
// notify the UI that the location has been set
|
||||
locationWasSet.setEvent(initialSetUp)
|
||||
|
||||
// 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")
|
||||
}
|
||||
class SettingsViewModel(app: Application) : BackupViewModel(app) {
|
||||
|
||||
fun backupNow() = Thread { requestBackup(app) }.start()
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import android.os.ParcelFileDescriptor
|
|||
import android.util.Log
|
||||
import com.stevesoltys.backup.settings.SettingsActivity
|
||||
|
||||
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
|
||||
const val DEFAULT_RESTORE_SET_TOKEN: Long = 1
|
||||
|
||||
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport"
|
||||
|
@ -32,8 +33,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
|||
}
|
||||
|
||||
override fun name(): String {
|
||||
// TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName.
|
||||
return this.javaClass.name
|
||||
return TRANSPORT_ID
|
||||
}
|
||||
|
||||
override fun getTransportFlags(): Int {
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
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
|
||||
const val REQUEST_CODE_RECOVERY_CODE = 2
|
||||
|
||||
private val TAG = BackupActivity::class.java.name
|
||||
|
||||
/**
|
||||
* An Activity that requires the recovery code and the backup location to be set up
|
||||
* before starting.
|
||||
*/
|
||||
abstract class BackupActivity : AppCompatActivity() {
|
||||
|
||||
protected abstract fun getViewModel(): BackupViewModel
|
||||
|
||||
protected abstract fun getInitialFragment(): Fragment
|
||||
|
||||
@CallSuper
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
getViewModel().onLocationSet.observeEvent(this, LiveEventHandler { result ->
|
||||
if (result.validLocation) {
|
||||
if (result.initialSetup) showFragment(getInitialFragment())
|
||||
else supportFragmentManager.popBackStack()
|
||||
} else onInvalidLocation()
|
||||
})
|
||||
getViewModel().chooseBackupLocation.observeEvent(this, LiveEventHandler { show ->
|
||||
if (show) showFragment(BackupLocationFragment(), true)
|
||||
})
|
||||
}
|
||||
|
||||
@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) {
|
||||
Log.w(TAG, "Error in activity result: $requestCode")
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, result)
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
|
||||
item.itemId == android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun showRecoveryCodeActivity() {
|
||||
val intent = Intent(this, RecoveryCodeActivity::class.java)
|
||||
startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE)
|
||||
}
|
||||
|
||||
protected open fun onInvalidLocation() {
|
||||
Toast.makeText(this, getString(R.string.settings_backup_location_invalid), LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
|
||||
val fragmentTransaction = supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, f)
|
||||
if (addToBackStack) fragmentTransaction.addToBackStack(null)
|
||||
fragmentTransaction.commit()
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.ActivityNotFoundException
|
||||
|
@ -12,8 +12,7 @@ import androidx.lifecycle.ViewModelProviders
|
|||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
private val TAG = BackupLocationFragment::class.java.name
|
||||
import com.stevesoltys.backup.settings.SettingsViewModel
|
||||
|
||||
class BackupLocationFragment : PreferenceFragmentCompat() {
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.app.Application
|
||||
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
|
||||
|
||||
private val TAG = BackupViewModel::class.java.simpleName
|
||||
|
||||
abstract class BackupViewModel(protected val app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val locationWasSet = 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> = locationWasSet
|
||||
|
||||
private val mChooseBackupLocation = MutableLiveEvent<Boolean>()
|
||||
internal val chooseBackupLocation: LiveEvent<Boolean> = mChooseBackupLocation
|
||||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||
|
||||
internal fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey()
|
||||
|
||||
internal fun validLocationIsSet(): Boolean {
|
||||
val uri = getBackupFolderUri(app) ?: return false
|
||||
if (uri.isOnExternalStorage()) return true // might be a temporary failure
|
||||
val file = DocumentFile.fromTreeUri(app, uri) ?: return false
|
||||
return file.isDirectory
|
||||
}
|
||||
|
||||
internal fun handleChooseFolderResult(result: Intent?) {
|
||||
val folderUri = result?.data ?: return
|
||||
|
||||
// persist permission to access backup folder across reboots
|
||||
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)
|
||||
|
||||
// notify the UI that the location has been set
|
||||
locationWasSet.setEvent(LocationResult(true, initialSetUp))
|
||||
|
||||
// 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")
|
||||
} else {
|
||||
Log.w(TAG, "Location was rejected: $folderUri")
|
||||
|
||||
// notify the UI that the location was invalid
|
||||
locationWasSet.setEvent(LocationResult(false, initialSetUp))
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun acceptBackupLocation(folderUri: Uri): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LocationResult(val validLocation: Boolean, val initialSetup: Boolean)
|
|
@ -1,9 +1,9 @@
|
|||
package com.stevesoltys.backup
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import com.stevesoltys.backup.LiveEvent.ConsumableEvent
|
||||
import com.stevesoltys.backup.ui.LiveEvent.ConsumableEvent
|
||||
|
||||
open class LiveEvent<T> : LiveData<ConsumableEvent<T>>() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup;
|
||||
package com.stevesoltys.backup.ui;
|
||||
|
||||
public interface LiveEventHandler<T> {
|
||||
void onEvent(T t);
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
class MutableLiveEvent<T> : LiveEvent<T>() {
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.stevesoltys.backup.LiveEventHandler
|
||||
import com.stevesoltys.backup.R
|
||||
|
||||
class RecoveryCodeActivity : AppCompatActivity() {
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
@ -57,7 +57,7 @@ class RecoveryCodeInputFragment : Fragment() {
|
|||
}
|
||||
|
||||
private fun allFilledOut(input: List<CharSequence>): Boolean {
|
||||
for (i in 0 until input.size) {
|
||||
for (i in input.indices) {
|
||||
if (input[i].isNotEmpty()) continue
|
||||
showError(i, getString(R.string.recovery_code_error_empty_word))
|
||||
return false
|
||||
|
@ -96,7 +96,7 @@ class RecoveryCodeInputFragment : Fragment() {
|
|||
|
||||
private fun debugPreFill() {
|
||||
val words = viewModel.wordList
|
||||
for (i in 0 until words.size) {
|
||||
for (i in words.indices) {
|
||||
getWordLayout(i).editText!!.setText(words[i])
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
|
@ -1,10 +1,8 @@
|
|||
package com.stevesoltys.backup.settings
|
||||
package com.stevesoltys.backup.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.backup.Backup
|
||||
import com.stevesoltys.backup.LiveEvent
|
||||
import com.stevesoltys.backup.MutableLiveEvent
|
||||
import io.github.novacrypto.bip39.*
|
||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||
import io.github.novacrypto.bip39.Validation.InvalidWordCountException
|
10
app/src/main/res/drawable/ic_cloud_download.xml
Normal file
10
app/src/main/res/drawable/ic_cloud_download.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_phone_android.xml
Normal file
10
app/src/main/res/drawable/ic_phone_android.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
|
||||
</vector>
|
|
@ -9,7 +9,7 @@
|
|||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:context=".settings.RecoveryCodeInputFragment">
|
||||
tools:context=".ui.RecoveryCodeInputFragment">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/introIcon"
|
||||
|
|
81
app/src/main/res/layout/fragment_restore_set.xml
Normal file
81
app/src/main/res/layout/fragment_restore_set.xml
Normal file
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_margin="16dp"
|
||||
android:tint="?android:colorAccent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_cloud_download"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/restore_choose_restore_set"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/listView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toTopOf="@+id/backView"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
tools:listitem="@layout/list_item_restore_set" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toTopOf="@+id/backView"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@android:color/holo_red_dark"
|
||||
android:textSize="18sp"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintBottom_toTopOf="@+id/backView"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
tools:text="There was an error retrieving your backups." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/backView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/restore_back"
|
||||
android:textColor="?android:colorAccent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/listView" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
45
app/src/main/res/layout/list_item_restore_set.xml
Normal file
45
app/src/main/res/layout/list_item_restore_set.xml
Normal file
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:selectableItemBackground"
|
||||
tools:showIn="@layout/fragment_restore_set">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_phone_android"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="18sp"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Pixel 2 XL backup" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitleView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:textColor="?android:attr/textColorTertiary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/titleView"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
tools:text="Yesterday, 8:25 AM" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -29,6 +29,7 @@
|
|||
<string name="settings_backup_location_picker">Choose backup location</string>
|
||||
<string name="settings_backup_location_title">Backup Location</string>
|
||||
<string name="settings_backup_location_info">Choose where to store your backups. More options might get added in the future.</string>
|
||||
<string name="settings_backup_location_invalid">The chosen location can not be used.</string>
|
||||
<string name="settings_backup_external_storage">External Storage</string>
|
||||
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
|
||||
<string name="settings_auto_restore_title">Automatic restore</string>
|
||||
|
@ -70,4 +71,11 @@
|
|||
<string name="notification_error_text">A device backup failed to run.</string>
|
||||
<string name="notification_error_action">Fix</string>
|
||||
|
||||
<!-- Restore -->
|
||||
<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_set_error">An error occurred loading the backups.</string>
|
||||
<string name="restore_set_empty_result">No backups found at given location.</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -4,4 +4,9 @@
|
|||
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="AppTheme">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue