From 77fc59238ff724e1754bd1b9c18747f1b4ba2413 Mon Sep 17 00:00:00 2001 From: Super12138 <70494801+Super12138@users.noreply.github.com> Date: Wed, 25 Dec 2024 19:08:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=A4=87=E4=BB=BD=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=BA=93=E7=9A=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再支持旧方式 使用 Room Backup 1.0.2,对其部分代码改用 Kotlin 原生实现,部分参考 LibChecker 的实现 --- app/build.gradle.kts | 6 +- app/proguard-rules.pro | 11 +- app/src/main/AndroidManifest.xml | 4 +- .../todo/views/activities/MainActivity.kt | 4 + .../todo/views/fragments/SettingsFragment.kt | 142 ++- .../core/AESEncryptionHelper.kt | 129 +++ .../core/AESEncryptionManager.kt | 114 +++ .../core/OnCompleteListener.kt | 78 ++ .../roomdatabasebackup/core/RoomBackup.kt | 853 ++++++++++++++++++ app/src/main/res/layout/dialog_backup.xml | 30 - app/src/main/res/layout/dialog_restore.xml | 45 - app/src/main/res/values-zh-rCN/strings.xml | 12 +- app/src/main/res/values/strings.xml | 12 +- gradle/libs.versions.toml | 10 +- 14 files changed, 1260 insertions(+), 190 deletions(-) create mode 100644 app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt create mode 100644 app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt create mode 100644 app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt create mode 100644 app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt delete mode 100644 app/src/main/res/layout/dialog_backup.xml delete mode 100644 app/src/main/res/layout/dialog_restore.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ebf19b..86d9c8c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e520fc5..f4ec64c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { *; } \ No newline at end of file +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c5f4d17..0e3e6b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,7 +30,7 @@ + android:exported="false" + android:theme="@style/Theme.ToDo" /> - \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt b/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt index 5bbaa6a..e718af2 100644 --- a/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt +++ b/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt @@ -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() { + lateinit var roomBackup: RoomBackup override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -32,6 +34,8 @@ class MainActivity : BaseActivity() { false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } handleIntent(intent) + + roomBackup = RoomBackup(this) } override fun onNewIntent(intent: Intent, caller: ComponentCaller) { diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt index 14c882e..ccbd0f3 100644 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt +++ b/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt @@ -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(Constants.PREF_DARK_MODE)?.apply { setOnPreferenceClickListener { @@ -87,22 +78,35 @@ class SettingsFragment : PreferenceFragmentCompat() { findPreference(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>() {}.type - val taskList: List = - 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 diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt new file mode 100644 index 0000000..3226c09 --- /dev/null +++ b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt @@ -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") + } +} diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt new file mode 100644 index 0000000..7611362 --- /dev/null +++ b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt new file mode 100644 index 0000000..43dd44a --- /dev/null +++ b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt new file mode 100644 index 0000000..a6b7018 --- /dev/null +++ b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt @@ -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() + + // 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 + } +} diff --git a/app/src/main/res/layout/dialog_backup.xml b/app/src/main/res/layout/dialog_backup.xml deleted file mode 100644 index 2b577ad..0000000 --- a/app/src/main/res/layout/dialog_backup.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_restore.xml b/app/src/main/res/layout/dialog_restore.xml deleted file mode 100644 index b7463b6..0000000 --- a/app/src/main/res/layout/dialog_restore.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f69ae00..fce948c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -50,14 +50,6 @@ 备份待办数据库 恢复数据库 恢复先前备份过的待办数据库 - 下方是数据库文件,请全选复制以导出 - 在下方粘贴已经导出的数据库 - 覆盖原有数据 - 粘贴导出的数据 - 导出数据 - 恢复数据 - 请粘贴数据 - 数据恢复成功 需要重启应用载入数据 JSON 数据格式错误,请检查粘贴数据格式 恢复失败,请检查是否重复恢复同一次备份的数据 @@ -90,4 +82,8 @@ 这是待办内容 这是待办学科 这是待办列表的项目\n点击右边的 √ 就可以把这个待办标记为已完成\n长按需要修改的待办项目即可修改其信息 + 备份成功 + 备份失败(错误代码:%d) + 恢复成功,重启应用以加载数据 + 恢复失败(错误代码:%d) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c897acc..d591bf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,14 +50,6 @@ Backup to-do database Restore database Restore the previously backed up to-do database - The following is the database file, please select all and copy to export. - Paste the exported database below. - Overwrite old data - Paste exported data - Export data - Restore data - Please paste the data - Data restore successful Need to restart the app to apply the recovered data JSON data format is wrong. Please check the paste data format Restore failed, please check if you are attempting to restore the same backup data again. @@ -90,4 +82,8 @@ This is the task content This is the task subject "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. " + Backup successful + Backup failed (Exit code: %d) + Restore Successful + Restore failed (Exit code: %d) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49e358a..3961d71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }