From ec3bd545c47c483f3ff7103aefb02ef9e82e65b4 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 8 Apr 2024 19:23:51 +0200 Subject: [PATCH] Adding backup Signed-off-by: Arnau Mora Gras --- .../java/at/bitfire/icsdroid/BackupTest.kt | 61 ++++++++++++ .../at/bitfire/icsdroid/db/AppDatabase.kt | 78 +++++++++++++-- .../icsdroid/ui/views/CalendarListActivity.kt | 98 +++++++++++++++++++ app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/java/at/bitfire/icsdroid/BackupTest.kt diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/BackupTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/BackupTest.kt new file mode 100644 index 00000000..16c15f1f --- /dev/null +++ b/app/src/androidTest/java/at/bitfire/icsdroid/BackupTest.kt @@ -0,0 +1,61 @@ +package at.bitfire.icsdroid + +import android.content.Context +import android.net.Uri +import androidx.test.platform.app.InstrumentationRegistry +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Subscription +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class BackupTest { + companion object { + val appContext: Context by lazy { InstrumentationRegistry.getInstrumentation().targetContext } + + private val subscription1 = + Subscription(1, null, Uri.parse("https://example.com/calendar1.ics"), null, "Example 1") + private val subscription2 = + Subscription(2, null, Uri.parse("https://example.com/calendar2.ics"), null, "Example 2") + } + + @Before + fun prepare_database() { + // Change the database name + AppDatabase.databaseName = "icsx5-test" + + // Create the database, and add some sample subscriptions + val database = AppDatabase.getInstance(appContext) + + val subscriptionsDao = database.subscriptionsDao() + subscriptionsDao.add(subscription1) + subscriptionsDao.add(subscription2) + } + + @Test + fun test_import_export() { + // Get the data stored in the db + // Note: This closes the database + val data = runBlocking { AppDatabase.readAllData(appContext) } + + // Clear the data to make sure the test is not confused + AppDatabase.getInstance(appContext).clearAllTables() + + // Import the data + runBlocking { AppDatabase.recreateFromFile(appContext) { data.inputStream() } } + + // Assert + val subscriptionsDao = AppDatabase.getInstance(appContext).subscriptionsDao() + val subscriptions = subscriptionsDao.getAll() + assertEquals(2, subscriptions.size) + assertEquals(subscription1, subscriptions[0]) + assertEquals(subscription2, subscriptions[1]) + } + + @After + fun dispose_database() { + AppDatabase.getInstance(appContext).clearAllTables() + } +} diff --git a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt index e78114e9..3ac60007 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt @@ -5,6 +5,7 @@ package at.bitfire.icsdroid.db import android.content.Context +import android.util.Log import androidx.annotation.VisibleForTesting import androidx.room.AutoMigration import androidx.room.Database @@ -12,12 +13,16 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.sqlite.db.SupportSQLiteDatabase +import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.SyncWorker import at.bitfire.icsdroid.db.AppDatabase.Companion.getInstance import at.bitfire.icsdroid.db.dao.CredentialsDao import at.bitfire.icsdroid.db.dao.SubscriptionsDao import at.bitfire.icsdroid.db.entity.Credential import at.bitfire.icsdroid.db.entity.Subscription +import java.io.InputStream +import java.util.concurrent.Callable +import kotlinx.coroutines.delay /** * The database for storing all the ICSx5 subscriptions and other data. Use [getInstance] for getting access to the database. @@ -47,6 +52,9 @@ abstract class AppDatabase : RoomDatabase() { @Volatile private var instance: AppDatabase? = null + @set:VisibleForTesting + var databaseName = "icsx5" + /** * This function is only intended to be used by tests, use [getInstance], it initializes * the instance automatically. @@ -74,18 +82,72 @@ abstract class AppDatabase : RoomDatabase() { } // create a new instance and save it - val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "icsx5") - .fallbackToDestructiveMigration() - .addCallback(object : Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - SyncWorker.run(context, onlyMigrate = true) - } - }) - .build() + val db = databaseBuilder(context).build() instance = db return db } } + + /** + * Creates a new builder for the database. This is used by tests to mock the function, and + * create in-memory databases. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun databaseBuilder(context: Context): Builder = + Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, databaseName) + .fallbackToDestructiveMigration() + .addCallback(object : Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + SyncWorker.run(context, onlyMigrate = true) + } + }) + + /** Reads all the data stored in the database */ + suspend fun readAllData(context: Context): ByteArray { + // Wait until current transaction is finished + if (instance != null) while (instance?.inTransaction() == true) { delay(1) } + // Close access to the database so no writes are performed + instance?.close() + + // Get access to the database file + val file = context.getDatabasePath(databaseName) + // Read the contents + val bytes = file.readBytes() + + // Dispose the instance + instance = null + + // Open the database again + getInstance(context) + + // Return the read bytes + return bytes + } + + /** Clears the current database, and creates a new one from [stream] */ + suspend fun recreateFromFile(context: Context, stream: Callable): AppDatabase { + // Wait until current transaction is finished + if (instance != null) while (instance?.inTransaction() == true) { delay(1) } + // Clear all the data existing if any + Log.d(Constants.TAG, "Clearing all tables in the database...") + instance?.clearAllTables() + instance?.close() + instance = null + + Log.d(Constants.TAG, "Removing database file...") + val file = context.getDatabasePath(databaseName) + if (file.exists()) file.delete() + + Log.d(Constants.TAG, "Creating a new database from the data imported...") + val newDatabase = databaseBuilder(context) + .createFromInputStream(stream) + .build() + + val subscriptions = newDatabase.subscriptionsDao().getAll() + Log.i(Constants.TAG, "Successfully imported ${subscriptions.size} subscriptions.") + + return newDatabase.also { instance = it } + } } abstract fun subscriptionsDao(): SubscriptionsDao diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt index 009ef36e..03e1d812 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt @@ -8,11 +8,14 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Context import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager +import android.util.Log import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ExperimentalFoundationApi @@ -63,6 +66,7 @@ import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import at.bitfire.icsdroid.AppAccount import at.bitfire.icsdroid.BuildConfig +import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.PermissionUtils import at.bitfire.icsdroid.R import at.bitfire.icsdroid.Settings @@ -78,8 +82,11 @@ import at.bitfire.icsdroid.ui.partials.SyncIntervalDialog import at.bitfire.icsdroid.ui.theme.setContentThemed import java.util.ServiceLoader import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) class CalendarListActivity: AppCompatActivity() { @@ -91,6 +98,9 @@ class CalendarListActivity: AppCompatActivity() { const val EXTRA_REQUEST_CALENDAR_PERMISSION = "permission" const val PRIVACY_POLICY_URL = "https://icsx5.bitfire.at/privacy/" + + /** The MIME type for SQLite files */ + const val MIME_SQLITE = "application/vnd.sqlite3" } private val model by viewModels() @@ -102,6 +112,48 @@ class CalendarListActivity: AppCompatActivity() { /** Stores the post notification permission request for asking for permissions during runtime */ private lateinit var requestNotificationPermission: () -> Unit + @OptIn(ExperimentalCoroutinesApi::class) + private val saveBackupRequestLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument(MIME_SQLITE) + ) { uri -> + Toast.makeText(this, R.string.backup_export_toast, Toast.LENGTH_SHORT).show() + val job = model.exportBackup(uri ?: return@registerForActivityResult) + job.invokeOnCompletion { + val error = job.getCompletionExceptionOrNull() + if (error != null) { + Log.e(Constants.TAG, "Could not export backup.", error) + Toast.makeText(this, R.string.backup_export_error, Toast.LENGTH_LONG).show() + } else { + Toast.makeText(this, R.string.backup_export_success, Toast.LENGTH_LONG).show() + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val loadBackupRequestLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + Toast.makeText(this, R.string.backup_import_toast, Toast.LENGTH_SHORT).show() + val job = model.importBackup(uri ?: return@registerForActivityResult) + job.invokeOnCompletion { + val error = job.getCompletionExceptionOrNull() + if (error != null) { + Log.e(Constants.TAG, "Could not import backup.", error) + Toast.makeText(this, R.string.backup_import_error, Toast.LENGTH_LONG).show() + } else { + Toast.makeText(this, R.string.backup_import_success, Toast.LENGTH_LONG).show() + + // Restart the application + val intent = Intent(this, CalendarListActivity::class.java).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + finish() + Runtime.getRuntime().exit(0) + } + } + } + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -323,6 +375,7 @@ class CalendarListActivity: AppCompatActivity() { val context = LocalContext.current var showMenu by remember { mutableStateOf(false) } + var showBackupMenu by remember { mutableStateOf(false) } IconButton(onClick = { showMenu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.action_more)) @@ -375,6 +428,13 @@ class CalendarListActivity: AppCompatActivity() { onToggleDarkMode() } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_backup)) }, + onClick = { + showMenu = false + showBackupMenu = true + } + ) DropdownMenuItem( text = { Text(stringResource(R.string.calendar_list_privacy_policy)) }, onClick = { @@ -390,6 +450,26 @@ class CalendarListActivity: AppCompatActivity() { } ) } + + DropdownMenu( + expanded = showBackupMenu, + onDismissRequest = { showBackupMenu = false; showMenu = true } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_backup_import)) }, + onClick = { + showBackupMenu = false + loadBackupRequestLauncher.launch(arrayOf("*/*")) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_backup_export)) }, + onClick = { + showBackupMenu = false + saveBackupRequestLauncher.launch("icsx5.sqlite") + } + ) + } } @@ -458,6 +538,24 @@ class CalendarListActivity: AppCompatActivity() { askForAutoRevoke.postValue(!isAutoRevokeWhitelisted) } + fun exportBackup(uri: Uri) = viewModelScope.async { + withContext(Dispatchers.IO) { + val data = AppDatabase.readAllData(getApplication()) + getApplication().contentResolver.openOutputStream(uri)?.use { output -> + output.write(data) + } + } + } + + fun importBackup(uri: Uri) = viewModelScope.async { + withContext(Dispatchers.IO) { + val data = getApplication().contentResolver.openInputStream(uri)?.readBytes() + if (data != null) { + AppDatabase.recreateFromFile(getApplication()) { data.inputStream() } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42659d99..03b94463 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,9 @@ Battery optimization Battery optimizations may prevent sync intervals shorter than a day. Set %s to \"Not optimized\". Open settings + Backup + Import Backup + Export Backup Auto-Revoke Permissions Android revokes permissions of rarely opened apps. Please disable permission revokes for %s. Click Permissions > uncheck \"Remove permissions if app isn\'t used\" @@ -136,4 +139,11 @@ along with this program. If not, see ht Show donation page Maybe later + Could not export backup! + Backup exported! + Exporting backup… + Could not import backup! + Backup imported, restarting app… + Importing backup… +