From a45619594c5973e1eb49e65e767ec110dfdd8c84 Mon Sep 17 00:00:00 2001 From: Matthias Emde Date: Sun, 14 Jul 2024 19:37:12 +0200 Subject: [PATCH] fix: Add validation and restoration to database import (#100) Move export/import functionality out of application class and distribute it appropriately into Activity and Database. Add validation for the imported database and revert to a temporary backup if anything fails. Fixes #13. --- app/build.gradle.kts | 3 +- app/src/main/java/app/musikus/Application.kt | 18 +- .../app/musikus/database/MusikusDatabase.kt | 54 +++++- .../app/musikus/datastore/UserPreferences.kt | 2 + .../main/java/app/musikus/di/MainModule.kt | 5 +- .../main/java/app/musikus/ui/MainActivity.kt | 181 ++++++++++++------ .../ui/settings/backup/BackupScreen.kt | 9 +- 7 files changed, 182 insertions(+), 90 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 663f0f6bd..7054507d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,7 +47,8 @@ android { keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") } - } catch (_: Exception) { + } catch (e: Exception) { + logger.warn("No signing configuration found, using debug key (message: ${e.message})") } } diff --git a/app/src/main/java/app/musikus/Application.kt b/app/src/main/java/app/musikus/Application.kt index bb6bb2d74..531b07c76 100644 --- a/app/src/main/java/app/musikus/Application.kt +++ b/app/src/main/java/app/musikus/Application.kt @@ -13,7 +13,6 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build -import androidx.activity.result.ActivityResultLauncher import dagger.hilt.android.HiltAndroidApp import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -83,14 +82,9 @@ class Musikus : Application() { IO_EXECUTOR.execute(f) } - var exportLauncher: ActivityResultLauncher? = null - var importLauncher: ActivityResultLauncher>? = null - var noSessionsYet = true var serviceIsRunning = false - const val USER_PREFERENCES_NAME = "user_preferences" - fun getRandomQuote(context: Context) : CharSequence { return context.resources.getTextArray(R.array.quotes).random() } @@ -98,15 +92,5 @@ class Musikus : Application() { fun dp(context: Context, dp: Int): Float { return context.resources.displayMetrics.density * dp } - - - fun importDatabase() { - importLauncher?.launch(arrayOf("*/*")) - } - - - fun exportDatabase() { - exportLauncher?.launch("musikus_backup") - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/musikus/database/MusikusDatabase.kt b/app/src/main/java/app/musikus/database/MusikusDatabase.kt index 7c0decb88..22e82a4f9 100644 --- a/app/src/main/java/app/musikus/database/MusikusDatabase.kt +++ b/app/src/main/java/app/musikus/database/MusikusDatabase.kt @@ -3,17 +3,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * - * Copyright (c) 2022-2024 Matthias Emde - * - * Parts of this software are licensed under the MIT license - * - * Copyright (c) 2022, Javier Carbone, author Matthias Emde - * Additions and modifications, author Michael Prommersberger + * Copyright (c) 2022-2024 Matthias Emde, Michael Prommersberger */ package app.musikus.database -import android.app.Application import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase @@ -46,7 +40,11 @@ import app.musikus.database.entities.SessionModel import app.musikus.utils.IdProvider import app.musikus.utils.TimeProvider import app.musikus.utils.prepopulateDatabase +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.InputStream +import java.io.OutputStream import java.nio.ByteBuffer import java.time.Instant import java.time.ZoneId @@ -55,6 +53,8 @@ import java.time.format.DateTimeFormatter import java.util.UUID import javax.inject.Provider +const val MIME_TYPE_DATABASE = "application/octet-stream" + @Database( version = 4, entities = [ @@ -92,6 +92,46 @@ abstract class MusikusDatabase : RoomDatabase() { lateinit var timeProvider: TimeProvider lateinit var idProvider: IdProvider + lateinit var databaseFile: File + + suspend fun validate(): Boolean { + return try { + val isDatabaseEmpty = listOf( + libraryItemDao.getAllAsFlow().first(), + libraryFolderDao.getAllAsFlow().first(), + goalDescriptionDao.getAllAsFlow().first(), + goalInstanceDao.getAllAsFlow().first(), + sessionDao.getAllAsFlow().first(), + sectionDao.getAllAsFlow().first() + ).all { + it.isEmpty() + } + !isDatabaseEmpty + } catch (e: Exception) { + Log.e("Database", "Validation failed: ${e.javaClass.simpleName}: ${e.message}") + false + } + } + + /** + * ---------------- Datbase Export/Import ---------------- + */ + + fun export(outputStream: OutputStream) { + // close the database to collect all logs + close() + + // copy database file to output stream + databaseFile.inputStream().copyTo(outputStream) + } + + fun import(inputStream: InputStream) { + // delete old database + databaseFile.delete() + + // copy new database from input stream + inputStream.copyTo(databaseFile.outputStream()) + } companion object { const val DATABASE_NAME = "musikus-database" diff --git a/app/src/main/java/app/musikus/datastore/UserPreferences.kt b/app/src/main/java/app/musikus/datastore/UserPreferences.kt index c9ae9ce42..9d368f5bf 100644 --- a/app/src/main/java/app/musikus/datastore/UserPreferences.kt +++ b/app/src/main/java/app/musikus/datastore/UserPreferences.kt @@ -14,6 +14,8 @@ import app.musikus.utils.LibraryFolderSortMode import app.musikus.utils.LibraryItemSortMode import app.musikus.utils.SortDirection +const val USER_PREFERENCES_NAME = "user_preferences" + interface EnumWithLabel { val label: String } diff --git a/app/src/main/java/app/musikus/di/MainModule.kt b/app/src/main/java/app/musikus/di/MainModule.kt index 4c77fc56a..9473568f1 100644 --- a/app/src/main/java/app/musikus/di/MainModule.kt +++ b/app/src/main/java/app/musikus/di/MainModule.kt @@ -15,8 +15,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStoreFile -import app.musikus.Musikus import app.musikus.database.MusikusDatabase +import app.musikus.datastore.USER_PREFERENCES_NAME import app.musikus.utils.IdProvider import app.musikus.utils.IdProviderImpl import app.musikus.utils.TimeProvider @@ -58,7 +58,7 @@ object MainModule { emptyPreferences() }, scope = CoroutineScope(IO + SupervisorJob()), - produceFile = { app.preferencesDataStoreFile(Musikus.USER_PREFERENCES_NAME) } + produceFile = { app.preferencesDataStoreFile(USER_PREFERENCES_NAME) } ) } @@ -82,6 +82,7 @@ object MainModule { ).apply { this.timeProvider = timeProvider this.idProvider = idProvider + this.databaseFile = app.getDatabasePath(MusikusDatabase.DATABASE_NAME) } } } \ No newline at end of file diff --git a/app/src/main/java/app/musikus/ui/MainActivity.kt b/app/src/main/java/app/musikus/ui/MainActivity.kt index b70fb0591..f941aef4d 100644 --- a/app/src/main/java/app/musikus/ui/MainActivity.kt +++ b/app/src/main/java/app/musikus/ui/MainActivity.kt @@ -10,7 +10,6 @@ package app.musikus.ui import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment @@ -18,8 +17,10 @@ import android.provider.DocumentsContract import android.util.Log import android.widget.Toast import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import app.musikus.Musikus +import androidx.lifecycle.lifecycleScope +import app.musikus.database.MIME_TYPE_DATABASE import app.musikus.database.MusikusDatabase import app.musikus.services.ActiveSessionServiceActions import app.musikus.services.SessionService @@ -28,9 +29,11 @@ import app.musikus.utils.PermissionChecker import app.musikus.utils.PermissionCheckerActivity import app.musikus.utils.TimeProvider import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import java.io.File import javax.inject.Inject -import javax.inject.Provider +const val DATABASE_IMPORT_BACKUP_FILE = "database_import_backup.tmp" @AndroidEntryPoint class MainActivity : PermissionCheckerActivity() { @@ -41,74 +44,25 @@ class MainActivity : PermissionCheckerActivity() { @Inject lateinit var timeProvider: TimeProvider - @Inject - lateinit var databaseProvider: Provider - @Inject lateinit var activeSessionUseCases: ActiveSessionUseCases - private val database: MusikusDatabase by lazy { databaseProvider.get() } + @Inject + lateinit var database: MusikusDatabase + private lateinit var exportLauncher: ActivityResultLauncher + private lateinit var importLauncher: ActivityResultLauncher> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Musikus.exportLauncher = registerForActivityResult( - ExportDatabaseContract() - ) { exportDatabaseCallback(applicationContext, it) } - - Musikus.importLauncher = registerForActivityResult( - ImportDatabaseContract() - ) { importDatabaseCallback(applicationContext, it) } + initializeExportImportLaunchers() setContent { MusikusApp(timeProvider) } } - private fun importDatabaseCallback(context: Context, uri: Uri?) { - uri?.let { - // close the database to collect all logs - database.close() - val databaseFile = context.getDatabasePath(MusikusDatabase.DATABASE_NAME) - // delete old database - databaseFile.delete() - // copy new database - databaseFile.outputStream().let { outputStream -> - context.contentResolver.openInputStream(it)?.let { inputStream -> - inputStream.copyTo(outputStream) - inputStream.close() - } - outputStream.close() - Toast.makeText(context, "Backup loaded successfully, restart your app to complete the process.", Toast.LENGTH_LONG).show() - } - } - - // open database again -// openDatabase(context) - } - - private fun exportDatabaseCallback(context: Context, uri: Uri?) { - uri?.let { - - // close the database to collect all logs - database.close() - val databaseFile = context.getDatabasePath(MusikusDatabase.DATABASE_NAME) - // copy database - context.contentResolver.openOutputStream(it)?.let { outputStream -> - databaseFile.inputStream().let { inputStream -> - inputStream.copyTo(outputStream) - inputStream.close() - } - outputStream.close() - - Toast.makeText(context, "Backup successful", Toast.LENGTH_LONG).show() - } - - // open database again -// openDatabase(context) - } - } override fun onResume() { super.onResume() @@ -139,13 +93,116 @@ class MainActivity : PermissionCheckerActivity() { } } + + private fun initializeExportImportLaunchers() { + + exportLauncher = registerForActivityResult( + ExportDatabaseContract() + ) { uri -> + if (uri == null) { + Toast.makeText(this, "No file selected", Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + + contentResolver.openOutputStream(uri)?.let { outputStream -> + database.export(outputStream) + outputStream.close() + } + + Toast.makeText(this, "Backup successful", Toast.LENGTH_LONG).show() + + // Restart the app to allow dagger hilt to reopen the database + triggerRestart() + } + + importLauncher = registerForActivityResult( + ImportDatabaseContract() + ) { uri -> + if (uri == null) { + Toast.makeText(this, "No file selected", Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + + // Close the database to collect all logs + database.close() + + // Create a backup of the current database before loading the new one + val backupFile = File(filesDir, DATABASE_IMPORT_BACKUP_FILE) + database.databaseFile.copyTo(backupFile, overwrite = true) + + var message = "Error loading backup. Aborting..." + + lifecycleScope.launch { + try { + // Load the new database + contentResolver.openInputStream(uri)?.let { inputStream -> + database.import(inputStream) + inputStream.close() + } + + // Open the new database locally to check if the import was successful + val newDatabase = MusikusDatabase.buildDatabase(context = this@MainActivity) + + val isNewDatabaseValid = newDatabase.validate() + + newDatabase.close() + + if (isNewDatabaseValid) { + message = "Backup successfully loaded" + } else { + throw Exception("Invalid database") + } + } catch (e: Exception) { + // If an error occurred, restore the backup + Log.e("MainActivity", "Error loading backup: ${e.message}") + backupFile.copyTo(database.databaseFile, overwrite = true) + } finally { + // Finally, delete the backup file + backupFile.delete() + + // Show a toast containing either the success or error message + Toast.makeText( + this@MainActivity, + message, + Toast.LENGTH_LONG + ).show() + + // Restart the app to allow dagger hilt to load the new database + triggerRestart() + } + } + } + } + + fun exportDatabase() { + exportLauncher.launch("musikus_backup") + } + + fun importDatabase() { + importLauncher.launch(arrayOf( + MIME_TYPE_DATABASE, + "application/vnd.sqlite3", + "application/x-sqlite3", + )) + } + + // source: https://gist.github.com/easterapps/7127ce0749cfce2edf083e55b6eecec5 + private fun triggerRestart() { + val intent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + startActivity(intent) + finish() + kotlin.system.exitProcess(0) + } } /** * Contracts for exporting/importing the database */ -class ExportDatabaseContract : ActivityResultContracts.CreateDocument("*/*") { +private class ExportDatabaseContract : ActivityResultContracts.CreateDocument(MIME_TYPE_DATABASE) { override fun createIntent(context: Context, input: String) = super.createIntent(context, input).apply { addCategory(Intent.CATEGORY_OPENABLE) @@ -155,9 +212,13 @@ class ExportDatabaseContract : ActivityResultContracts.CreateDocument("*/*") { } } -class ImportDatabaseContract : ActivityResultContracts.OpenDocument() { +private class ImportDatabaseContract : ActivityResultContracts.OpenDocument() { override fun createIntent(context: Context, input: Array) = super.createIntent(context, input).apply { - + type = MIME_TYPE_DATABASE + addCategory(Intent.CATEGORY_OPENABLE) + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.DIRECTORY_DOWNLOADS) + } } } \ No newline at end of file diff --git a/app/src/main/java/app/musikus/ui/settings/backup/BackupScreen.kt b/app/src/main/java/app/musikus/ui/settings/backup/BackupScreen.kt index f722dc838..571406c62 100644 --- a/app/src/main/java/app/musikus/ui/settings/backup/BackupScreen.kt +++ b/app/src/main/java/app/musikus/ui/settings/backup/BackupScreen.kt @@ -30,8 +30,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import app.musikus.Musikus +import app.musikus.ui.MainActivity import app.musikus.ui.theme.spacing @OptIn(ExperimentalMaterial3Api::class) @@ -67,11 +68,13 @@ fun BackupScreen( Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium)) + val activity: MainActivity = LocalContext.current as MainActivity + Button( modifier = Modifier .fillMaxWidth() .padding(horizontal = MaterialTheme.spacing.extraLarge), - onClick = { Musikus.exportDatabase() }, + onClick = { activity.exportDatabase() }, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -91,7 +94,7 @@ fun BackupScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = MaterialTheme.spacing.extraLarge), - onClick = { Musikus.importDatabase() }, + onClick = { activity.importDatabase() }, ) { Row( verticalAlignment = Alignment.CenterVertically,