├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── styles.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_add.xml
│ │ │ │ ├── ic_close.xml
│ │ │ │ ├── ic_save.xml
│ │ │ │ ├── ic_delete.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── menu
│ │ │ │ └── save_menu.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_fragment.xml
│ │ │ │ ├── content_fruit_list.xml
│ │ │ │ ├── activity_add_edit_fruit.xml
│ │ │ │ └── activity_main.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── de
│ │ │ └── raphaelebner
│ │ │ └── roomdatabasebackup
│ │ │ └── sample
│ │ │ ├── database
│ │ │ ├── table
│ │ │ │ └── fruit
│ │ │ │ │ ├── FruitDao.kt
│ │ │ │ │ ├── Fruit.kt
│ │ │ │ │ ├── FruitRepository.kt
│ │ │ │ │ ├── FruitViewModel.kt
│ │ │ │ │ └── FruitListAdapter.kt
│ │ │ └── main
│ │ │ │ └── FruitDatabase.kt
│ │ │ ├── FragmentActivity.kt
│ │ │ ├── FragmentActivityJava.java
│ │ │ ├── ActivityAddEditFruit.kt
│ │ │ ├── MainFragmentJava.java
│ │ │ ├── MainActivityJava.java
│ │ │ ├── MainFragment.kt
│ │ │ └── MainActivity.kt
│ ├── test
│ │ └── java
│ │ │ └── de
│ │ │ └── raphaelebner
│ │ │ └── roomdatabasebackup
│ │ │ └── sample
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── de
│ │ └── raphaelebner
│ │ └── roomdatabasebackup
│ │ └── sample
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
├── schemas
│ └── com.ebner.roomdatabasebackup.sample.database.main.FruitDatabase
│ │ └── 1.json
└── build.gradle.kts
├── core
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── de
│ │ │ └── raphaelebner
│ │ │ └── roomdatabasebackup
│ │ │ └── core
│ │ │ ├── OnCompleteListener.kt
│ │ │ ├── AESEncryptionManager.kt
│ │ │ ├── AESEncryptionHelper.kt
│ │ │ └── RoomBackup.kt
│ ├── test
│ │ └── java
│ │ │ └── de
│ │ │ └── raphaelebner
│ │ │ └── roomdatabasebackup
│ │ │ └── core
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── de
│ │ └── raphaelebner
│ │ └── roomdatabasebackup
│ │ └── core
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── settings.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ └── android.yml
├── LICENSE
├── gradle.properties
├── .gitignore
├── scripts
├── publish-root.gradle
└── publish-module.gradle
├── gradlew.bat
├── CHANGELOG.md
├── gradlew
└── README.md
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/core/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/core/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | include(":core")
2 | include(":app")
3 | rootProject.name = "Android Room Database Backup"
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
13 | * Copyright (c) 2025 Raphael Ebner 14 | *
15 | * Permission is hereby granted, free of charge, to any person obtaining a copy 16 | * of this software and associated documentation files (the "Software"), to deal 17 | * in the Software without restriction, including without limitation the rights 18 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | * copies of the Software, and to permit persons to whom the Software is 20 | * furnished to do so, subject to the following conditions: 21 | *
22 | * The above copyright notice and this permission notice shall be included in 23 | * all 24 | * copies or substantial portions of the Software. 25 | *
26 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32 | * SOFTWARE.
33 | */
34 | public class FragmentActivityJava extends AppCompatActivity {
35 |
36 | RoomBackup roomBackup;
37 |
38 | @Override
39 | protected void onCreate(Bundle savedInstanceState) {
40 | super.onCreate(savedInstanceState);
41 | setContentView(R.layout.activity_fragment);
42 |
43 | roomBackup = new RoomBackup(this);
44 |
45 | FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
46 | transaction.replace(R.id.fragment_container_view, new MainFragmentJava());
47 | transaction.commit();
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/database/table/fruit/FruitViewModel.kt:
--------------------------------------------------------------------------------
1 | package de.raphaelebner.roomdatabasebackup.sample.database.table.fruit
2 |
3 | import android.app.Application
4 | import androidx.lifecycle.AndroidViewModel
5 | import androidx.lifecycle.LiveData
6 | import androidx.lifecycle.viewModelScope
7 | import de.raphaelebner.roomdatabasebackup.sample.database.main.FruitDatabase
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.launch
10 |
11 | /**
12 | * MIT License
13 | *
14 | * Copyright (c) 2025 Raphael Ebner
15 | *
16 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
17 | * associated documentation files (the "Software"), to deal in the Software without restriction,
18 | * including without limitation the rights to use, copy, modify, merge, publish, distribute,
19 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
20 | * furnished to do so, subject to the following conditions:
21 | *
22 | * The above copyright notice and this permission notice shall be included in all copies or
23 | * substantial portions of the Software.
24 | *
25 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
26 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
27 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
28 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
30 | */
31 | class FruitViewModel(application: Application) : AndroidViewModel(application) {
32 |
33 | private val fruitRepository: FruitRepository
34 |
35 | val allFruit: LiveData
39 | * Copyright (c) 2025 Raphael Ebner
40 | *
41 | * Permission is hereby granted, free of charge, to any person obtaining a copy
42 | * of this software and associated documentation files (the "Software"), to deal
43 | * in the Software without restriction, including without limitation the rights
44 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
45 | * copies of the Software, and to permit persons to whom the Software is
46 | * furnished to do so, subject to the following conditions:
47 | *
48 | * The above copyright notice and this permission notice shall be included in
49 | * all
50 | * copies or substantial portions of the Software.
51 | *
52 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
53 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
54 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
55 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
56 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
57 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
58 | * SOFTWARE.
59 | */
60 | public class MainFragmentJava extends Fragment implements FruitListAdapter.OnItemClickListener {
61 |
62 | public MainFragmentJava() {
63 | // Required empty public constructor
64 | }
65 |
66 | private static final String TAG = "debug_MainActivityJava";
67 | private FruitViewModel fruitViewModel;
68 |
69 | private boolean encryptBackup;
70 | /*---------------------when returning from |ActivityAddEditFruit| do something--------------------------*/
71 | ActivityResultLauncher
38 | * Copyright (c) 2025 Raphael Ebner
39 | *
40 | * Permission is hereby granted, free of charge, to any person obtaining a copy
41 | * of this software and associated documentation files (the "Software"), to deal
42 | * in the Software without restriction, including without limitation the rights
43 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
44 | * copies of the Software, and to permit persons to whom the Software is
45 | * furnished to do so, subject to the following conditions:
46 | *
47 | * The above copyright notice and this permission notice shall be included in
48 | * all
49 | * copies or substantial portions of the Software.
50 | *
51 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
52 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
53 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
54 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
55 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
56 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
57 | * SOFTWARE.
58 | */
59 | public class MainActivityJava extends AppCompatActivity implements FruitListAdapter.OnItemClickListener {
60 |
61 | private static final String TAG = "debug_MainActivityJava";
62 | private FruitViewModel fruitViewModel;
63 |
64 | private boolean encryptBackup;
65 | /*---------------------when returning from |ActivityAddEditFruit| do something--------------------------*/
66 | ActivityResultLauncher>
36 |
37 | /*---------------------Define the Database, and the Repository--------------------------*/
38 | init {
39 | val fruitDao = FruitDatabase.getInstance(application).fruitDao()
40 | fruitRepository = FruitRepository(fruitDao)
41 | allFruit = fruitRepository.getAllFruit
42 | }
43 |
44 | /*---------------------Define default queries--------------------------*/
45 | fun insert(fruit: Fruit) =
46 | viewModelScope.launch(Dispatchers.IO) { fruitRepository.insert(fruit) }
47 |
48 | fun update(fruit: Fruit) =
49 | viewModelScope.launch(Dispatchers.IO) { fruitRepository.update(fruit) }
50 |
51 | fun delete(fruit: Fruit) =
52 | viewModelScope.launch(Dispatchers.IO) { fruitRepository.delete(fruit) }
53 | }
54 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("kotlin-android")
4 | id("com.google.devtools.ksp")
5 | }
6 |
7 | android {
8 |
9 | compileOptions {
10 | sourceCompatibility = JavaVersion.VERSION_17
11 | targetCompatibility = JavaVersion.VERSION_17
12 | }
13 |
14 | defaultConfig {
15 | applicationId = "de.raphaelebner.roomdatabasebackup.sample"
16 | minSdk = 21
17 | targetSdk = 35
18 | compileSdk = 35
19 | buildToolsVersion = "35.0.0"
20 | versionCode = 1
21 | versionName = "1.0.3"
22 |
23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
24 | ksp {
25 | arg("room.schemaLocation", "$projectDir/schemas")
26 | arg("room.incremental", "true")
27 | arg("room.expandProjection", "true")
28 | }
29 | }
30 |
31 | buildTypes {
32 | getByName("release") {
33 | isMinifyEnabled = false
34 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
35 | }
36 | }
37 |
38 | buildFeatures {
39 | viewBinding = true
40 | }
41 |
42 | kotlinOptions {
43 | jvmTarget = JavaVersion.VERSION_17.toString()
44 | }
45 | namespace = "de.raphaelebner.roomdatabasebackup.sample"
46 | }
47 |
48 | dependencies {
49 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
50 | implementation("androidx.core:core-ktx:1.15.0")
51 | implementation("androidx.appcompat:appcompat:1.7.0")
52 | implementation("androidx.constraintlayout:constraintlayout:2.2.1")
53 | implementation(project(":core"))
54 | implementation("androidx.legacy:legacy-support-v4:1.0.0")
55 | testImplementation("junit:junit:4.13.2")
56 | androidTestImplementation("androidx.test.ext:junit:1.2.1")
57 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
58 |
59 | //ROOM SQLite
60 | val roomVersion = "2.6.1"
61 |
62 | implementation("androidx.room:room-runtime:$roomVersion")
63 | ksp("androidx.room:room-compiler:$roomVersion")
64 |
65 | // optional - Kotlin Extensions and Coroutines support for Room
66 | implementation("androidx.room:room-ktx:$roomVersion")
67 |
68 | // optional - RxJava support for Room
69 | implementation("androidx.room:room-rxjava2:$roomVersion")
70 |
71 | implementation("androidx.lifecycle:lifecycle-extensions:2.2.0")
72 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
73 |
74 | // optional - Guava support for Room, including Optional and ListenableFuture
75 | implementation("androidx.room:room-guava:$roomVersion")
76 |
77 | // Test helpers
78 | testImplementation("androidx.room:room-testing:$roomVersion")
79 |
80 | //Recyclerview Implementation
81 | implementation("androidx.recyclerview:recyclerview:1.4.0")
82 |
83 | //Material Design Implementation
84 | implementation("com.google.android.material:material:1.12.0")
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/core/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
2 | import com.vanniktech.maven.publish.SonatypeHost
3 |
4 | plugins {
5 | id("com.android.library")
6 | id("kotlin-android")
7 | id("com.google.devtools.ksp")
8 | id("org.jetbrains.dokka")
9 | id("com.vanniktech.maven.publish") version "0.31.0"
10 | }
11 |
12 | version = properties["VERSION_NAME"] as String
13 |
14 | android {
15 | compileOptions {
16 | sourceCompatibility = JavaVersion.VERSION_17
17 | targetCompatibility = JavaVersion.VERSION_17
18 | }
19 |
20 | defaultConfig {
21 | minSdk = 21
22 | compileSdk = 35
23 | buildToolsVersion = "35.0.0"
24 |
25 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
26 | consumerProguardFiles("consumer-rules.pro")
27 | }
28 |
29 | buildFeatures {
30 | viewBinding = true
31 | }
32 |
33 | kotlinOptions {
34 | jvmTarget = JavaVersion.VERSION_17.toString()
35 | }
36 | namespace = "de.raphaelebner.roomdatabasebackup.core"
37 |
38 | }
39 |
40 |
41 | mavenPublishing {
42 | configure(
43 | AndroidSingleVariantLibrary(
44 | // the published variant
45 | variant = "release",
46 | // whether to publish a sources jar
47 | sourcesJar = true,
48 | // whether to publish a javadoc jar
49 | publishJavadocJar = true,
50 | )
51 | )
52 |
53 |
54 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
55 | signAllPublications()
56 | }
57 |
58 |
59 | // apply {
60 | // from("${rootDir}/scripts/publish-module.gradle")
61 | // }
62 |
63 |
64 | dependencies {
65 | implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
66 | implementation("androidx.core:core-ktx:1.15.0")
67 | implementation("androidx.appcompat:appcompat:1.7.0")
68 | testImplementation("junit:junit:4.13.2")
69 | androidTestImplementation("androidx.test.ext:junit:1.2.1")
70 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
71 |
72 | //ROOM SQLite
73 | val roomVersion = "2.6.1"
74 |
75 | implementation("androidx.room:room-runtime:$roomVersion")
76 | ksp("androidx.room:room-compiler:$roomVersion")
77 |
78 | // optional - Kotlin Extensions and Coroutines support for Room
79 | implementation("androidx.room:room-ktx:$roomVersion")
80 |
81 | // optional - RxJava support for Room
82 | implementation("androidx.room:room-rxjava2:$roomVersion")
83 |
84 | // optional - Guava support for Room, including Optional and ListenableFuture
85 | implementation("androidx.room:room-guava:$roomVersion")
86 |
87 | // Test helpers
88 | testImplementation("androidx.room:room-testing:$roomVersion")
89 |
90 | //Material Design Implementation
91 | implementation("com.google.android.material:material:1.12.0")
92 |
93 | //Androidx Security
94 | implementation("androidx.security:security-crypto:1.1.0-alpha06")
95 |
96 | //Google Guava
97 | implementation("com.google.guava:guava:33.4.5-jre")
98 |
99 | //Apache commons io
100 | //https://mvnrepository.com/artifact/commons-io/commons-io
101 | //noinspection GradleDependency
102 | implementation("commons-io:commons-io:2.18.0")
103 |
104 | }
105 |
--------------------------------------------------------------------------------
/core/src/main/java/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt:
--------------------------------------------------------------------------------
1 | package de.raphaelebner.roomdatabasebackup.core
2 |
3 | import de.raphaelebner.roomdatabasebackup.core.RoomBackup.Companion.BACKUP_FILE_LOCATION_CUSTOM_FILE
4 |
5 | /**
6 | * MIT License
7 | *
8 | * Copyright (c) 2025 Raphael Ebner
9 | *
10 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
11 | * associated documentation files (the "Software"), to deal in the Software without restriction,
12 | * including without limitation the rights to use, copy, modify, merge, publish, distribute,
13 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
14 | * furnished to do so, subject to the following conditions:
15 | *
16 | * The above copyright notice and this permission notice shall be included in all copies or
17 | * substantial portions of the Software.
18 | *
19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
20 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
22 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24 | */
25 | interface OnCompleteListener {
26 | fun onComplete(success: Boolean, message: String, exitCode: Int)
27 |
28 | companion object {
29 |
30 | /** Other Error */
31 | const val EXIT_CODE_ERROR = 1
32 |
33 | /** Error while choosing backup to restore. Maybe no file selected */
34 | const val EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER = 2
35 |
36 | /** Error while choosing backup file to create. Maybe no file selected */
37 | const val EXIT_CODE_ERROR_BACKUP_FILE_CREATOR = 3
38 |
39 | /**
40 | * [BACKUP_FILE_LOCATION_CUSTOM_FILE] is set but [RoomBackup.backupLocationCustomFile] is
41 | * not set
42 | */
43 | const val EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING = 4
44 |
45 | /** [RoomBackup.backupLocation] is not set */
46 | const val EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING = 5
47 |
48 | /** Restore dialog for internal/external storage was canceled by user */
49 | const val EXIT_CODE_ERROR_BY_USER_CANCELED = 6
50 |
51 | /** Cannot decrypt provided backup file */
52 | const val EXIT_CODE_ERROR_DECRYPTION_ERROR = 7
53 |
54 | /** Cannot encrypt database backup */
55 | const val EXIT_CODE_ERROR_ENCRYPTION_ERROR = 8
56 |
57 | /**
58 | * You tried to restore a encrypted backup but [RoomBackup.backupIsEncrypted] is set to
59 | * false
60 | */
61 | const val EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED = 9
62 |
63 | /** No backups to restore are available in internal/external sotrage */
64 | const val EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE = 10
65 |
66 | /** No room database to backup is provided */
67 | const val EXIT_CODE_ERROR_ROOM_DATABASE_MISSING = 11
68 |
69 | /** Storage permissions not granted for custom dialog */
70 | const val EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED = 12
71 |
72 | /** Cannot decrypt provided backup file because the password is incorrect */
73 | const val EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD = 13
74 |
75 | /** No error, action successful */
76 | const val EXIT_CODE_SUCCESS = 0
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/scripts/publish-module.gradle:
--------------------------------------------------------------------------------
1 |
2 | // apply plugin: 'com.vanniktech.maven.publish'
3 | // apply plugin: 'signing'
4 | // apply plugin: 'org.jetbrains.dokka'
5 |
6 | // tasks.withType(Jar).configureEach {
7 | // duplicatesStrategy = DuplicatesStrategy.EXCLUDE
8 | // }
9 |
10 | // tasks.register('androidSourcesJar', Jar) {
11 | // duplicatesStrategy = DuplicatesStrategy.EXCLUDE
12 | // archiveClassifier.set('sources')
13 | // if (project.plugins.findPlugin("com.android.library")) {
14 | // // For Android libraries
15 | // from android.sourceSets.main.kotlin.srcDirs
16 | // from android.sourceSets.main.java.srcDirs
17 | // } else {
18 | // // For pure Kotlin libraries, in case you have them
19 | // from sourceSets.main.java.srcDirs
20 | // from sourceSets.main.kotlin.srcDirs
21 | // }
22 | // }
23 |
24 | // tasks.withType(dokkaHtmlPartial.getClass()).configureEach {
25 | // pluginsMapConfiguration.set(
26 | // ["org.jetbrains.dokka.base.DokkaBase": """{ "separateInheritedMembers": true}"""]
27 | // )
28 | // }
29 |
30 | // tasks.register('javadocJar', Jar) {
31 | // dependsOn dokkaJavadoc
32 | // archiveClassifier.set("javadoc")
33 | // from dokkaJavadoc.outputDirectory
34 | // }
35 |
36 | // tasks.register('htmldocJar', Jar) {
37 | // dependsOn dokkaHtml
38 | // archiveClassifier.set("htmldoc")
39 | // from dokkaHtml.outputDirectory
40 | // }
41 |
42 | // artifacts {
43 | // archives androidSourcesJar
44 | // archives javadocJar
45 | // archives htmldocJar
46 | // }
47 |
48 | // group = GROUP_ID
49 | // version = VERSION_NAME
50 |
51 | // afterEvaluate {
52 | // publishing {
53 | // publications {
54 | // release(MavenPublication) {
55 | // tasks.named("generateMetadataFileForReleasePublication").configure {dependsOn(androidSourcesJar)}
56 |
57 | // groupId GROUP_ID
58 | // artifactId ARTIFACT_ID
59 | // version VERSION_NAME
60 |
61 | // // Two artifacts, the `aar` (or `jar`) and the sources
62 | // if (project.plugins.findPlugin("com.android.library")) {
63 | // from components.release
64 | // } else {
65 | // from components.java
66 | // }
67 |
68 | // artifact javadocJar
69 | // artifact htmldocJar
70 | // // artifact androidSourcesJar
71 |
72 | // // Mostly self-explanatory metadata
73 | // pom {
74 | // name = ARTIFACT_ID
75 | // description = POM_DESCRIPTION
76 | // url = POM_URL
77 | // licenses {
78 | // license {
79 | // name = POM_LICENCE_NAME
80 | // url = POM_LICENCE_URL
81 | // }
82 | // }
83 | // developers {
84 | // developer {
85 | // id = POM_DEVELOPER_ID
86 | // name = POM_DEVELOPER_NAME
87 | // }
88 | // }
89 |
90 | // // Version control info - if you're using GitHub, follow the
91 | // // format as seen here
92 | // scm {
93 | // connection = POM_SCM_CONNECTION
94 | // developerConnection = POM_SCM_DEV_CONNECTION
95 | // url = POM_SCM_URL
96 | // }
97 | // }
98 | // }
99 | // }
100 | // }
101 | // }
102 |
103 | // signing {
104 | // sign publishing.publications
105 | // }
106 |
--------------------------------------------------------------------------------
/app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/database/table/fruit/FruitListAdapter.kt:
--------------------------------------------------------------------------------
1 | package de.raphaelebner.roomdatabasebackup.sample.database.table.fruit
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import android.widget.TextView
7 | import androidx.recyclerview.widget.DiffUtil
8 | import androidx.recyclerview.widget.ListAdapter
9 | import androidx.recyclerview.widget.RecyclerView
10 | import de.raphaelebner.roomdatabasebackup.sample.R
11 |
12 | /**
13 | * MIT License
14 | *
15 | * Copyright (c) 2025 Raphael Ebner
16 | *
17 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
18 | * associated documentation files (the "Software"), to deal in the Software without restriction,
19 | * including without limitation the rights to use, copy, modify, merge, publish, distribute,
20 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
21 | * furnished to do so, subject to the following conditions:
22 | *
23 | * The above copyright notice and this permission notice shall be included in all copies or
24 | * substantial portions of the Software.
25 | *
26 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
27 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
28 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
29 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
31 | */
32 | class FruitListAdapter(private val itemClickListener: OnItemClickListener) :
33 | ListAdapter
16 |
17 | Simple tool to backup and restore your room database in Android
18 |
19 | ## Features
20 |
21 | - Create simple backups of your room database
22 | - Encrypt the backup file with AES encryption
23 | - Save the backup to any type of storage (some types are in beta)
24 | - Material design
25 | - Written in Kotlin
26 |
27 | ## Content
28 |
29 | - [Features](#Features)
30 | - [Changelog](#Changelog)
31 | - [Getting started](#Getting-started)
32 | - [Usage](#Usage)
33 | - [Sample app](#Sample-app)
34 | - [Developed by](#Developed-by)
35 | - [License](#License)
36 |
37 | ## Changelog
38 |
39 | [Changelog and Upgrading notes](CHANGELOG.md)
40 |
41 | ## Getting started
42 |
43 | Android-Room-Database-Backup library is pushed
44 | to [Maven Central](https://central.sonatype.com/artifact/de.raphaelebner/roomdatabasebackup/1.1.0/versions)
45 | .
46 | Add the dependency for `Android-Room-Database-Backup ` to your app-level `build.gradle` file.
47 |
48 | ```groovy
49 | implementation 'de.raphaelebner:roomdatabasebackup:1.1.0'
50 | ```
51 |
52 | **If the version makes any technical problems please feel free to contact me. I made some changes in
53 | Gradle/Kotlin DSL and not sure if everything is working as excepted**
54 |
55 | ## Usage
56 |
57 | - [Properties](#Properties)
58 | - [Exit Codes](#Exit-Codes)
59 | - [Example Activity (Kotlin and Java)](#example-activity-kotlin-and-java)
60 | - [Example Fragment (Java and Kotlin)](#example-fragment-kotlin-and-java)
61 |
62 | ### Properties
63 |
64 | **Required**
65 |
66 | - Current context
67 | **Attention**
68 | Must be declared outside of an onClickListener before lifecycle state changes to started
69 |
70 | ```kotlin
71 | RoomBackup(this)
72 | ```
73 |
74 | - Instance of your room database
75 |
76 | ```kotlin
77 | .database(*YourDatabase*.getInstance(this))
78 | ```
79 |
80 | e.g. [`YourDatabase.kt`](app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/database/main/FruitDatabase.kt)
81 |
82 | **Optional**
83 |
84 | The following options are optional and the default options
85 |
86 | - Enable logging, for debugging and some error messages
87 |
88 | ```kotlin
89 | .enableLogDebug(false)
90 | ```
91 |
92 | - Set custom log tag
93 |
94 | ```kotlin
95 | .customLogTag("debug_RoomBackup")
96 | ```
97 |
98 | - Enable and set maxFileCount
99 |
100 | - if file count of Backups > maxFileCount all old / the oldest backup file will be deleted
101 | - can be used with internal and external storage
102 | - default: infinity
103 |
104 | ```kotlin
105 | .maxFileCount(5)
106 | ```
107 |
108 | - Encrypt your backup
109 |
110 | - Is encrypted with AES encryption
111 | - uses a random 15 digit long key with alphanumeric characters
112 | - this key is saved in [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences)
113 | - backup name is default backup name + ".aes"
114 |
115 | ```kotlin
116 | .backupIsEncrypted(false)
117 | ```
118 |
119 | - Encrypt your backup with your own password / key
120 |
121 | - This property is only working, if `.backupIsEncrypted(true)` is set
122 | - If you use the key to encrypt the backup, you will also need it to decrypt
123 | - Example: If you want to create an encrypted backup, export it and import it to another device. Then you need a custom key, else the backup is encrypted with a random key, and you can not decrypt it on a new device
124 |
125 | **Attention**
126 | i do not assume any liability for the loss of your key
127 |
128 | ```kotlin
129 | .customEncryptPassword("YOUR_SECRET_PASSWORD")
130 | ```
131 |
132 | - Save your backup to different storage
133 |
134 | - External
135 | - storage path: /storage/emulated/0/Android/data/_package_/files/backup/
136 | - This files will be deleted, if you uninstall your app
137 | - `RoomBackup.BACKUP_FILE_LOCATION_EXTERNAL`
138 | - Internal
139 | - Private, storage not accessible
140 | - This files will be deleted, if you uninstall your app
141 | - `RoomBackup.BACKUP_FILE_LOCATION_INTERNAL`
142 | - Custom Dialog (beta)
143 | - You can choose to save or restore where ever you want. A CreateDocument() or OpenDocument() Activity will be launched where you can choose the location
144 | - If your backup is encrypted I reccomend you using a custom encrption password else you can't
145 | restore your backup
146 | - `RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG`
147 | - Custom File (beta)
148 |
149 | - You can choose to save or restore to/from a custom File.
150 | - If your backup is encrypted I reccomend you using a custom encrption password else you can't restore your backup
151 | - Please use `backupLocationCustomFile(File)` to set a custom File
152 | - `RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_FILE`
153 |
154 | **Attention**
155 | For custom dialog and custom file I only verified the functionality for local storage. For thirt party storage please try and contact me if it is not working. I hope I can find a solution and fix it :)
156 |
157 | ```kotlin
158 | .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_INTERNAL)
159 | ```
160 |
161 | - Set a custom File to save/restore to/from
162 | Only working if `backupLocation` is set to `BACKUP_FILE_LOCATION_CUSTOM_FILE`
163 | You have to define a File withe Filename and extension
164 |
165 | ```kotlin
166 | .backupLocationCustomFile(backupLocationCustomFile: File)
167 | ```
168 |
169 | - Set a custom dialog title, when showing list of available backups to restore (only for external or internal storage)
170 |
171 | ```kotlin
172 | .customRestoreDialogTitle("Choose file to restore")
173 | ```
174 |
175 | - Set your custom name to the Backup files
176 |
177 | **Attention**\
178 | If a backup file with the same name already exists, it will be replaced
179 | customBackupFileName should not contain file extension. File extension(s) will be added automatically
180 | ".sqlite3" is the default file extension and for encrypted backups ".aes" will be added
181 |
182 | ```kotlin
183 | .customBackupFileName(*DatabaseName* + *currentTime* + ".sqlite3")
184 | ```
185 |
186 | - Run some code, after backup / restore process is finished
187 |
188 | - success: Boolean (If backup / restore was successful = true)
189 | - message: String (message with simple hints, if backup / restore failed)
190 |
191 | ```kotlin
192 | .onCompleteListener { success, message, exitCode ->
193 | }
194 | ```
195 |
196 | - Restart your Application. Can be implemented in the onCompleteListener, when "success == true"
197 |
198 | **Attention**\
199 | it does not always work reliably!\
200 | But you can use other methods.\
201 | Important is that all activities / fragments that are still open must be closed and reopened\
202 | Because the Database instance is a new one, and the old activities / fragments are trying to
203 | work with the old instance
204 |
205 | ```kotlin
206 | .restartApp(Intent(this@MainActivity, MainActivity::class.java))
207 | ```
208 |
209 | ### Exit Codes
210 |
211 | Here are all exit codes for the onCompleteListener.
212 | They can be calles using `OnCompleteListener.$NAME$`
213 |
214 | | Exit Code | Name | Description |
215 | | --------- | :----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
216 | | 0 | `EXIT_CODE_SUCCESS` | No error, action successful |
217 | | 1 | `EXIT_CODE_ERROR` | Other Error |
218 | | 2 | `EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER` | Error while choosing backup to restore. Maybe no file selected |
219 | | 3 | `EXIT_CODE_ERROR_BACKUP_FILE_CREATOR` | Error while choosing backup file to create. Maybe no file selected |
220 | | 4 | `EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING` | [BACKUP_FILE_LOCATION_CUSTOM_FILE] is set but [RoomBackup.backupLocationCustomFile] is not set |
221 | | 5 | `EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING` | [RoomBackup.backupLocation] is not set |
222 | | 6 | `EXIT_CODE_ERROR_BY_USER_CANCELED` | Restore dialog for internal/external storage was canceled by user |
223 | | 7 | `EXIT_CODE_ERROR_DECRYPTION_ERROR` | Cannot decrypt provided backup file |
224 | | 8 | `EXIT_CODE_ERROR_ENCRYPTION_ERROR` | Cannot encrypt database backup |
225 | | 9 | `EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED` | You tried to restore a encrypted backup but [RoomBackup.backupIsEncrypted] is set to false |
226 | | 10 | `EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE` | No backups to restore are available in internal/external sotrage |
227 | | 11 | `EXIT_CODE_ERROR_ROOM_DATABASE_MISSING` | No room database to backup is provided |
228 | | 12 | `EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED` | Storage permissions not granted for custom dialog |
229 | | 13 | `EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD` | Cannot decrypt provided backup file because the password is incorrect |
230 |
231 | ### Example Activity (Kotlin and Java)
232 |
233 | #### Kotlin
234 |
235 | - ##### Backup
236 |
237 | ```kotlin
238 | val backup = RoomBackup(this)
239 | ...
240 | backup
241 | .database(FruitDatabase.getInstance(this))
242 | .enableLogDebug(true)
243 | .backupIsEncrypted(true)
244 | .customEncryptPassword("YOUR_SECRET_PASSWORD")
245 | .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_INTERNAL)
246 | .maxFileCount(5)
247 | .apply {
248 | onCompleteListener { success, message, exitCode ->
249 | Log.d(TAG, "success: $success, message: $message, exitCode: $exitCode")
250 | if (success) restartApp(Intent(this@MainActivity, MainActivity::class.java))
251 | }
252 | }
253 | .backup()
254 | ```
255 |
256 | - ##### Restore
257 |
258 | ```kotlin
259 | val backup = RoomBackup(this)
260 | ...
261 | backup
262 | .database(FruitDatabase.getInstance(this))
263 | .enableLogDebug(true)
264 | .backupIsEncrypted(true)
265 | .customEncryptPassword("YOUR_SECRET_PASSWORD")
266 | .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_INTERNAL)
267 | .apply {
268 | onCompleteListener { success, message, exitCode ->
269 | Log.d(TAG, "success: $success, message: $message, exitCode: $exitCode")
270 | if (success) restartApp(Intent(this@MainActivity, MainActivity::class.java))
271 | }
272 | }
273 | .restore()
274 | ```
275 |
276 | #### Java
277 |
278 | - ##### Backup
279 |
280 | ```java
281 | final RoomBackup roomBackup = new RoomBackup(MainActivityJava.this);
282 | ...
283 | roomBackup.database(FruitDatabase.Companion.getInstance(getApplicationContext()));
284 | roomBackup.enableLogDebug(enableLog);
285 | roomBackup.backupIsEncrypted(encryptBackup);
286 | roomBackup.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_INTERNAL);
287 | roomBackup.maxFileCount(5);
288 | roomBackup.onCompleteListener((success, message, exitCode) -> {
289 | Log.d(TAG, "success: " + success + ", message: " + message + ", exitCode: " + exitCode);
290 | if (success) roomBackup.restartApp(new Intent(getApplicationContext(), MainActivityJava.class));
291 | });
292 | roomBackup.backup();
293 | ```
294 |
295 | - ##### Restore
296 |
297 | ```java
298 | final RoomBackup roomBackup = new RoomBackup(MainActivityJava.this);
299 | ...
300 | roomBackup.database(FruitDatabase.Companion.getInstance(getApplicationContext()));
301 | roomBackup.enableLogDebug(enableLog);
302 | roomBackup.backupIsEncrypted(encryptBackup);
303 | roomBackup.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_INTERNAL);
304 | roomBackup.onCompleteListener((success, message, exitCode) -> {
305 | Log.d(TAG, "success: " + success + ", message: " + message + ", exitCode: " + exitCode);
306 | if (success) roomBackup.restartApp(new Intent(getApplicationContext(), MainActivityJava.class));
307 | });
308 | roomBackup.restore();
309 | ```
310 |
311 | ### Example Fragment (Kotlin and Java)
312 |
313 | ##### Kotlin
314 |
315 | [`FragmentActivity.kt`](app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/FragmentActivity.kt)
316 | [`MainFragment.kt`](app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/MainFragment.kt)
317 |
318 | ##### Java
319 |
320 | [`FragmentActivityJava.java`](app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/FragmentActivityJava.java)
321 | [`MainFragmentJava.java`](app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/MainFragmentJava.java)
322 |
323 | ## Sample app
324 |
325 | 1. Download this repo
326 | 2. Unzip
327 | 3. Android Studio --> File --> Open --> select this Project
328 | 4. within the app folder you find the sample app
329 |
330 | ## Developed by
331 |
332 | - Raphael Ebner
333 | - [paypal.me/raphaelebner](https://www.paypal.me/raphaelebner)
334 |
335 | ## License
336 |
337 | MIT License
338 |
339 | Copyright (c) 2025 Raphael Ebner
340 |
341 | Permission is hereby granted, free of charge, to any person obtaining a copy
342 | of this software and associated documentation files (the "Software"), to deal
343 | in the Software without restriction, including without limitation the rights
344 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
345 | copies of the Software, and to permit persons to whom the Software is
346 | furnished to do so, subject to the following conditions:
347 |
348 | The above copyright notice and this permission notice shall be included in all
349 | copies or substantial portions of the Software.
350 |
351 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
352 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
353 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
354 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
355 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
356 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
357 | SOFTWARE.
358 |
--------------------------------------------------------------------------------
/app/src/main/java/de/raphaelebner/roomdatabasebackup/sample/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package de.raphaelebner.roomdatabasebackup.sample
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Activity
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.graphics.Canvas
8 | import android.graphics.Color
9 | import android.os.Bundle
10 | import android.util.Log
11 | import android.widget.Button
12 | import android.widget.TextView
13 | import android.widget.Toast
14 | import androidx.activity.result.contract.ActivityResultContracts
15 | import androidx.appcompat.app.AppCompatActivity
16 | import androidx.coordinatorlayout.widget.CoordinatorLayout
17 | import androidx.core.content.ContextCompat
18 | import androidx.lifecycle.ViewModelProvider
19 | import androidx.recyclerview.widget.ItemTouchHelper
20 | import androidx.recyclerview.widget.LinearLayoutManager
21 | import androidx.recyclerview.widget.RecyclerView
22 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
23 | import com.google.android.material.floatingactionbutton.FloatingActionButton
24 | import com.google.android.material.snackbar.Snackbar
25 | import de.raphaelebner.roomdatabasebackup.core.RoomBackup
26 | import de.raphaelebner.roomdatabasebackup.sample.database.main.FruitDatabase
27 | import de.raphaelebner.roomdatabasebackup.sample.database.table.fruit.Fruit
28 | import de.raphaelebner.roomdatabasebackup.sample.database.table.fruit.FruitListAdapter
29 | import de.raphaelebner.roomdatabasebackup.sample.database.table.fruit.FruitViewModel
30 | import java.io.File
31 | import androidx.core.content.edit
32 |
33 | /**
34 | * MIT License
35 | *
36 | * Copyright (c) 2025 Raphael Ebner
37 | *
38 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
39 | * associated documentation files (the "Software"), to deal in the Software without restriction,
40 | * including without limitation the rights to use, copy, modify, merge, publish, distribute,
41 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
42 | * furnished to do so, subject to the following conditions:
43 | *
44 | * The above copyright notice and this permission notice shall be included in all copies or
45 | * substantial portions of the Software.
46 | *
47 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
48 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
49 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
50 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
51 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
52 | */
53 | class MainActivity : AppCompatActivity(), FruitListAdapter.OnItemClickListener {
54 |
55 | private lateinit var fruitViewModel: FruitViewModel
56 | private lateinit var clMain: CoordinatorLayout
57 |
58 | companion object {
59 | private const val TAG = "debug_MainActivity"
60 | const val SECRET_PASSWORD = "verySecretEncryptionKey"
61 | }
62 |
63 | @SuppressLint("SetTextI18n")
64 | override fun onCreate(savedInstanceState: Bundle?) {
65 | super.onCreate(savedInstanceState)
66 | setContentView(R.layout.activity_main)
67 |
68 | /*---------------------Link items to Layout--------------------------*/
69 | clMain = findViewById(R.id.cl_main)
70 | val recyclerView: RecyclerView = findViewById(R.id.rv_fruits)
71 | val fab: FloatingActionButton = findViewById(R.id.btn_addFruit)
72 | val btnBackup: Button = findViewById(R.id.btn_backup)
73 | val btnRestore: Button = findViewById(R.id.btn_restore)
74 | val btnProperties: Button = findViewById(R.id.btn_properties)
75 | val btnLanguage: Button = findViewById(R.id.btn_switch_language)
76 | val btnFragmentActivity: Button = findViewById(R.id.btn_switch_fragment_activity)
77 | val btnBackupLocation: Button = findViewById(R.id.btn_backup_location)
78 | val tvFruits: TextView = findViewById(R.id.tv_fruits)
79 |
80 | val adapter = FruitListAdapter(this)
81 | recyclerView.adapter = adapter
82 | recyclerView.layoutManager = LinearLayoutManager(this)
83 |
84 | fruitViewModel = ViewModelProvider(this)[FruitViewModel::class.java]
85 |
86 | fruitViewModel.allFruit.observe(this) { fruits -> adapter.submitList(fruits) }
87 |
88 | tvFruits.text = "Fruits List (Kotlin Activity)"
89 | btnLanguage.text = "switch to Java"
90 | btnFragmentActivity.text = "switch to Fragment"
91 |
92 | val sharedPrefs = "sampleBackup"
93 | val spEncryptBackup = "encryptBackup"
94 | val spStorageLocation = "storageLocation"
95 | val spEnableLog = "enableLog"
96 | val spUseMaxFileCount = "useMaxFileCount"
97 | val sharedPreferences = getSharedPreferences(sharedPrefs, Context.MODE_PRIVATE)
98 |
99 | /*---------------------FAB Add Button--------------------------*/
100 | fab.setOnClickListener {
101 | val intent = Intent(this, ActivityAddEditFruit::class.java)
102 | openAddEditActivity.launch(intent)
103 | }
104 |
105 | /*---------------------go to Java MainActivity--------------------------*/
106 | btnLanguage.setOnClickListener {
107 | finish()
108 | val intent = Intent(this, MainActivityJava::class.java)
109 | startActivity(intent)
110 | }
111 |
112 | /*---------------------go to Fragment class--------------------------*/
113 | btnFragmentActivity.setOnClickListener {
114 | finish()
115 | val intent = Intent(this, FragmentActivity::class.java)
116 | startActivity(intent)
117 | }
118 |
119 | var encryptBackup = sharedPreferences.getBoolean(spEncryptBackup, true)
120 | var storageLocation = sharedPreferences.getInt(spStorageLocation, 1)
121 | var enableLog = sharedPreferences.getBoolean(spEnableLog, true)
122 | var useMaxFileCount = sharedPreferences.getBoolean(spUseMaxFileCount, false)
123 |
124 | /*---------------------set Properties--------------------------*/
125 | btnProperties.setOnClickListener {
126 | val multiItems = arrayOf("Encrypt Backup", "enable Log", "use maxFileCount = 5")
127 | val checkedItems = booleanArrayOf(encryptBackup, enableLog, useMaxFileCount)
128 |
129 | MaterialAlertDialogBuilder(this)
130 | .setTitle("Change Properties")
131 | .setPositiveButton("Ok", null)
132 | // Multi-choice items (initialized with checked items)
133 | .setMultiChoiceItems(multiItems, checkedItems) { _, which, checked ->
134 | // Respond to item chosen
135 | when (which) {
136 | 0 -> {
137 | encryptBackup = checked
138 | sharedPreferences
139 | .edit {
140 | putBoolean(spEncryptBackup, encryptBackup)
141 | }
142 | }
143 | 1 -> {
144 | enableLog = checked
145 | sharedPreferences.edit { putBoolean(spEnableLog, enableLog) }
146 | }
147 | 2 -> {
148 | useMaxFileCount = checked
149 | sharedPreferences
150 | .edit {
151 | putBoolean(spUseMaxFileCount, useMaxFileCount)
152 | }
153 | }
154 | }
155 | }
156 | .show()
157 | }
158 |
159 | /*---------------------set Backup Location--------------------------*/
160 | btnBackupLocation.setOnClickListener {
161 | val storageItems = arrayOf("Internal", "External", "Custom Dialog", "Custom File")
162 | MaterialAlertDialogBuilder(this)
163 | .setTitle("Change Storage")
164 | .setPositiveButton("Ok", null)
165 | .setSingleChoiceItems(storageItems, storageLocation - 1) { _, which ->
166 | when (which) {
167 | 0 -> {
168 | storageLocation = RoomBackup.BACKUP_FILE_LOCATION_INTERNAL
169 | sharedPreferences
170 | .edit {
171 | putInt(spStorageLocation, storageLocation)
172 | }
173 | }
174 | 1 -> {
175 | storageLocation = RoomBackup.BACKUP_FILE_LOCATION_EXTERNAL
176 | sharedPreferences
177 | .edit {
178 | putInt(spStorageLocation, storageLocation)
179 | }
180 | }
181 | 2 -> {
182 | storageLocation = RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG
183 | sharedPreferences
184 | .edit {
185 | putInt(spStorageLocation, storageLocation)
186 | }
187 | }
188 | 3 -> {
189 | storageLocation = RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_FILE
190 | sharedPreferences
191 | .edit {
192 | putInt(spStorageLocation, storageLocation)
193 | }
194 | }
195 | }
196 | }
197 | .show()
198 | }
199 |
200 | val backup = RoomBackup(this@MainActivity)
201 | /*---------------------Backup and Restore Database--------------------------*/
202 | btnBackup.setOnClickListener {
203 | backup.backupLocation(storageLocation)
204 | .backupLocationCustomFile(
205 | File("${this.filesDir}/databasebackup/geilesBackup.sqlite3")
206 | )
207 | .database(FruitDatabase.getInstance(this))
208 | .enableLogDebug(enableLog)
209 | .backupIsEncrypted(encryptBackup)
210 | .customEncryptPassword(SECRET_PASSWORD)
211 | // maxFileCount: else 1000 because i cannot surround it with if condition
212 | .maxFileCount(if (useMaxFileCount) 5 else 1000)
213 | .apply {
214 | onCompleteListener { success, message, exitCode ->
215 | Log.d(TAG, "success: $success, message: $message, exitCode: $exitCode")
216 | Toast.makeText(
217 | this@MainActivity,
218 | "success: $success, message: $message, exitCode: $exitCode",
219 | Toast.LENGTH_LONG
220 | )
221 | .show()
222 | if (success)
223 | restartApp(Intent(this@MainActivity, MainActivity::class.java))
224 | }
225 | }
226 | .backup()
227 | }
228 | btnRestore.setOnClickListener {
229 | backup.backupLocation(storageLocation)
230 | .backupLocationCustomFile(
231 | File("${this.filesDir}/databasebackup/geilesBackup.sqlite3")
232 | )
233 | .database(FruitDatabase.getInstance(this))
234 | .enableLogDebug(enableLog)
235 | .backupIsEncrypted(encryptBackup)
236 | .customEncryptPassword(SECRET_PASSWORD)
237 | .apply {
238 | onCompleteListener { success, message, exitCode ->
239 | Log.d(TAG, "success: $success, message: $message, exitCode: $exitCode")
240 | Toast.makeText(
241 | this@MainActivity,
242 | "success: $success, message: $message, exitCode: $exitCode",
243 | Toast.LENGTH_LONG
244 | )
245 | .show()
246 | if (success)
247 | restartApp(Intent(this@MainActivity, MainActivity::class.java))
248 | }
249 | }
250 | .restore()
251 | }
252 |
253 | /*---------------------Swiping on a row--------------------------*/
254 | ItemTouchHelper(
255 | object :
256 | ItemTouchHelper.SimpleCallback(
257 | 0,
258 | ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
259 | ) {
260 |
261 | override fun onMove(
262 | recyclerView: RecyclerView,
263 | viewHolder: RecyclerView.ViewHolder,
264 | target: RecyclerView.ViewHolder
265 | ): Boolean {
266 | return false
267 | }
268 |
269 | /*---------------------do action on swipe--------------------------*/
270 | override fun onSwiped(
271 | viewHolder: RecyclerView.ViewHolder,
272 | direction: Int
273 | ) {
274 | // Item in recyclerview
275 | val position = viewHolder.bindingAdapterPosition
276 | val fruit = adapter.getFruitAt(position)!!
277 |
278 | fruitViewModel.delete(fruit)
279 | // adapter.notifyItemChanged(position)
280 | // show snack bar with Undo option
281 | val snackbar = Snackbar.make(clMain, "${fruit.name} deleted", 8000)
282 | snackbar.setAction("UNDO") {
283 | // undo is selected, restore the deleted item
284 | fruitViewModel.insert(fruit)
285 | }
286 | snackbar.setActionTextColor(Color.YELLOW)
287 | snackbar.show()
288 | }
289 |
290 | /*---------------------ADD trash bin icon to background--------------------------*/
291 | override fun onChildDraw(
292 | c: Canvas,
293 | recyclerView: RecyclerView,
294 | viewHolder: RecyclerView.ViewHolder,
295 | dX: Float,
296 | dY: Float,
297 | actionState: Int,
298 | isCurrentlyActive: Boolean
299 | ) {
300 | super.onChildDraw(
301 | c,
302 | recyclerView,
303 | viewHolder,
304 | dX,
305 | dY,
306 | actionState,
307 | isCurrentlyActive
308 | )
309 |
310 | val icon =
311 | ContextCompat.getDrawable(
312 | this@MainActivity,
313 | R.drawable.ic_delete
314 | )
315 |
316 | val itemView = viewHolder.itemView
317 | val iconMargin = (itemView.height - icon!!.intrinsicHeight) / 2
318 | val iconTop =
319 | itemView.top + (itemView.height - icon.intrinsicHeight) / 2
320 | val iconBottom = iconTop + icon.intrinsicHeight
321 |
322 | if (dX > 0) { // Swiping to the right
323 | val iconLeft = itemView.left + iconMargin + icon.intrinsicWidth
324 | val iconRight = itemView.left + iconMargin
325 | icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
326 | } else if (dX < 0) { // Swiping to the left
327 | val iconLeft = itemView.right - iconMargin - icon.intrinsicWidth
328 | val iconRight = itemView.right - iconMargin
329 | icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
330 | }
331 |
332 | icon.draw(c)
333 | }
334 | }
335 | )
336 | .attachToRecyclerView(recyclerView)
337 | }
338 |
339 | /*---------------------when returning from |ActivityAddEditFruit| do something--------------------------*/
340 | private val openAddEditActivity =
341 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
342 |
343 | /*---------------------If the Request was successful--------------------------*/
344 | if (result.resultCode == Activity.RESULT_OK) {
345 | val data = result.data
346 | val name = data!!.getStringExtra(ActivityAddEditFruit.EXTRA_NAME)!!
347 | val id = data.getIntExtra(ActivityAddEditFruit.EXTRA_ID, -1)
348 | val deleteFruit =
349 | data.getBooleanExtra(ActivityAddEditFruit.EXTRA_DELETE_FRUIT, false)
350 |
351 | val fruit = Fruit(name)
352 |
353 | if (id == -1) {
354 | fruitViewModel.insert(fruit)
355 | } else {
356 | fruit.id = id
357 | if (deleteFruit) fruitViewModel.delete(fruit)
358 | else {
359 | fruitViewModel.update(fruit)
360 | }
361 | }
362 | }
363 | }
364 |
365 | /*---------------------onItemClicked listener--------------------------*/
366 | override fun onItemClicked(fruit: Fruit) {
367 | val intent = Intent(this, ActivityAddEditFruit::class.java)
368 | intent.putExtra(ActivityAddEditFruit.EXTRA_ID, fruit.id)
369 | intent.putExtra(ActivityAddEditFruit.EXTRA_NAME, fruit.name)
370 | openAddEditActivity.launch(intent)
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/core/src/main/java/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt:
--------------------------------------------------------------------------------
1 | package de.raphaelebner.roomdatabasebackup.core
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.SharedPreferences
7 | import android.net.Uri
8 | import android.os.Build
9 | import android.provider.OpenableColumns
10 | import android.util.Log
11 | import android.widget.Toast
12 | import androidx.activity.ComponentActivity
13 | import androidx.activity.result.contract.ActivityResultContracts
14 | import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
15 | import androidx.room.RoomDatabase
16 | import androidx.security.crypto.EncryptedSharedPreferences
17 | import androidx.security.crypto.MasterKey
18 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
19 | import com.google.common.io.Files.copy
20 | import kotlinx.coroutines.runBlocking
21 | import org.apache.commons.io.comparator.LastModifiedFileComparator
22 | import java.io.BufferedOutputStream
23 | import java.io.File
24 | import java.io.FileOutputStream
25 | import java.io.IOException
26 | import java.io.InputStream
27 | import java.io.OutputStream
28 | import java.text.SimpleDateFormat
29 | import java.util.Arrays
30 | import java.util.Calendar
31 | import java.util.Locale
32 | import javax.crypto.BadPaddingException
33 |
34 | /**
35 | * MIT License
36 | *
37 | * Copyright (c) 2025 Raphael Ebner
38 | *
39 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
40 | * associated documentation files (the "Software"), to deal in the Software without restriction,
41 | * including without limitation the rights to use, copy, modify, merge, publish, distribute,
42 | * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
43 | * furnished to do so, subject to the following conditions:
44 | *
45 | * The above copyright notice and this permission notice shall be included in all copies or
46 | * substantial portions of the Software.
47 | *
48 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
49 | * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
50 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
51 | * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
52 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
53 | */
54 | class RoomBackup(var context: Context) {
55 |
56 | companion object {
57 | private const val SHARED_PREFS = "de.raphaelebner.roomdatabasebackup"
58 | private var TAG = "debug_RoomBackup"
59 | private lateinit var INTERNAL_BACKUP_PATH: File
60 | private lateinit var TEMP_BACKUP_PATH: File
61 | private lateinit var TEMP_BACKUP_FILE: File
62 | private lateinit var EXTERNAL_BACKUP_PATH: File
63 | private lateinit var DATABASE_FILE: File
64 |
65 | private var currentProcess: Int? = null
66 | private const val PROCESS_BACKUP = 1
67 | private const val PROCESS_RESTORE = 2
68 | private const val BACKUP_EXTENSION_BASE = "sqlite3"
69 | private const val BACKUP_EXTENSION_ENCRYPTED = "aes"
70 | private var backupFilename: String? = null
71 |
72 | /** Code for internal backup location, used for [backupLocation] */
73 | const val BACKUP_FILE_LOCATION_INTERNAL = 1
74 |
75 | /** Code for external backup location, used for [backupLocation] */
76 | const val BACKUP_FILE_LOCATION_EXTERNAL = 2
77 |
78 | /** Code for custom backup location dialog, used for [backupLocation] */
79 | const val BACKUP_FILE_LOCATION_CUSTOM_DIALOG = 3
80 |
81 | /** Code for custom backup file location, used for [backupLocation] */
82 | const val BACKUP_FILE_LOCATION_CUSTOM_FILE = 4
83 | }
84 |
85 | private lateinit var sharedPreferences: SharedPreferences
86 | private lateinit var dbName: String
87 |
88 | private var roomDatabase: RoomDatabase? = null
89 | private var enableLogDebug: Boolean = false
90 | private var restartIntent: Intent? = null
91 | private var onCompleteListener: OnCompleteListener? = null
92 | private var customRestoreDialogTitle: String = "Choose file to restore"
93 | private var customBackupFileName: String? = null
94 | private var backupIsEncrypted: Boolean = false
95 | private var maxFileCount: Int? = null
96 | private var encryptPassword: String? = null
97 | private var backupLocation: Int = BACKUP_FILE_LOCATION_INTERNAL
98 | private var backupLocationCustomFile: File? = null
99 |
100 | /**
101 | * Set RoomDatabase instance
102 | *
103 | * @param roomDatabase RoomDatabase
104 | */
105 | fun database(roomDatabase: RoomDatabase): RoomBackup {
106 | this.roomDatabase = roomDatabase
107 | return this
108 | }
109 |
110 | /**
111 | * Set LogDebug enabled / disabled
112 | *
113 | * @param enableLogDebug Boolean
114 | */
115 | fun enableLogDebug(enableLogDebug: Boolean): RoomBackup {
116 | this.enableLogDebug = enableLogDebug
117 | return this
118 | }
119 |
120 | /**
121 | * Set Intent in which to boot after App restart
122 | *
123 | * @param restartIntent Intent
124 | */
125 | fun restartApp(restartIntent: Intent): RoomBackup {
126 | this.restartIntent = restartIntent
127 | restartApp()
128 | return this
129 | }
130 |
131 | /**
132 | * Set onCompleteListener, to run code when tasks completed
133 | *
134 | * @param onCompleteListener OnCompleteListener
135 | */
136 | fun onCompleteListener(onCompleteListener: OnCompleteListener): RoomBackup {
137 | this.onCompleteListener = onCompleteListener
138 | return this
139 | }
140 |
141 | /**
142 | * Set onCompleteListener, to run code when tasks completed
143 | *
144 | * @param listener (success: Boolean, message: String) -> Unit
145 | */
146 | fun onCompleteListener(
147 | listener: (success: Boolean, message: String, exitCode: Int) -> Unit
148 | ): RoomBackup {
149 | this.onCompleteListener =
150 | object : OnCompleteListener {
151 | override fun onComplete(success: Boolean, message: String, exitCode: Int) {
152 | listener(success, message, exitCode)
153 | }
154 | }
155 | return this
156 | }
157 |
158 | /**
159 | * Set custom log tag, for detailed debugging
160 | *
161 | * @param customLogTag String
162 | */
163 | fun customLogTag(customLogTag: String): RoomBackup {
164 | TAG = customLogTag
165 | return this
166 | }
167 |
168 | /**
169 | * Set custom Restore Dialog Title, default = "Choose file to restore"
170 | *
171 | * @param customRestoreDialogTitle String
172 | */
173 | fun customRestoreDialogTitle(customRestoreDialogTitle: String): RoomBackup {
174 | this.customRestoreDialogTitle = customRestoreDialogTitle
175 | return this
176 | }
177 |
178 | /**
179 | * Set custom Backup File Name, default = "$dbName-$currentTime.sqlite3"
180 | * customBackupFileName should not contain file extension. File extension(s) will be added automatically
181 | * @param customBackupFileName String
182 | */
183 | fun customBackupFileName(customBackupFileName: String): RoomBackup {
184 | this.customBackupFileName = customBackupFileName
185 | return this
186 | }
187 |
188 | /**
189 | * Set you backup location. Available values see: [BACKUP_FILE_LOCATION_INTERNAL],
190 | * [BACKUP_FILE_LOCATION_EXTERNAL], [BACKUP_FILE_LOCATION_CUSTOM_DIALOG] or
191 | * [BACKUP_FILE_LOCATION_CUSTOM_FILE]
192 | *
193 | * @param backupLocation Int, default = [BACKUP_FILE_LOCATION_INTERNAL]
194 | */
195 | fun backupLocation(backupLocation: Int): RoomBackup {
196 | this.backupLocation = backupLocation
197 | return this
198 | }
199 |
200 | /**
201 | * Set a custom file where to save or restore a backup. can be used for backup and restore
202 | *
203 | * Only available if [backupLocation] is set to [BACKUP_FILE_LOCATION_CUSTOM_FILE]
204 | *
205 | * @param backupLocationCustomFile File
206 | */
207 | fun backupLocationCustomFile(backupLocationCustomFile: File): RoomBackup {
208 | this.backupLocationCustomFile = backupLocationCustomFile
209 | return this
210 | }
211 |
212 | /**
213 | * Set file encryption to true / false can be used for backup and restore
214 | *
215 | * @param backupIsEncrypted Boolean, default = false
216 | */
217 | fun backupIsEncrypted(backupIsEncrypted: Boolean): RoomBackup {
218 | this.backupIsEncrypted = backupIsEncrypted
219 | return this
220 | }
221 |
222 | /**
223 | * Set max backup files count if fileCount is > maxFileCount the oldest backup file will be
224 | * deleted is for both internal and external storage
225 | *
226 | * @param maxFileCount Int, default = null
227 | */
228 | fun maxFileCount(maxFileCount: Int): RoomBackup {
229 | this.maxFileCount = maxFileCount
230 | return this
231 | }
232 |
233 | /**
234 | * Set custom backup encryption password
235 | *
236 | * @param encryptPassword String
237 | */
238 | fun customEncryptPassword(encryptPassword: String): RoomBackup {
239 | this.encryptPassword = encryptPassword
240 | return this
241 | }
242 |
243 | /** Init vars, and return true if no error occurred */
244 | private fun initRoomBackup(): Boolean {
245 | if (roomDatabase == null) {
246 | if (enableLogDebug) Log.d(TAG, "roomDatabase is missing")
247 | onCompleteListener?.onComplete(
248 | false,
249 | "roomDatabase is missing",
250 | OnCompleteListener.EXIT_CODE_ERROR_ROOM_DATABASE_MISSING
251 | )
252 | // throw IllegalArgumentException("roomDatabase is not initialized")
253 | return false
254 | }
255 |
256 | // Create or retrieve the Master Key for encryption/decryption
257 | val masterKeyAlias =
258 | MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
259 |
260 | if (backupLocation !in
261 | listOf(
262 | BACKUP_FILE_LOCATION_INTERNAL,
263 | BACKUP_FILE_LOCATION_EXTERNAL,
264 | BACKUP_FILE_LOCATION_CUSTOM_DIALOG,
265 | BACKUP_FILE_LOCATION_CUSTOM_FILE
266 | )
267 | ) {
268 | if (enableLogDebug) Log.d(TAG, "backupLocation is missing")
269 | onCompleteListener?.onComplete(
270 | false,
271 | "backupLocation is missing",
272 | OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING
273 | )
274 | return false
275 | }
276 |
277 | if (backupLocation == BACKUP_FILE_LOCATION_CUSTOM_FILE && backupLocationCustomFile == null
278 | ) {
279 | if (enableLogDebug)
280 | Log.d(
281 | TAG,
282 | "backupLocation is set to custom backup file, but no file is defined"
283 | )
284 | onCompleteListener?.onComplete(
285 | false,
286 | "backupLocation is set to custom backup file, but no file is defined",
287 | OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING
288 | )
289 | return false
290 | }
291 |
292 | // Initialize/open an instance of EncryptedSharedPreferences
293 | // Encryption key is stored in plain text in an EncryptedSharedPreferences --> it is saved
294 | // encrypted
295 | sharedPreferences =
296 | EncryptedSharedPreferences.create(
297 | context,
298 | SHARED_PREFS,
299 | masterKeyAlias,
300 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
301 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
302 | )
303 |
304 | dbName = roomDatabase!!.openHelper.databaseName!!
305 | INTERNAL_BACKUP_PATH = File("${context.filesDir}/databasebackup/")
306 | TEMP_BACKUP_PATH = File("${context.filesDir}/databasebackup-temp/")
307 | TEMP_BACKUP_FILE = File("$TEMP_BACKUP_PATH/tempbackup.sqlite3")
308 | EXTERNAL_BACKUP_PATH = File(context.getExternalFilesDir("backup")!!.toURI())
309 | DATABASE_FILE = File(context.getDatabasePath(dbName).toURI())
310 |
311 | // Create internal and temp backup directory if does not exist
312 | try {
313 | INTERNAL_BACKUP_PATH.mkdirs()
314 | TEMP_BACKUP_PATH.mkdirs()
315 | } catch (_: FileAlreadyExistsException) {
316 | } catch (_: IOException) {
317 | }
318 |
319 | if (enableLogDebug) {
320 | Log.d(TAG, "DatabaseName: $dbName")
321 | Log.d(TAG, "Database Location: $DATABASE_FILE")
322 | Log.d(TAG, "INTERNAL_BACKUP_PATH: $INTERNAL_BACKUP_PATH")
323 | Log.d(TAG, "EXTERNAL_BACKUP_PATH: $EXTERNAL_BACKUP_PATH")
324 | if (backupLocationCustomFile != null)
325 | Log.d(TAG, "backupLocationCustomFile: $backupLocationCustomFile")
326 | }
327 | return true
328 | }
329 |
330 | /** restart App with custom Intent */
331 | private fun restartApp() {
332 | restartIntent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
333 | context.startActivity(restartIntent)
334 | if (context is Activity) {
335 | (context as Activity).finish()
336 | }
337 | Runtime.getRuntime().exit(0)
338 | }
339 |
340 | /**
341 | * Start Backup process, and set onComplete Listener to success, if no error occurred, else
342 | * onComplete Listener success is false and error message is passed
343 | *
344 | * if custom storage ist selected, the [openBackupfileCreator] will be launched
345 | */
346 | fun backup() {
347 | if (enableLogDebug) Log.d(TAG, "Starting Backup ...")
348 | val success = initRoomBackup()
349 | if (!success) return
350 |
351 | // Needed for storage permissions request
352 | currentProcess = PROCESS_BACKUP
353 |
354 | // Create name for backup file
355 | // if no custom name is set: Database name + currentTime + .sqlite3
356 | // for custom name: customBackupFileName + .sqlite3
357 | // if backup is encrypted: .aes will be added to filename
358 | var filename =
359 | if (customBackupFileName == null) "$dbName-${getTime()}.$BACKUP_EXTENSION_BASE"
360 | else "$customBackupFileName.$BACKUP_EXTENSION_BASE"
361 | // Add .aes extension to filename, if file is encrypted
362 | if (backupIsEncrypted) filename += ".$BACKUP_EXTENSION_ENCRYPTED"
363 | if (enableLogDebug) Log.d(TAG, "backupFilename: $filename")
364 |
365 | when (backupLocation) {
366 | BACKUP_FILE_LOCATION_INTERNAL -> {
367 | val backupFile = File("$INTERNAL_BACKUP_PATH/$filename")
368 | doBackup(backupFile)
369 | }
370 | BACKUP_FILE_LOCATION_EXTERNAL -> {
371 | val backupFile = File("$EXTERNAL_BACKUP_PATH/$filename")
372 | doBackup(backupFile)
373 | }
374 | BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> {
375 | backupFilename = filename
376 | permissionRequestLauncher.launch(arrayOf())
377 | return
378 | }
379 | BACKUP_FILE_LOCATION_CUSTOM_FILE -> {
380 | doBackup(backupLocationCustomFile!!)
381 | }
382 | else -> return
383 | }
384 | }
385 |
386 | /**
387 | * This method will do the backup action
388 | *
389 | * @param destination File
390 | */
391 | private fun doBackup(destination: File) {
392 | // Close the database
393 | roomDatabase!!.close()
394 | roomDatabase = null
395 | if (backupIsEncrypted) {
396 | val encryptedBytes = encryptBackup() ?: return
397 | val bos = BufferedOutputStream(FileOutputStream(destination, false))
398 | bos.write(encryptedBytes)
399 | bos.flush()
400 | bos.close()
401 | } else {
402 | // Copy current database to save location (/files dir)
403 | copy(DATABASE_FILE, destination)
404 | }
405 |
406 | // If maxFileCount is set and is reached, delete oldest file
407 | if (maxFileCount != null) {
408 | val deleted = deleteOldBackup()
409 | if (!deleted) return
410 | }
411 |
412 | if (enableLogDebug)
413 | Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination")
414 | onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
415 | }
416 |
417 | /**
418 | * This method will do the backup action
419 | *
420 | * @param destination OutputStream
421 | */
422 | private fun doBackup(destination: OutputStream) {
423 | // Close the database
424 | roomDatabase!!.close()
425 | roomDatabase = null
426 |
427 | val bos = BufferedOutputStream(destination)
428 | if (backupIsEncrypted) {
429 | val encryptedBytes = encryptBackup() ?: return
430 | bos.write(encryptedBytes)
431 | } else {
432 | // Copy current database to save location (/files dir)
433 | val inputStream = DATABASE_FILE.inputStream().buffered()
434 | inputStream.copyTo(bos)
435 | inputStream.close()
436 | }
437 | bos.flush()
438 | bos.close()
439 |
440 | // If maxFileCount is set and is reached, delete oldest file
441 | if (maxFileCount != null) {
442 | val deleted = deleteOldBackup()
443 | if (!deleted) return
444 | }
445 | if (enableLogDebug)
446 | Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination")
447 | onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS)
448 | }
449 |
450 | /**
451 | * Encrypts the current Database and return it's content as ByteArray. The original Database is
452 | * not encrypted only a current copy of this database
453 | *
454 | * @return encrypted backup as ByteArray
455 | */
456 | private fun encryptBackup(): ByteArray? {
457 | try {
458 | // Copy database you want to backup to temp directory
459 | copy(DATABASE_FILE, TEMP_BACKUP_FILE)
460 |
461 | // encrypt temp file, and save it to backup location
462 | val encryptDecryptBackup = AESEncryptionHelper()
463 | val fileData = encryptDecryptBackup.readFile(TEMP_BACKUP_FILE)
464 |
465 | val aesEncryptionManager = AESEncryptionManager()
466 | val encryptedBytes =
467 | aesEncryptionManager.encryptData(sharedPreferences, encryptPassword, fileData)
468 |
469 | // Delete temp file
470 | TEMP_BACKUP_FILE.delete()
471 |
472 | return encryptedBytes
473 | } catch (e: Exception) {
474 | if (enableLogDebug) Log.d(TAG, "error during encryption: ${e.message}")
475 | onCompleteListener?.onComplete(
476 | false,
477 | "error during encryption",
478 | OnCompleteListener.EXIT_CODE_ERROR_ENCRYPTION_ERROR
479 | )
480 | return null
481 | }
482 | }
483 |
484 | /**
485 | * Start Restore process, and set onComplete Listener to success, if no error occurred, else
486 | * onComplete Listener success is false and error message is passed
487 | *
488 | * if internal or external storage is selected, this function shows a list of all available
489 | * backup files in a MaterialAlertDialog and calls [restoreSelectedInternalExternalFile] to
490 | * restore selected file
491 | *
492 | * if custom storage ist selected, the [openBackupfileChooser] will be launched
493 | */
494 | fun restore() {
495 | if (enableLogDebug) Log.d(TAG, "Starting Restore ...")
496 | val success = initRoomBackup()
497 | if (!success) return
498 |
499 | // Needed for storage permissions request
500 | currentProcess = PROCESS_RESTORE
501 |
502 | // Path of Backup Directory
503 | val backupDirectory: File
504 |
505 | when (backupLocation) {
506 | BACKUP_FILE_LOCATION_INTERNAL -> {
507 | backupDirectory = INTERNAL_BACKUP_PATH
508 | }
509 | BACKUP_FILE_LOCATION_EXTERNAL -> {
510 | backupDirectory = File("$EXTERNAL_BACKUP_PATH/")
511 | }
512 | BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> {
513 | permissionRequestLauncher.launch(arrayOf())
514 | return
515 | }
516 | BACKUP_FILE_LOCATION_CUSTOM_FILE -> {
517 | Log.d(
518 | TAG,
519 | "backupLocationCustomFile!!.exists()? : ${backupLocationCustomFile!!.exists()}"
520 | )
521 | doRestore(backupLocationCustomFile!!)
522 | return
523 | }
524 | else -> return
525 | }
526 |
527 | // All Files in an Array of type File
528 | val arrayOfFiles = backupDirectory.listFiles()
529 |
530 | // If array is null or empty show "error" and return
531 | if (arrayOfFiles.isNullOrEmpty()) {
532 | if (enableLogDebug) Log.d(TAG, "No backups available to restore")
533 | onCompleteListener?.onComplete(
534 | false,
535 | "No backups available",
536 | OnCompleteListener.EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE
537 | )
538 | Toast.makeText(context, "No backups available to restore", Toast.LENGTH_SHORT).show()
539 | return
540 | }
541 |
542 | // Sort Array: lastModified
543 | Arrays.sort(arrayOfFiles, LastModifiedFileComparator.LASTMODIFIED_COMPARATOR)
544 |
545 | // New empty MutableList of String
546 | val mutableListOfFilesAsString = mutableListOf