Skip to content

Commit

Permalink
Adding backup
Browse files Browse the repository at this point in the history
Signed-off-by: Arnau Mora Gras <[email protected]>
  • Loading branch information
ArnyminerZ committed Apr 8, 2024
1 parent a77733b commit ec3bd54
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 8 deletions.
61 changes: 61 additions & 0 deletions app/src/androidTest/java/at/bitfire/icsdroid/BackupTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
78 changes: 70 additions & 8 deletions app/src/main/java/at/bitfire/icsdroid/db/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@
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
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<AppDatabase> =
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<InputStream>): 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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<SubscriptionsModel>()
Expand All @@ -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?) {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 = {
Expand All @@ -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")
}
)
}
}


Expand Down Expand Up @@ -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<Application>().contentResolver.openOutputStream(uri)?.use { output ->
output.write(data)
}
}
}

fun importBackup(uri: Uri) = viewModelScope.async {
withContext(Dispatchers.IO) {
val data = getApplication<Application>().contentResolver.openInputStream(uri)?.readBytes()
if (data != null) {
AppDatabase.recreateFromFile(getApplication()) { data.inputStream() }
}
}
}

}

}
10 changes: 10 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<string name="calendar_list_battery_whitelist_title">Battery optimization</string>
<string name="calendar_list_battery_whitelist_text">Battery optimizations may prevent sync intervals shorter than a day. Set %s to \"Not optimized\".</string>
<string name="calendar_list_battery_whitelist_open_settings">Open settings</string>
<string name="calendar_list_backup">Backup</string>
<string name="calendar_list_backup_import">Import Backup</string>
<string name="calendar_list_backup_export">Export Backup</string>
<string name="calendar_list_autorevoke_permissions_title">Auto-Revoke Permissions</string>
<string name="calendar_list_autorevoke_permissions_text">Android revokes permissions of rarely opened apps. Please disable permission revokes for %s.</string>
<string name="calendar_list_autorevoke_permissions_instruction">Click Permissions > uncheck \"Remove permissions if app isn\'t used\"</string>
Expand Down Expand Up @@ -136,4 +139,11 @@ along with this program. If not, see <a href="https://www.gnu.org/licenses/">ht
<string name="donate_now">Show donation page</string>
<string name="donate_later">Maybe later</string>

<string name="backup_export_error">Could not export backup!</string>
<string name="backup_export_success">Backup exported!</string>
<string name="backup_export_toast">Exporting backup…</string>
<string name="backup_import_error">Could not import backup!</string>
<string name="backup_import_success">Backup imported, restarting app…</string>
<string name="backup_import_toast">Importing backup…</string>

</resources>

0 comments on commit ec3bd54

Please sign in to comment.