更新备份恢复数据库的实现方式
不再支持旧方式 使用 Room Backup 1.0.2,对其部分代码改用 Kotlin 原生实现,部分参考 LibChecker 的实现
This commit is contained in:
parent
cb334151b6
commit
77fc59238f
14 changed files with 1260 additions and 190 deletions
|
|
@ -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()
|
||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
|
|
@ -19,12 +19,5 @@
|
|||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Gson
|
||||
-keepattributes Signature
|
||||
-keep class com.google.gson.reflect.TypeToken { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keepattributes AnnotationDefault,RuntimeVisibleAnnotations
|
||||
|
||||
# ToDo Room
|
||||
-keep class cn.super12138.todo.logic.dao.ToDoRoom { *; }
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.concurrent.GuardedBy
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
</activity>
|
||||
<activity
|
||||
android:name=".views.activities.CrashActivity"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.ToDo" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -13,8 +13,10 @@ import cn.super12138.todo.databinding.ActivityMainBinding
|
|||
import cn.super12138.todo.views.BaseActivity
|
||||
import cn.super12138.todo.views.fragments.SettingsParentFragment
|
||||
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment
|
||||
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
|
||||
|
||||
class MainActivity : BaseActivity<ActivityMainBinding>() {
|
||||
lateinit var roomBackup: RoomBackup
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -32,6 +34,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
|
|||
false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
handleIntent(intent)
|
||||
|
||||
roomBackup = RoomBackup(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import android.graphics.Color
|
|||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
|
|
@ -16,29 +14,22 @@ import androidx.preference.SwitchPreferenceCompat
|
|||
import cn.super12138.todo.R
|
||||
import cn.super12138.todo.constant.Constants
|
||||
import cn.super12138.todo.constant.GlobalValues
|
||||
import cn.super12138.todo.databinding.DialogBackupBinding
|
||||
import cn.super12138.todo.databinding.DialogRestoreBinding
|
||||
import cn.super12138.todo.logic.Repository
|
||||
import cn.super12138.todo.logic.dao.ToDoRoom
|
||||
import cn.super12138.todo.logic.dao.ToDoRoomDB
|
||||
import cn.super12138.todo.utils.VibrationUtils
|
||||
import cn.super12138.todo.views.activities.MainActivity
|
||||
import cn.super12138.todo.views.fragments.welcome.WelcomeFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.launch
|
||||
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var backupBinding: DialogBackupBinding
|
||||
private lateinit var restoreBinding: DialogRestoreBinding
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.preferences, rootKey)
|
||||
val mainActivity = requireActivity() as MainActivity
|
||||
val gson = Gson()
|
||||
val roomBackup = mainActivity.roomBackup
|
||||
|
||||
findPreference<ListPreference>(Constants.PREF_DARK_MODE)?.apply {
|
||||
setOnPreferenceClickListener {
|
||||
|
|
@ -87,22 +78,35 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
findPreference<Preference>(Constants.PREF_BACKUP_DB)?.apply {
|
||||
setOnPreferenceClickListener {
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
lifecycleScope.launch {
|
||||
val data = Repository.getAll()
|
||||
val jsonData = gson.toJson(data)
|
||||
backupBinding = DialogBackupBinding.inflate(layoutInflater)
|
||||
backupBinding.jsonOutput.text = jsonData
|
||||
|
||||
activity?.let {
|
||||
MaterialAlertDialogBuilder(it)
|
||||
.setTitle(R.string.export_data)
|
||||
.setView(backupBinding.root)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
val simpleDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val formattedDate = simpleDateFormat.format(Date())
|
||||
roomBackup
|
||||
.database(ToDoRoomDB.getDatabase(requireContext()))
|
||||
.enableLogDebug(GlobalValues.devMode)
|
||||
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
|
||||
.customBackupFileName("ToDo-DataBase-$formattedDate.sqlite3")
|
||||
.apply {
|
||||
onCompleteListener { success, _, exitCode ->
|
||||
if (success) {
|
||||
view?.let { it1 ->
|
||||
Snackbar.make(
|
||||
it1, R.string.tips_backup_success, Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
view?.let { it1 ->
|
||||
Snackbar.make(
|
||||
it1, getString(
|
||||
R.string.tips_backup_failed,
|
||||
exitCode
|
||||
), Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.backup()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -111,61 +115,39 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
setOnPreferenceClickListener {
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
|
||||
roomBackup
|
||||
.database(ToDoRoomDB.getDatabase(requireContext()))
|
||||
.enableLogDebug(GlobalValues.devMode)
|
||||
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
|
||||
.apply {
|
||||
onCompleteListener { success, _, exitCode ->
|
||||
if (success) {
|
||||
view?.let { it1 ->
|
||||
Snackbar.make(
|
||||
it1,
|
||||
R.string.tips_restore_success,
|
||||
Snackbar.LENGTH_LONG
|
||||
)
|
||||
.setAction(R.string.restart_app_now) {
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
restartApp(context)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
restoreBinding = DialogRestoreBinding.inflate(layoutInflater)
|
||||
activity?.let { it1 ->
|
||||
val dialog = MaterialAlertDialogBuilder(it1)
|
||||
.setTitle(R.string.restore_data)
|
||||
.setView(restoreBinding.root)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
|
||||
val jsonInput = restoreBinding.jsonInput.editText?.text.toString()
|
||||
if (jsonInput.isEmpty()) {
|
||||
restoreBinding.jsonInput.error =
|
||||
getString(R.string.please_paste_data)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (restoreBinding.overwriteData.isChecked) {
|
||||
Repository.deleteAll()
|
||||
}
|
||||
try {
|
||||
val listType = object : TypeToken<List<ToDoRoom>>() {}.type
|
||||
val taskList: List<ToDoRoom> =
|
||||
gson.fromJson(jsonInput, listType)
|
||||
taskList.forEach { Repository.insert(it) }
|
||||
dialog.dismiss()
|
||||
MaterialAlertDialogBuilder(it1)
|
||||
.setTitle(R.string.restore_successful)
|
||||
.setMessage(R.string.restore_need_restart_app)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
VibrationUtils.performHapticFeedback(view)
|
||||
|
||||
restartApp(context)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
} catch (e: JsonSyntaxException) {
|
||||
restoreBinding.jsonInput.error =
|
||||
getString(R.string.json_data_incorrect)
|
||||
} catch (e: Exception) {
|
||||
if (GlobalValues.devMode) {
|
||||
restoreBinding.jsonInput.error = e.toString()
|
||||
} else {
|
||||
restoreBinding.jsonInput.error =
|
||||
getString(R.string.restore_failed)
|
||||
} else {
|
||||
view?.let { it1 ->
|
||||
Snackbar.make(
|
||||
it1, getString(
|
||||
R.string.tips_restore_failed,
|
||||
exitCode
|
||||
), Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.restore()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -195,7 +177,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
override fun setDivider(divider: Drawable?) {
|
||||
super.setDivider(ColorDrawable(Color.TRANSPARENT))
|
||||
}
|
||||
|
|
@ -204,7 +185,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
super.setDividerHeight(0)
|
||||
}
|
||||
|
||||
|
||||
private fun restartApp(restartContext: Context) {
|
||||
val intent = Intent(restartContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -50,14 +50,6 @@
|
|||
<string name="backup_summary">备份待办数据库</string>
|
||||
<string name="restore">恢复数据库</string>
|
||||
<string name="restore_summary">恢复先前备份过的待办数据库</string>
|
||||
<string name="backup_tips">下方是数据库文件,请全选复制以导出</string>
|
||||
<string name="restore_tips">在下方粘贴已经导出的数据库</string>
|
||||
<string name="overwrite_data">覆盖原有数据</string>
|
||||
<string name="paste_data">粘贴导出的数据</string>
|
||||
<string name="export_data">导出数据</string>
|
||||
<string name="restore_data">恢复数据</string>
|
||||
<string name="please_paste_data">请粘贴数据</string>
|
||||
<string name="restore_successful">数据恢复成功</string>
|
||||
<string name="restore_need_restart_app">需要重启应用载入数据</string>
|
||||
<string name="json_data_incorrect">JSON 数据格式错误,请检查粘贴数据格式</string>
|
||||
<string name="restore_failed">恢复失败,请检查是否重复恢复同一次备份的数据</string>
|
||||
|
|
@ -90,4 +82,8 @@
|
|||
<string name="todo_item_item_title">这是待办内容</string>
|
||||
<string name="todo_item_item_subject">这是待办学科</string>
|
||||
<string name="todo_item_description_1">这是待办列表的项目\n点击右边的 √ 就可以把这个待办标记为已完成\n长按需要修改的待办项目即可修改其信息</string>
|
||||
<string name="tips_backup_success">备份成功</string>
|
||||
<string name="tips_backup_failed">备份失败(错误代码:%d)</string>
|
||||
<string name="tips_restore_success">恢复成功,重启应用以加载数据</string>
|
||||
<string name="tips_restore_failed">恢复失败(错误代码:%d)</string>
|
||||
</resources>
|
||||
|
|
@ -50,14 +50,6 @@
|
|||
<string name="backup_summary">Backup to-do database</string>
|
||||
<string name="restore">Restore database</string>
|
||||
<string name="restore_summary">Restore the previously backed up to-do database</string>
|
||||
<string name="backup_tips">The following is the database file, please select all and copy to export.</string>
|
||||
<string name="restore_tips">Paste the exported database below.</string>
|
||||
<string name="overwrite_data">Overwrite old data</string>
|
||||
<string name="paste_data">Paste exported data</string>
|
||||
<string name="export_data">Export data</string>
|
||||
<string name="restore_data">Restore data</string>
|
||||
<string name="please_paste_data">Please paste the data</string>
|
||||
<string name="restore_successful">Data restore successful</string>
|
||||
<string name="restore_need_restart_app">Need to restart the app to apply the recovered data</string>
|
||||
<string name="json_data_incorrect">JSON data format is wrong. Please check the paste data format</string>
|
||||
<string name="restore_failed">Restore failed, please check if you are attempting to restore the same backup data again.</string>
|
||||
|
|
@ -90,4 +82,8 @@
|
|||
<string name="todo_item_item_title">This is the task content</string>
|
||||
<string name="todo_item_item_subject">This is the task subject</string>
|
||||
<string name="todo_item_description_1">"This is an item in your to-do list.\n Click the checkmark on the right to mark it as completed.\n If you need to edit this to-do, just long press to make changes. "</string>
|
||||
<string name="tips_backup_success">Backup successful</string>
|
||||
<string name="tips_backup_failed">Backup failed (Exit code: %d)</string>
|
||||
<string name="tips_restore_success">Restore Successful</string>
|
||||
<string name="tips_restore_failed">Restore failed (Exit code: %d)</string>
|
||||
</resources>
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue