更新备份恢复数据库的实现方式

不再支持旧方式
使用 Room Backup 1.0.2,对其部分代码改用 Kotlin 原生实现,部分参考 LibChecker 的实现
This commit is contained in:
Super12138 2024-12-25 19:08:56 +08:00
parent cb334151b6
commit 77fc59238f
14 changed files with 1260 additions and 190 deletions

View file

@ -81,6 +81,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.preference)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.security)
// Material Design
implementation(libs.material)
// Room
@ -88,8 +89,8 @@ dependencies {
implementation(libs.androidx.room.ktx)
annotationProcessor(libs.androidx.room.compiler)
ksp(libs.androidx.room.compiler)
// Gson
implementation(libs.gson)
// Room Backup
// implementation(libs.room.backup)
// Fast Scroll
implementation(libs.fast.scroll)
// Test
@ -100,7 +101,6 @@ dependencies {
fun String.exec(): String = exec(this)
@Suppress("UnstableApiUsage")
fun Project.exec(command: String): String = providers.exec {
commandLine(command.split(" "))
}.standardOutput.asText.get().trim()

View file

@ -19,12 +19,5 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Gson
-keepattributes Signature
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations
# ToDo Room
-keep class cn.super12138.todo.logic.dao.ToDoRoom { *; }
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.concurrent.GuardedBy

View file

@ -30,7 +30,7 @@
</activity>
<activity
android:name=".views.activities.CrashActivity"
android:exported="false" />
android:exported="false"
android:theme="@style/Theme.ToDo" />
</application>
</manifest>

View file

@ -13,8 +13,10 @@ import cn.super12138.todo.databinding.ActivityMainBinding
import cn.super12138.todo.views.BaseActivity
import cn.super12138.todo.views.fragments.SettingsParentFragment
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
class MainActivity : BaseActivity<ActivityMainBinding>() {
lateinit var roomBackup: RoomBackup
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@ -32,6 +34,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
handleIntent(intent)
roomBackup = RoomBackup(this)
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {

View file

@ -6,9 +6,7 @@ import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@ -16,29 +14,22 @@ import androidx.preference.SwitchPreferenceCompat
import cn.super12138.todo.R
import cn.super12138.todo.constant.Constants
import cn.super12138.todo.constant.GlobalValues
import cn.super12138.todo.databinding.DialogBackupBinding
import cn.super12138.todo.databinding.DialogRestoreBinding
import cn.super12138.todo.logic.Repository
import cn.super12138.todo.logic.dao.ToDoRoom
import cn.super12138.todo.logic.dao.ToDoRoomDB
import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.views.activities.MainActivity
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.launch
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backupBinding: DialogBackupBinding
private lateinit var restoreBinding: DialogRestoreBinding
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
val mainActivity = requireActivity() as MainActivity
val gson = Gson()
val roomBackup = mainActivity.roomBackup
findPreference<ListPreference>(Constants.PREF_DARK_MODE)?.apply {
setOnPreferenceClickListener {
@ -87,22 +78,35 @@ class SettingsFragment : PreferenceFragmentCompat() {
findPreference<Preference>(Constants.PREF_BACKUP_DB)?.apply {
setOnPreferenceClickListener {
VibrationUtils.performHapticFeedback(view)
lifecycleScope.launch {
val data = Repository.getAll()
val jsonData = gson.toJson(data)
backupBinding = DialogBackupBinding.inflate(layoutInflater)
backupBinding.jsonOutput.text = jsonData
activity?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.export_data)
.setView(backupBinding.root)
.setPositiveButton(R.string.ok) { _, _ ->
VibrationUtils.performHapticFeedback(view)
val simpleDateFormat =
SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val formattedDate = simpleDateFormat.format(Date())
roomBackup
.database(ToDoRoomDB.getDatabase(requireContext()))
.enableLogDebug(GlobalValues.devMode)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.customBackupFileName("ToDo-DataBase-$formattedDate.sqlite3")
.apply {
onCompleteListener { success, _, exitCode ->
if (success) {
view?.let { it1 ->
Snackbar.make(
it1, R.string.tips_backup_success, Snackbar.LENGTH_LONG
).show()
}
} else {
view?.let { it1 ->
Snackbar.make(
it1, getString(
R.string.tips_backup_failed,
exitCode
), Snackbar.LENGTH_LONG
).show()
}
}
.show()
}
}
}
.backup()
true
}
}
@ -111,61 +115,39 @@ class SettingsFragment : PreferenceFragmentCompat() {
setOnPreferenceClickListener {
VibrationUtils.performHapticFeedback(view)
roomBackup
.database(ToDoRoomDB.getDatabase(requireContext()))
.enableLogDebug(GlobalValues.devMode)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.apply {
onCompleteListener { success, _, exitCode ->
if (success) {
view?.let { it1 ->
Snackbar.make(
it1,
R.string.tips_restore_success,
Snackbar.LENGTH_LONG
)
.setAction(R.string.restart_app_now) {
VibrationUtils.performHapticFeedback(view)
restartApp(context)
}
.show()
}
restoreBinding = DialogRestoreBinding.inflate(layoutInflater)
activity?.let { it1 ->
val dialog = MaterialAlertDialogBuilder(it1)
.setTitle(R.string.restore_data)
.setView(restoreBinding.root)
.setPositiveButton(R.string.ok, null)
.setNegativeButton(R.string.cancel, null)
.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
VibrationUtils.performHapticFeedback(view)
val jsonInput = restoreBinding.jsonInput.editText?.text.toString()
if (jsonInput.isEmpty()) {
restoreBinding.jsonInput.error =
getString(R.string.please_paste_data)
return@setOnClickListener
}
lifecycleScope.launch {
if (restoreBinding.overwriteData.isChecked) {
Repository.deleteAll()
}
try {
val listType = object : TypeToken<List<ToDoRoom>>() {}.type
val taskList: List<ToDoRoom> =
gson.fromJson(jsonInput, listType)
taskList.forEach { Repository.insert(it) }
dialog.dismiss()
MaterialAlertDialogBuilder(it1)
.setTitle(R.string.restore_successful)
.setMessage(R.string.restore_need_restart_app)
.setPositiveButton(R.string.ok) { _, _ ->
VibrationUtils.performHapticFeedback(view)
restartApp(context)
}
.setNegativeButton(R.string.cancel, null)
.setCancelable(false)
.show()
} catch (e: JsonSyntaxException) {
restoreBinding.jsonInput.error =
getString(R.string.json_data_incorrect)
} catch (e: Exception) {
if (GlobalValues.devMode) {
restoreBinding.jsonInput.error = e.toString()
} else {
restoreBinding.jsonInput.error =
getString(R.string.restore_failed)
} else {
view?.let { it1 ->
Snackbar.make(
it1, getString(
R.string.tips_restore_failed,
exitCode
), Snackbar.LENGTH_LONG
).show()
}
}
}
}
}
.restore()
true
}
}
@ -195,7 +177,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
override fun setDivider(divider: Drawable?) {
super.setDivider(ColorDrawable(Color.TRANSPARENT))
}
@ -204,7 +185,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
super.setDividerHeight(0)
}
private fun restartApp(restartContext: Context) {
val intent = Intent(restartContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK

View file

@ -0,0 +1,129 @@
package de.raphaelebner.roomdatabasebackup.core
import android.annotation.SuppressLint
import android.content.SharedPreferences
import java.io.*
import java.security.NoSuchAlgorithmException
import java.security.spec.InvalidKeySpecException
import java.security.spec.KeySpec
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* MIT License
*
* Copyright (c) 2024 Raphael Ebner
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute,
* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
* NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
class AESEncryptionHelper {
companion object {
private const val BACKUP_SECRET_KEY = "backupsecretkey"
private const val TAG = "debug_AESEncryptionHelper"
}
/**
* This method will convert a file to ByteArray
* @param file : the Path where the file is located
* @return ByteArray of the file
*/
@Throws(Exception::class)
fun readFile(file: File): ByteArray {
val fileContents = file.readBytes()
val inputBuffer = BufferedInputStream(FileInputStream(file))
inputBuffer.read(fileContents)
inputBuffer.close()
return fileContents
}
/**
* This method will convert a ByteArray to a file, and saves it to the path
* @param fileData : the ByteArray
* @param file : the path where the ByteArray should be saved
*/
@Throws(Exception::class)
fun saveFile(fileData: ByteArray, file: File) {
val bos = BufferedOutputStream(FileOutputStream(file, false))
bos.write(fileData)
bos.flush()
bos.close()
}
/**
* This method will convert a random password, saved in sharedPreferences to a SecretKey
* @param sharedPref : the sharedPref, to fetch / save the key
* @param iv : the encryption nonce
* @return SecretKey
*/
@SuppressLint("ApplySharedPref")
fun getSecretKey(sharedPref: SharedPreferences, iv: ByteArray): SecretKey {
// get key: String from sharedpref
var password = sharedPref.getString(BACKUP_SECRET_KEY, null)
// If no key is stored in shared pref, create one and save it
if (password == null) {
// generate random string
val stringLength = 15
val charset = ('a'..'z') + ('A'..'Z') + ('1'..'9')
password = (1..stringLength).map { charset.random() }.joinToString("")
val secretKey = generateSecretKey(password, iv)
// the key can be saved plain, because i am using EncryptedSharedPreferences
val editor = sharedPref.edit()
editor.putString(BACKUP_SECRET_KEY, password)
// I use .commit because when using .apply the needed app restart is faster then apply
// and the preferences wont be saved
editor.commit()
return secretKey
}
// generate secretKey, and return it
return generateSecretKey(password, iv)
}
/**
* This method will convert a custom password to a SecretKey
* @param encryptPassword : the custom user password as String
* @param iv : the encryption nonce
* @return SecretKey
*/
fun getSecretKeyWithCustomPw(encryptPassword: String, iv: ByteArray): SecretKey {
// generate secretKey, and return it
return generateSecretKey(encryptPassword, iv)
}
/**
* Function to generate a 128 bit key from the given password and iv
* @param password
* @param iv
* @return Secret key
* @throws NoSuchAlgorithmException
* @throws InvalidKeySpecException
*/
@Throws(NoSuchAlgorithmException::class, InvalidKeySpecException::class)
private fun generateSecretKey(password: String, iv: ByteArray?): SecretKey {
// convert random string to secretKey
val spec: KeySpec = PBEKeySpec(password.toCharArray(), iv, 65536, 128) // AES-128
val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val key = secretKeyFactory.generateSecret(spec).encoded
return SecretKeySpec(key, "AES")
}
}

View file

@ -0,0 +1,114 @@
package de.raphaelebner.roomdatabasebackup.core
import android.content.SharedPreferences
import java.nio.ByteBuffer
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.security.spec.InvalidKeySpecException
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.NoSuchPaddingException
import javax.crypto.spec.GCMParameterSpec
/**
* Encryption / Decryption service using the AES algorithm
* example for nullbeans.com
* https://nullbeans.com/how-to-encrypt-decrypt-files-byte-arrays-in-java-using-aes-gcm/#Generating_an_AES_key
*/
class AESEncryptionManager {
private val aesEncryptionHelper = AESEncryptionHelper()
/**
* This method will encrypt the given data
* @param sharedPref : the sharedPref, to fetch the key
* @param data : the data that will be encrypted
* @return Encrypted data in a byte array
*/
@Throws(
NoSuchPaddingException::class,
NoSuchAlgorithmException::class,
InvalidAlgorithmParameterException::class,
InvalidKeyException::class,
BadPaddingException::class,
IllegalBlockSizeException::class,
InvalidKeySpecException::class
)
fun encryptData(sharedPref: SharedPreferences, encryptPassword: String?, data: ByteArray): ByteArray {
//Prepare the nonce
val secureRandom = SecureRandom()
//Noonce should be 12 bytes
val iv = ByteArray(12)
secureRandom.nextBytes(iv)
//Prepare your key/password
val secretKey = if (encryptPassword != null) { aesEncryptionHelper.getSecretKeyWithCustomPw(encryptPassword, iv)
} else aesEncryptionHelper.getSecretKey(sharedPref, iv)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val parameterSpec = GCMParameterSpec(128, iv)
//Encryption mode on!
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec)
//Encrypt the data
val encryptedData = cipher.doFinal(data)
//Concatenate everything and return the final data
val byteBuffer = ByteBuffer.allocate(4 + iv.size + encryptedData.size)
byteBuffer.putInt(iv.size)
byteBuffer.put(iv)
byteBuffer.put(encryptedData)
return byteBuffer.array()
}
/**
* This method will decrypt the given data
* @param sharedPref : the sharedPref, to fetch the key
* @param encryptedData : the data that will be decrypted
* @return decrypted data in a byte array
*/
@Throws(
NoSuchPaddingException::class,
NoSuchAlgorithmException::class,
InvalidAlgorithmParameterException::class,
InvalidKeyException::class,
BadPaddingException::class,
IllegalBlockSizeException::class,
InvalidKeySpecException::class
)
fun decryptData(sharedPref: SharedPreferences, encryptPassword: String?, encryptedData: ByteArray): ByteArray {
//Wrap the data into a byte buffer to ease the reading process
val byteBuffer = ByteBuffer.wrap(encryptedData)
val noonceSize = byteBuffer.int
//Make sure that the file was encrypted properly
require(!(noonceSize < 12 || noonceSize >= 16)) { "Nonce size is incorrect. Make sure that the incoming data is an AES encrypted file." }
val iv = ByteArray(noonceSize)
byteBuffer[iv]
//Prepare your key/password
val secretKey = if (encryptPassword != null) { aesEncryptionHelper.getSecretKeyWithCustomPw(encryptPassword, iv)
} else aesEncryptionHelper.getSecretKey(sharedPref, iv)
//get the rest of encrypted data
val cipherBytes = ByteArray(byteBuffer.remaining())
byteBuffer[cipherBytes]
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val parameterSpec = GCMParameterSpec(128, iv)
//Encryption mode on!
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)
//Encrypt the data
return cipher.doFinal(cipherBytes)
}
}

View file

@ -0,0 +1,78 @@
package de.raphaelebner.roomdatabasebackup.core
import de.raphaelebner.roomdatabasebackup.core.RoomBackup.Companion.BACKUP_FILE_LOCATION_CUSTOM_FILE
/**
* MIT License
*
* Copyright (c) 2024 Raphael Ebner
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute,
* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
* NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
interface OnCompleteListener {
fun onComplete(success: Boolean, message: String, exitCode: Int)
companion object {
/** Other Error */
const val EXIT_CODE_ERROR = 1
/** Error while choosing backup to restore. Maybe no file selected */
const val EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER = 2
/** Error while choosing backup file to create. Maybe no file selected */
const val EXIT_CODE_ERROR_BACKUP_FILE_CREATOR = 3
/**
* [BACKUP_FILE_LOCATION_CUSTOM_FILE] is set but [RoomBackup.backupLocationCustomFile] is
* not set
*/
const val EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING = 4
/** [RoomBackup.backupLocation] is not set */
const val EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING = 5
/** Restore dialog for internal/external storage was canceled by user */
const val EXIT_CODE_ERROR_BY_USER_CANCELED = 6
/** Cannot decrypt provided backup file */
const val EXIT_CODE_ERROR_DECRYPTION_ERROR = 7
/** Cannot encrypt database backup */
const val EXIT_CODE_ERROR_ENCRYPTION_ERROR = 8
/**
* You tried to restore a encrypted backup but [RoomBackup.backupIsEncrypted] is set to
* false
*/
const val EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED = 9
/** No backups to restore are available in internal/external sotrage */
const val EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE = 10
/** No room database to backup is provided */
const val EXIT_CODE_ERROR_ROOM_DATABASE_MISSING = 11
/** Storage permissions not granted for custom dialog */
const val EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED = 12
/** Cannot decrypt provided backup file because the password is incorrect */
const val EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD = 13
/** No error, action successful */
const val EXIT_CODE_SUCCESS = 0
}
}

View file

@ -0,0 +1,853 @@
package de.raphaelebner.roomdatabasebackup.core
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.room.RoomDatabase
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.runBlocking
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import javax.crypto.BadPaddingException
/**
* MIT License
*
* Copyright (c) 2024 Raphael Ebner
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute,
* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
* NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
class RoomBackup(var context: Context) {
companion object {
private const val SHARED_PREFS = "de.raphaelebner.roomdatabasebackup"
private var TAG = "debug_RoomBackup"
private lateinit var INTERNAL_BACKUP_PATH: File
private lateinit var TEMP_BACKUP_PATH: File
private lateinit var TEMP_BACKUP_FILE: File
private lateinit var EXTERNAL_BACKUP_PATH: File
private lateinit var DATABASE_FILE: File
private var currentProcess: Int? = null
private const val PROCESS_BACKUP = 1
private const val PROCESS_RESTORE = 2
private var backupFilename: String? = null
/** Code for internal backup location, used for [backupLocation] */
const val BACKUP_FILE_LOCATION_INTERNAL = 1
/** Code for external backup location, used for [backupLocation] */
const val BACKUP_FILE_LOCATION_EXTERNAL = 2
/** Code for custom backup location dialog, used for [backupLocation] */
const val BACKUP_FILE_LOCATION_CUSTOM_DIALOG = 3
/** Code for custom backup file location, used for [backupLocation] */
const val BACKUP_FILE_LOCATION_CUSTOM_FILE = 4
}
private lateinit var sharedPreferences: SharedPreferences
private lateinit var dbName: String
private var roomDatabase: RoomDatabase? = null
private var enableLogDebug: Boolean = false
private var restartIntent: Intent? = null
private var onCompleteListener: OnCompleteListener? = null
private var customRestoreDialogTitle: String = "Choose file to restore"
private var customBackupFileName: String? = null
private var backupIsEncrypted: Boolean = false
private var maxFileCount: Int? = null
private var encryptPassword: String? = null
private var backupLocation: Int = BACKUP_FILE_LOCATION_INTERNAL
private var backupLocationCustomFile: File? = null
/**
* Set RoomDatabase instance
*
* @param roomDatabase RoomDatabase
*/
fun database(roomDatabase: RoomDatabase): RoomBackup {
this.roomDatabase = roomDatabase
return this
}
/**
* Set LogDebug enabled / disabled
*
* @param enableLogDebug Boolean
*/
fun enableLogDebug(enableLogDebug: Boolean): RoomBackup {
this.enableLogDebug = enableLogDebug
return this
}
/**
* Set Intent in which to boot after App restart
*
* @param restartIntent Intent
*/
fun restartApp(restartIntent: Intent): RoomBackup {
this.restartIntent = restartIntent
restartApp()
return this
}
/**
* Set onCompleteListener, to run code when tasks completed
*
* @param onCompleteListener OnCompleteListener
*/
fun onCompleteListener(onCompleteListener: OnCompleteListener): RoomBackup {
this.onCompleteListener = onCompleteListener
return this
}
/**
* Set onCompleteListener, to run code when tasks completed
*
* @param listener (success: Boolean, message: String) -> Unit
*/
fun onCompleteListener(
listener: (success: Boolean, message: String, exitCode: Int) -> Unit
): RoomBackup {
this.onCompleteListener =
object : OnCompleteListener {
override fun onComplete(success: Boolean, message: String, exitCode: Int) {
listener(success, message, exitCode)
}
}
return this
}
/**
* Set custom log tag, for detailed debugging
*
* @param customLogTag String
*/
fun customLogTag(customLogTag: String): RoomBackup {
TAG = customLogTag
return this
}
/**
* Set custom Restore Dialog Title, default = "Choose file to restore"
*
* @param customRestoreDialogTitle String
*/
fun customRestoreDialogTitle(customRestoreDialogTitle: String): RoomBackup {
this.customRestoreDialogTitle = customRestoreDialogTitle
return this
}
/**
* Set custom Backup File Name, default = "$dbName-$currentTime.sqlite3"
*
* @param customBackupFileName String
*/
fun customBackupFileName(customBackupFileName: String): RoomBackup {
this.customBackupFileName = customBackupFileName
return this
}
/**
* Set you backup location. Available values see: [BACKUP_FILE_LOCATION_INTERNAL],
* [BACKUP_FILE_LOCATION_EXTERNAL], [BACKUP_FILE_LOCATION_CUSTOM_DIALOG] or
* [BACKUP_FILE_LOCATION_CUSTOM_FILE]
*
* @param backupLocation Int, default = [BACKUP_FILE_LOCATION_INTERNAL]
*/
fun backupLocation(backupLocation: Int): RoomBackup {
this.backupLocation = backupLocation
return this
}
/**
* Set a custom file where to save or restore a backup. can be used for backup and restore
*
* Only available if [backupLocation] is set to [BACKUP_FILE_LOCATION_CUSTOM_FILE]
*
* @param backupLocationCustomFile File
*/
fun backupLocationCustomFile(backupLocationCustomFile: File): RoomBackup {
this.backupLocationCustomFile = backupLocationCustomFile
return this
}
/**
* Set file encryption to true / false can be used for backup and restore
*
* @param backupIsEncrypted Boolean, default = false
*/
fun backupIsEncrypted(backupIsEncrypted: Boolean): RoomBackup {
this.backupIsEncrypted = backupIsEncrypted
return this
}
/**
* Set max backup files count if fileCount is > maxFileCount the oldest backup file will be
* deleted is for both internal and external storage
*
* @param maxFileCount Int, default = null
*/
fun maxFileCount(maxFileCount: Int): RoomBackup {
this.maxFileCount = maxFileCount
return this
}
/**
* Set custom backup encryption password
*
* @param encryptPassword String
*/
fun customEncryptPassword(encryptPassword: String): RoomBackup {
this.encryptPassword = encryptPassword
return this
}
/** Init vars, and return true if no error occurred */
private fun initRoomBackup(): Boolean {
if (roomDatabase == null) {
if (enableLogDebug) Log.d(TAG, "roomDatabase is missing")
onCompleteListener?.onComplete(
false,
"roomDatabase is missing",
OnCompleteListener.EXIT_CODE_ERROR_ROOM_DATABASE_MISSING
)
// throw IllegalArgumentException("roomDatabase is not initialized")
return false
}
// Create or retrieve the Master Key for encryption/decryption
val masterKeyAlias =
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
if (backupLocation !in
listOf(
BACKUP_FILE_LOCATION_INTERNAL,
BACKUP_FILE_LOCATION_EXTERNAL,
BACKUP_FILE_LOCATION_CUSTOM_DIALOG,
BACKUP_FILE_LOCATION_CUSTOM_FILE
)
) {
if (enableLogDebug) Log.d(TAG, "backupLocation is missing")
onCompleteListener?.onComplete(
false,
"backupLocation is missing",
OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING
)
return false
}
if (backupLocation == BACKUP_FILE_LOCATION_CUSTOM_FILE && backupLocationCustomFile == null
) {
if (enableLogDebug)
Log.d(
TAG,
"backupLocation is set to custom backup file, but no file is defined"
)
onCompleteListener?.onComplete(
false,
"backupLocation is set to custom backup file, but no file is defined",
OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING
)
return false
}
// Initialize/open an instance of EncryptedSharedPreferences
// Encryption key is stored in plain text in an EncryptedSharedPreferences --> it is saved
// encrypted
sharedPreferences =
EncryptedSharedPreferences.create(
context,
SHARED_PREFS,
masterKeyAlias,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
dbName = roomDatabase!!.openHelper.databaseName!!
INTERNAL_BACKUP_PATH = File("${context.filesDir}/databasebackup/")
TEMP_BACKUP_PATH = File("${context.filesDir}/databasebackup-temp/")
TEMP_BACKUP_FILE = File("$TEMP_BACKUP_PATH/tempbackup.sqlite3")
EXTERNAL_BACKUP_PATH = File(context.getExternalFilesDir("backup")!!.toURI())
DATABASE_FILE = File(context.getDatabasePath(dbName).toURI())
// Create internal and temp backup directory if does not exist
try {
INTERNAL_BACKUP_PATH.mkdirs()
TEMP_BACKUP_PATH.mkdirs()
} catch (_: FileAlreadyExistsException) {
} catch (_: IOException) {
}
if (enableLogDebug) {
Log.d(TAG, "DatabaseName: $dbName")
Log.d(TAG, "Database Location: $DATABASE_FILE")
Log.d(TAG, "INTERNAL_BACKUP_PATH: $INTERNAL_BACKUP_PATH")
Log.d(TAG, "EXTERNAL_BACKUP_PATH: $EXTERNAL_BACKUP_PATH")
if (backupLocationCustomFile != null)
Log.d(TAG, "backupLocationCustomFile: $backupLocationCustomFile")
}
return true
}
/** restart App with custom Intent */
private fun restartApp() {
restartIntent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(restartIntent)
if (context is Activity) {
(context as Activity).finish()
}
Runtime.getRuntime().exit(0)
}
/**
* Start Backup process, and set onComplete Listener to success, if no error occurred, else
* onComplete Listener success is false and error message is passed
*
* if custom storage ist selected, the [openBackupfileCreator] will be launched
*/
fun backup() {
if (enableLogDebug) Log.d(TAG, "Starting Backup ...")
val success = initRoomBackup()
if (!success) return
// Needed for storage permissions request
currentProcess = PROCESS_BACKUP
// Create name for backup file, if no custom name is set: Database name + currentTime +
// .sqlite3
var filename =
if (customBackupFileName == null) "$dbName-${getTime()}.sqlite3"
else customBackupFileName as String
// Add .aes extension to filename, if file is encrypted
if (backupIsEncrypted) filename += ".aes"
if (enableLogDebug) Log.d(TAG, "backupFilename: $filename")
when (backupLocation) {
BACKUP_FILE_LOCATION_INTERNAL -> {
val backupFile = File("$INTERNAL_BACKUP_PATH/$filename")
doBackup(backupFile)
}
BACKUP_FILE_LOCATION_EXTERNAL -> {
val backupFile = File("$EXTERNAL_BACKUP_PATH/$filename")
doBackup(backupFile)
}
BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> {
backupFilename = filename
permissionRequestLauncher.launch(arrayOf())
return
}
BACKUP_FILE_LOCATION_CUSTOM_FILE -> {
doBackup(backupLocationCustomFile!!)
}
else -> return
}
}
/**
* This method will do the backup action
*
* @param destination File
*/
private fun doBackup(destination: File) {
// Close the database
roomDatabase!!.close()
roomDatabase = null
if (backupIsEncrypted) {
val encryptedBytes = encryptBackup() ?: return
val bos = BufferedOutputStream(FileOutputStream(destination, false))
bos.write(encryptedBytes)
bos.flush()
bos.close()
} else {
// Copy current database to save location (/files dir)
DATABASE_FILE.copyTo(destination)
}
// If maxFileCount is set and is reached, delete oldest file
if (maxFileCount != null) {
val deleted = deleteOldBackup()
if (!deleted) return
}
if (enableLogDebug)
Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination")
onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
}
/**
* This method will do the backup action
*
* @param destination OutputStream
*/
private fun doBackup(destination: OutputStream) {
// Close the database
roomDatabase!!.close()
roomDatabase = null
if (backupIsEncrypted) {
val encryptedBytes = encryptBackup() ?: return
destination.write(encryptedBytes)
} else {
// Copy current database to save location (/files dir)
DATABASE_FILE.inputStream().use { input ->
destination.use { output ->
input.copyTo(output)
}
}
}
// If maxFileCount is set and is reached, delete oldest file
if (maxFileCount != null) {
val deleted = deleteOldBackup()
if (!deleted) return
}
if (enableLogDebug)
Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination")
onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
}
/**
* Encrypts the current Database and return it's content as ByteArray. The original Database is
* not encrypted only a current copy of this database
*
* @return encrypted backup as ByteArray
*/
private fun encryptBackup(): ByteArray? {
try {
// Copy database you want to backup to temp directory
DATABASE_FILE.copyTo(TEMP_BACKUP_FILE)
// encrypt temp file, and save it to backup location
val encryptDecryptBackup = AESEncryptionHelper()
val fileData = encryptDecryptBackup.readFile(TEMP_BACKUP_FILE)
val aesEncryptionManager = AESEncryptionManager()
val encryptedBytes =
aesEncryptionManager.encryptData(sharedPreferences, encryptPassword, fileData)
// Delete temp file
TEMP_BACKUP_FILE.delete()
return encryptedBytes
} catch (e: Exception) {
if (enableLogDebug) Log.d(TAG, "error during encryption: ${e.message}")
onCompleteListener?.onComplete(
false,
"error during encryption",
OnCompleteListener.EXIT_CODE_ERROR_ENCRYPTION_ERROR
)
return null
}
}
/**
* Start Restore process, and set onComplete Listener to success, if no error occurred, else
* onComplete Listener success is false and error message is passed
*
* if internal or external storage is selected, this function shows a list of all available
* backup files in a MaterialAlertDialog and calls [restoreSelectedInternalExternalFile] to
* restore selected file
*
* if custom storage ist selected, the [openBackupfileChooser] will be launched
*/
fun restore() {
if (enableLogDebug) Log.d(TAG, "Starting Restore ...")
val success = initRoomBackup()
if (!success) return
// Needed for storage permissions request
currentProcess = PROCESS_RESTORE
// Path of Backup Directory
val backupDirectory: File
when (backupLocation) {
BACKUP_FILE_LOCATION_INTERNAL -> {
backupDirectory = INTERNAL_BACKUP_PATH
}
BACKUP_FILE_LOCATION_EXTERNAL -> {
backupDirectory = File("$EXTERNAL_BACKUP_PATH/")
}
BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> {
permissionRequestLauncher.launch(arrayOf())
return
}
BACKUP_FILE_LOCATION_CUSTOM_FILE -> {
Log.d(
TAG,
"backupLocationCustomFile!!.exists()? : ${backupLocationCustomFile!!.exists()}"
)
doRestore(backupLocationCustomFile!!)
return
}
else -> return
}
// All Files in an Array of type File
val arrayOfFiles = backupDirectory.listFiles()
// If array is null or empty show "error" and return
if (arrayOfFiles.isNullOrEmpty()) {
if (enableLogDebug) Log.d(TAG, "No backups available to restore")
onCompleteListener?.onComplete(
false,
"No backups available",
OnCompleteListener.EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE
)
Toast.makeText(context, "No backups available to restore", Toast.LENGTH_SHORT).show()
return
}
// Sort Array: lastModified
arrayOfFiles.sortBy { it.lastModified() }
// New empty MutableList of String
val mutableListOfFilesAsString = mutableListOf<String>()
// Add each filename to mutablelistOfFilesAsString
runBlocking {
for (i in arrayOfFiles.indices) {
mutableListOfFilesAsString.add(arrayOfFiles[i].name)
}
}
// Convert MutableList to Array
val filesStringArray = mutableListOfFilesAsString.toTypedArray()
// Show MaterialAlertDialog, with all available files, and on click Listener
AlertDialog.Builder(context)
.setTitle(customRestoreDialogTitle)
.setItems(filesStringArray) { _, which ->
restoreSelectedInternalExternalFile(filesStringArray[which])
}
.setOnCancelListener {
if (enableLogDebug) Log.d(TAG, "Restore dialog canceled")
onCompleteListener?.onComplete(
false,
"Restore dialog canceled",
OnCompleteListener.EXIT_CODE_ERROR_BY_USER_CANCELED
)
}
.show()
}
/**
* This method will do the restore action
*
* @param source File
*/
private fun doRestore(source: File) {
// Close the database
roomDatabase!!.close()
roomDatabase = null
val fileExtension = source.extension
if (backupIsEncrypted) {
source.copyTo(TEMP_BACKUP_FILE)
val decryptedBytes = decryptBackup() ?: return
val bos = BufferedOutputStream(FileOutputStream(DATABASE_FILE, false))
bos.write(decryptedBytes)
bos.flush()
bos.close()
} else {
if (fileExtension == "aes") {
if (enableLogDebug)
Log.d(
TAG,
"Cannot restore database, it is encrypted. Maybe you forgot to add the property .fileIsEncrypted(true)"
)
onCompleteListener?.onComplete(
false,
"cannot restore database, see Log for more details (if enabled)",
OnCompleteListener.EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED
)
return
}
// Copy back database and replace current database
source.copyTo(DATABASE_FILE)
}
if (enableLogDebug)
Log.d(TAG, "Restore done, decrypted($backupIsEncrypted) and restored from $source")
onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
}
/**
* This method will do the restore action
*
* @param source InputStream
*/
private fun doRestore(source: InputStream) {
if (backupIsEncrypted) {
// Save inputstream to temp file
source.use { input ->
TEMP_BACKUP_FILE.outputStream().use { output -> input.copyTo(output) }
}
// Decrypt tempfile and write to database file
val decryptedBytes = decryptBackup() ?: return
// Close the database if decryption is succesfull
roomDatabase!!.close()
roomDatabase = null
val bos = BufferedOutputStream(FileOutputStream(DATABASE_FILE, false))
bos.write(decryptedBytes)
bos.flush()
bos.close()
} else {
// Close the database
roomDatabase!!.close()
roomDatabase = null
// Copy back database and replace current database
source.use { input ->
DATABASE_FILE.outputStream().use { output -> input.copyTo(output) }
}
}
if (enableLogDebug)
Log.d(TAG, "Restore done, decrypted($backupIsEncrypted) and restored from $source")
onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
}
/**
* Restores the selected file from internal or external storage
*
* @param filename String
*/
private fun restoreSelectedInternalExternalFile(filename: String) {
if (enableLogDebug) Log.d(TAG, "Restore selected file...")
when (backupLocation) {
BACKUP_FILE_LOCATION_INTERNAL -> {
doRestore(File("$INTERNAL_BACKUP_PATH/$filename"))
}
BACKUP_FILE_LOCATION_EXTERNAL -> {
doRestore(File("$EXTERNAL_BACKUP_PATH/$filename"))
}
else -> return
}
}
/**
* Decrypts the [TEMP_BACKUP_FILE] and return it's content as ByteArray. A valid encrypted
* backup file must be present on [TEMP_BACKUP_FILE]
*
* @return decrypted backup as ByteArray
*/
private fun decryptBackup(): ByteArray? {
try {
// Decrypt temp file, and save it to database location
val encryptDecryptBackup = AESEncryptionHelper()
val fileData = encryptDecryptBackup.readFile(TEMP_BACKUP_FILE)
val aesEncryptionManager = AESEncryptionManager()
val decryptedBytes =
aesEncryptionManager.decryptData(sharedPreferences, encryptPassword, fileData)
// Delete tem file
TEMP_BACKUP_FILE.delete()
return decryptedBytes
} catch (e: BadPaddingException) {
if (enableLogDebug) Log.d(TAG, "error during decryption (wrong password): ${e.message}")
onCompleteListener?.onComplete(
false,
"error during decryption (wrong password) see Log for more details (if enabled)",
OnCompleteListener.EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD
)
return null
} catch (e: Exception) {
if (enableLogDebug) Log.d(TAG, "error during decryption: ${e.message}")
onCompleteListener?.onComplete(
false,
"error during decryption see Log for more details (if enabled)",
OnCompleteListener.EXIT_CODE_ERROR_DECRYPTION_ERROR
)
return null
}
}
/**
* Opens the [ActivityResultContracts.RequestMultiplePermissions] and prompts the user to grant
* storage permissions
*
* If granted backup or restore process starts
*/
private val permissionRequestLauncher =
(context as ComponentActivity).registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
permissions.entries.forEach {
if (!it.value) {
onCompleteListener?.onComplete(
false,
"storage permissions are required, please allow!",
OnCompleteListener.EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED
)
return@registerForActivityResult
}
}
when (currentProcess) {
PROCESS_BACKUP -> {
backupFilename?.let { openBackupfileCreator.launch(it) }
}
PROCESS_RESTORE -> {
openBackupfileChooser.launch(arrayOf("application/octet-stream"))
}
}
}
/**
* Opens the [ActivityResultContracts.OpenDocument] and prompts the user to open a document for
* restoring a backup file
*/
private val openBackupfileChooser =
(context as ComponentActivity).registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { result ->
if (result != null) {
val inputstream = context.contentResolver.openInputStream(result)!!
doRestore(inputstream)
return@registerForActivityResult
}
onCompleteListener?.onComplete(
false,
"failure",
OnCompleteListener.EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER
)
}
/**
* Opens the [ActivityResultContracts.CreateDocument] and prompts the user to select a path for
* creating the new backup file
*/
private val openBackupfileCreator =
(context as ComponentActivity).registerForActivityResult(
CreateDocument("application/octet-stream")
) { result ->
if (result != null) {
val out = context.contentResolver.openOutputStream(result)!!
doBackup(out)
return@registerForActivityResult
}
onCompleteListener?.onComplete(
false,
"failure",
OnCompleteListener.EXIT_CODE_ERROR_BACKUP_FILE_CREATOR
)
}
/** @return current time formatted as String */
private fun getTime(): String {
val currentTime = Calendar.getInstance().time
val sdf =
if (Build.VERSION.SDK_INT <= 28) {
SimpleDateFormat("yyyy-MM-dd-HH_mm_ss", Locale.getDefault())
} else {
SimpleDateFormat("yyyy-MM-dd-HH:mm:ss", Locale.getDefault())
}
return sdf.format(currentTime)
}
/**
* If maxFileCount is set, and reached, all old files will be deleted. Only if
* [BACKUP_FILE_LOCATION_INTERNAL] or [BACKUP_FILE_LOCATION_EXTERNAL]
*
* @return true if old files deleted or nothing to do
*/
private fun deleteOldBackup(): Boolean {
// Path of Backup Directory
val backupDirectory: File =
when (backupLocation) {
BACKUP_FILE_LOCATION_INTERNAL -> {
INTERNAL_BACKUP_PATH
}
BACKUP_FILE_LOCATION_EXTERNAL -> {
File("$EXTERNAL_BACKUP_PATH/")
}
BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> {
// In custom backup location no backups will be removed
return true
}
else -> return true
}
// All Files in an Array of type File
val arrayOfFiles = backupDirectory.listFiles()
// If array is null or empty nothing to do and return
if (arrayOfFiles.isNullOrEmpty()) {
if (enableLogDebug) Log.d(TAG, "")
onCompleteListener?.onComplete(
false,
"maxFileCount: Failed to get list of backups",
OnCompleteListener.EXIT_CODE_ERROR
)
return false
} else if (arrayOfFiles.size > maxFileCount!!) {
// Sort Array: lastModified
arrayOfFiles.sortBy { it.lastModified() }
// Get count of files to delete
val fileCountToDelete = arrayOfFiles.size - maxFileCount!!
for (i in 1..fileCountToDelete) {
// Delete all old files (i-1 because array starts a 0)
arrayOfFiles[i - 1].delete()
if (enableLogDebug)
Log.d(TAG, "maxFileCount reached: ${arrayOfFiles[i - 1]} deleted")
}
}
return true
}
}

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="25dp"
android:paddingTop="5dp"
android:paddingEnd="25dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/backup_tips"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/json_output"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:fontFamily="monospace"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="true" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="25dp"
android:paddingTop="5dp"
android:paddingEnd="25dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/restore_tips"
android:textColor="?attr/colorOnSurfaceVariant" />
<CheckBox
android:id="@+id/overwrite_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/overwrite_data" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/json_input"
style="?attr/textInputFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/paste_data"
app:endIconMode="clear_text"
app:errorEnabled="true"
app:helperText=" "
app:helperTextEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -50,14 +50,6 @@
<string name="backup_summary">备份待办数据库</string>
<string name="restore">恢复数据库</string>
<string name="restore_summary">恢复先前备份过的待办数据库</string>
<string name="backup_tips">下方是数据库文件,请全选复制以导出</string>
<string name="restore_tips">在下方粘贴已经导出的数据库</string>
<string name="overwrite_data">覆盖原有数据</string>
<string name="paste_data">粘贴导出的数据</string>
<string name="export_data">导出数据</string>
<string name="restore_data">恢复数据</string>
<string name="please_paste_data">请粘贴数据</string>
<string name="restore_successful">数据恢复成功</string>
<string name="restore_need_restart_app">需要重启应用载入数据</string>
<string name="json_data_incorrect">JSON 数据格式错误,请检查粘贴数据格式</string>
<string name="restore_failed">恢复失败,请检查是否重复恢复同一次备份的数据</string>
@ -90,4 +82,8 @@
<string name="todo_item_item_title">这是待办内容</string>
<string name="todo_item_item_subject">这是待办学科</string>
<string name="todo_item_description_1">这是待办列表的项目\n点击右边的 √ 就可以把这个待办标记为已完成\n长按需要修改的待办项目即可修改其信息</string>
<string name="tips_backup_success">备份成功</string>
<string name="tips_backup_failed">备份失败(错误代码:%d</string>
<string name="tips_restore_success">恢复成功,重启应用以加载数据</string>
<string name="tips_restore_failed">恢复失败(错误代码:%d</string>
</resources>

View file

@ -50,14 +50,6 @@
<string name="backup_summary">Backup to-do database</string>
<string name="restore">Restore database</string>
<string name="restore_summary">Restore the previously backed up to-do database</string>
<string name="backup_tips">The following is the database file, please select all and copy to export.</string>
<string name="restore_tips">Paste the exported database below.</string>
<string name="overwrite_data">Overwrite old data</string>
<string name="paste_data">Paste exported data</string>
<string name="export_data">Export data</string>
<string name="restore_data">Restore data</string>
<string name="please_paste_data">Please paste the data</string>
<string name="restore_successful">Data restore successful</string>
<string name="restore_need_restart_app">Need to restart the app to apply the recovered data</string>
<string name="json_data_incorrect">JSON data format is wrong. Please check the paste data format</string>
<string name="restore_failed">Restore failed, please check if you are attempting to restore the same backup data again.</string>
@ -90,4 +82,8 @@
<string name="todo_item_item_title">This is the task content</string>
<string name="todo_item_item_subject">This is the task subject</string>
<string name="todo_item_description_1">"This is an item in your to-do list.\n Click the checkmark on the right to mark it as completed.\n If you need to edit this to-do, just long press to make changes. "</string>
<string name="tips_backup_success">Backup successful</string>
<string name="tips_backup_failed">Backup failed (Exit code: %d)</string>
<string name="tips_restore_success">Restore Successful</string>
<string name="tips_restore_failed">Restore failed (Exit code: %d)</string>
</resources>

View file

@ -15,12 +15,13 @@ fragment = "1.8.5"
fragmentKtx = "1.8.5"
roomRuntime = "2.6.1"
splashScreen = "1.2.0-alpha02"
security = "1.1.0-alpha06"
# Material
material = "1.13.0-alpha09"
# Fast Scroll
fastScroll = "1.3.0"
# Gson
gson = "2.11.0"
# Room Backup
# roomBackup = "1.0.2"
# Test
espressoCore = "3.6.1"
junit = "4.13.2"
@ -49,12 +50,13 @@ androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", ve
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomRuntime" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomRuntime" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomRuntime" }
androidx-security = { group = "androidx.security", name = "security-crypto", version.ref = "security" }
# Material
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Fast Scroll
fast-scroll = { group = "me.zhanghai.android.fastscroll", name = "library", version.ref = "fastScroll" }
# Gson
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
# Room Backup
# room-backup = { group = "de.raphaelebner", name = "roomdatabasebackup", version = "roomBackup" }
# Test
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }