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

不再支持旧方式
使用 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.lifecycle.viewmodel.ktx)
implementation(libs.androidx.preference) implementation(libs.androidx.preference)
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.security)
// Material Design // Material Design
implementation(libs.material) implementation(libs.material)
// Room // Room
@ -88,8 +89,8 @@ dependencies {
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
annotationProcessor(libs.androidx.room.compiler) annotationProcessor(libs.androidx.room.compiler)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Gson // Room Backup
implementation(libs.gson) // implementation(libs.room.backup)
// Fast Scroll // Fast Scroll
implementation(libs.fast.scroll) implementation(libs.fast.scroll)
// Test // Test
@ -100,7 +101,6 @@ dependencies {
fun String.exec(): String = exec(this) fun String.exec(): String = exec(this)
@Suppress("UnstableApiUsage")
fun Project.exec(command: String): String = providers.exec { fun Project.exec(command: String): String = providers.exec {
commandLine(command.split(" ")) commandLine(command.split(" "))
}.standardOutput.asText.get().trim() }.standardOutput.asText.get().trim()

View file

@ -19,12 +19,5 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-dontwarn javax.annotation.Nullable
# Gson -dontwarn javax.annotation.concurrent.GuardedBy
-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 { *; }

View file

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

View file

@ -13,8 +13,10 @@ import cn.super12138.todo.databinding.ActivityMainBinding
import cn.super12138.todo.views.BaseActivity import cn.super12138.todo.views.BaseActivity
import cn.super12138.todo.views.fragments.SettingsParentFragment import cn.super12138.todo.views.fragments.SettingsParentFragment
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment import cn.super12138.todo.views.fragments.welcome.WelcomeFragment
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
class MainActivity : BaseActivity<ActivityMainBinding>() { class MainActivity : BaseActivity<ActivityMainBinding>() {
lateinit var roomBackup: RoomBackup
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,6 +34,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
} }
handleIntent(intent) handleIntent(intent)
roomBackup = RoomBackup(this)
} }
override fun onNewIntent(intent: Intent, caller: ComponentCaller) { 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.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
@ -16,29 +14,22 @@ import androidx.preference.SwitchPreferenceCompat
import cn.super12138.todo.R import cn.super12138.todo.R
import cn.super12138.todo.constant.Constants import cn.super12138.todo.constant.Constants
import cn.super12138.todo.constant.GlobalValues import cn.super12138.todo.constant.GlobalValues
import cn.super12138.todo.databinding.DialogBackupBinding import cn.super12138.todo.logic.dao.ToDoRoomDB
import cn.super12138.todo.databinding.DialogRestoreBinding
import cn.super12138.todo.logic.Repository
import cn.super12138.todo.logic.dao.ToDoRoom
import cn.super12138.todo.utils.VibrationUtils import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.views.activities.MainActivity import cn.super12138.todo.views.activities.MainActivity
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment 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.android.material.snackbar.Snackbar
import com.google.gson.Gson import de.raphaelebner.roomdatabasebackup.core.RoomBackup
import com.google.gson.JsonSyntaxException import java.text.SimpleDateFormat
import com.google.gson.reflect.TypeToken import java.util.Date
import kotlinx.coroutines.launch import java.util.Locale
import kotlin.system.exitProcess import kotlin.system.exitProcess
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var backupBinding: DialogBackupBinding
private lateinit var restoreBinding: DialogRestoreBinding
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey) setPreferencesFromResource(R.xml.preferences, rootKey)
val mainActivity = requireActivity() as MainActivity val mainActivity = requireActivity() as MainActivity
val gson = Gson() val roomBackup = mainActivity.roomBackup
findPreference<ListPreference>(Constants.PREF_DARK_MODE)?.apply { findPreference<ListPreference>(Constants.PREF_DARK_MODE)?.apply {
setOnPreferenceClickListener { setOnPreferenceClickListener {
@ -87,22 +78,35 @@ class SettingsFragment : PreferenceFragmentCompat() {
findPreference<Preference>(Constants.PREF_BACKUP_DB)?.apply { findPreference<Preference>(Constants.PREF_BACKUP_DB)?.apply {
setOnPreferenceClickListener { setOnPreferenceClickListener {
VibrationUtils.performHapticFeedback(view) VibrationUtils.performHapticFeedback(view)
lifecycleScope.launch { val simpleDateFormat =
val data = Repository.getAll() SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val jsonData = gson.toJson(data) val formattedDate = simpleDateFormat.format(Date())
backupBinding = DialogBackupBinding.inflate(layoutInflater) roomBackup
backupBinding.jsonOutput.text = jsonData .database(ToDoRoomDB.getDatabase(requireContext()))
.enableLogDebug(GlobalValues.devMode)
activity?.let { .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
MaterialAlertDialogBuilder(it) .customBackupFileName("ToDo-DataBase-$formattedDate.sqlite3")
.setTitle(R.string.export_data) .apply {
.setView(backupBinding.root) onCompleteListener { success, _, exitCode ->
.setPositiveButton(R.string.ok) { _, _ -> if (success) {
VibrationUtils.performHapticFeedback(view) 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 true
} }
} }
@ -111,61 +115,39 @@ class SettingsFragment : PreferenceFragmentCompat() {
setOnPreferenceClickListener { setOnPreferenceClickListener {
VibrationUtils.performHapticFeedback(view) 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) } else {
activity?.let { it1 -> view?.let { it1 ->
val dialog = MaterialAlertDialogBuilder(it1) Snackbar.make(
.setTitle(R.string.restore_data) it1, getString(
.setView(restoreBinding.root) R.string.tips_restore_failed,
.setPositiveButton(R.string.ok, null) exitCode
.setNegativeButton(R.string.cancel, null) ), Snackbar.LENGTH_LONG
.show() ).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)
} }
} }
} }
} }
} .restore()
true true
} }
} }
@ -195,7 +177,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
} }
} }
override fun setDivider(divider: Drawable?) { override fun setDivider(divider: Drawable?) {
super.setDivider(ColorDrawable(Color.TRANSPARENT)) super.setDivider(ColorDrawable(Color.TRANSPARENT))
} }
@ -204,7 +185,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
super.setDividerHeight(0) super.setDividerHeight(0)
} }
private fun restartApp(restartContext: Context) { private fun restartApp(restartContext: Context) {
val intent = Intent(restartContext, MainActivity::class.java).apply { val intent = Intent(restartContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 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="backup_summary">备份待办数据库</string>
<string name="restore">恢复数据库</string> <string name="restore">恢复数据库</string>
<string name="restore_summary">恢复先前备份过的待办数据库</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="restore_need_restart_app">需要重启应用载入数据</string>
<string name="json_data_incorrect">JSON 数据格式错误,请检查粘贴数据格式</string> <string name="json_data_incorrect">JSON 数据格式错误,请检查粘贴数据格式</string>
<string name="restore_failed">恢复失败,请检查是否重复恢复同一次备份的数据</string> <string name="restore_failed">恢复失败,请检查是否重复恢复同一次备份的数据</string>
@ -90,4 +82,8 @@
<string name="todo_item_item_title">这是待办内容</string> <string name="todo_item_item_title">这是待办内容</string>
<string name="todo_item_item_subject">这是待办学科</string> <string name="todo_item_item_subject">这是待办学科</string>
<string name="todo_item_description_1">这是待办列表的项目\n点击右边的 √ 就可以把这个待办标记为已完成\n长按需要修改的待办项目即可修改其信息</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> </resources>

View file

@ -50,14 +50,6 @@
<string name="backup_summary">Backup to-do database</string> <string name="backup_summary">Backup to-do database</string>
<string name="restore">Restore database</string> <string name="restore">Restore database</string>
<string name="restore_summary">Restore the previously backed up to-do 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="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="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> <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_title">This is the task content</string>
<string name="todo_item_item_subject">This is the task subject</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="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> </resources>

View file

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