diff --git a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/di/SettingsModule.kt b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/di/SettingsModule.kt index e0930944..58be59bc 100644 --- a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/di/SettingsModule.kt +++ b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/di/SettingsModule.kt @@ -17,6 +17,8 @@ import br.com.sailboat.todozy.feature.settings.impl.domain.usecase.SetAlarmVibra import br.com.sailboat.todozy.feature.settings.impl.domain.usecase.SetAlarmVibrateSettingUseCaseImpl import br.com.sailboat.todozy.feature.settings.impl.presentation.navigator.SettingsNavigatorImpl import br.com.sailboat.todozy.feature.settings.impl.presentation.viewmodel.SettingsViewModel +import br.com.sailboat.todozy.utility.android.sqlite.DatabaseJsonBackupService +import br.com.sailboat.todozy.utility.kotlin.LogService import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.module.Module import org.koin.dsl.module @@ -29,6 +31,8 @@ private val presentation = setAlarmSoundSettingUseCase = get(), getAlarmVibrateSettingUseCase = get(), setAlarmVibrateSettingUseCase = get(), + databaseJsonBackupService = get(), + logService = get(), ) } factory { SettingsNavigatorImpl() } diff --git a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/SettingsFragment.kt b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/SettingsFragment.kt index 937f4675..b1464b31 100644 --- a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/SettingsFragment.kt +++ b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/SettingsFragment.kt @@ -1,10 +1,15 @@ package br.com.sailboat.todozy.feature.settings.impl.presentation +import android.app.Activity import android.media.RingtoneManager +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import br.com.sailboat.todozy.feature.navigation.android.AboutNavigator import br.com.sailboat.todozy.feature.settings.impl.databinding.FrgSettingsBinding @@ -22,6 +27,18 @@ internal class SettingsFragment : Fragment() { private lateinit var binding: FrgSettingsBinding + private val exportDatabaseLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + uri?.run { + viewModel.dispatchViewIntent(SettingsViewIntent.OnSelectExportDatabaseUri(uri)) + } + } + + private val importDatabaseLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.run { showConfirmImportDatabaseDialog(uri) } + } + private val pickRingtoneLauncher = registerForActivityResult(PickRingtoneContract()) { uri -> uri?.run { @@ -53,6 +70,7 @@ internal class SettingsFragment : Fragment() { initAbout() initCheckBoxVibrate() initToneVibrate() + initBackupViews() } private fun observeViewModel() { @@ -75,6 +93,16 @@ internal class SettingsFragment : Fragment() { when (action) { is SettingsViewAction.NavigateToAlarmToneSelector -> navigateToAlarmToneSelector(action) is SettingsViewAction.NavigateToAbout -> navigateToAbout() + is SettingsViewAction.CreateDatabaseBackupDocument -> exportDatabaseLauncher.launch(action.fileName) + is SettingsViewAction.OpenDatabaseBackupDocument -> importDatabaseLauncher.launch( + arrayOf( + "application/json", + "text/*", + ), + ) + is SettingsViewAction.ShowDatabaseExportSuccess -> showDatabaseExportSuccess() + is SettingsViewAction.ShowDatabaseImportSuccess -> showDatabaseImportSuccess() + is SettingsViewAction.ShowDatabaseBackupError -> showDatabaseBackupError() } } } @@ -112,4 +140,37 @@ internal class SettingsFragment : Fragment() { private fun initToneVibrate() = with(binding) { flSettingsVibrate.setSafeClickListener { cbSettingsVibrate.performClick() } } + + private fun initBackupViews() = with(binding) { + tvSettingsExportDatabase.setSafeClickListener { + viewModel.dispatchViewIntent(SettingsViewIntent.OnClickExportDatabase) + } + tvSettingsImportDatabase.setSafeClickListener { + viewModel.dispatchViewIntent(SettingsViewIntent.OnClickImportDatabase) + } + } + + private fun showConfirmImportDatabaseDialog(uri: Uri) { + AlertDialog.Builder(requireContext()) + .setTitle(UiR.string.are_you_sure) + .setMessage(getString(UiR.string.msg_confirm_database_import)) + .setPositiveButton(UiR.string.import_action) { _, _ -> + viewModel.dispatchViewIntent(SettingsViewIntent.OnConfirmImportDatabase(uri)) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showDatabaseExportSuccess() { + Toast.makeText(activity, UiR.string.msg_database_exported, Toast.LENGTH_SHORT).show() + } + + private fun showDatabaseImportSuccess() { + activity?.setResult(Activity.RESULT_OK) + Toast.makeText(activity, UiR.string.msg_database_imported, Toast.LENGTH_SHORT).show() + } + + private fun showDatabaseBackupError() { + Toast.makeText(activity, UiR.string.msg_error, Toast.LENGTH_SHORT).show() + } } diff --git a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewAction.kt b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewAction.kt index c610c927..e50f0951 100644 --- a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewAction.kt +++ b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewAction.kt @@ -5,4 +5,9 @@ import android.net.Uri internal sealed class SettingsViewAction { object NavigateToAbout : SettingsViewAction() data class NavigateToAlarmToneSelector(val uri: Uri?) : SettingsViewAction() + data class CreateDatabaseBackupDocument(val fileName: String) : SettingsViewAction() + object OpenDatabaseBackupDocument : SettingsViewAction() + object ShowDatabaseExportSuccess : SettingsViewAction() + object ShowDatabaseImportSuccess : SettingsViewAction() + object ShowDatabaseBackupError : SettingsViewAction() } diff --git a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewIntent.kt b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewIntent.kt index fc819c9f..f3cad766 100644 --- a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewIntent.kt +++ b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewIntent.kt @@ -6,6 +6,10 @@ internal sealed class SettingsViewIntent { object OnStart : SettingsViewIntent() object OnClickMenuAlarmTone : SettingsViewIntent() object OnClickAbout : SettingsViewIntent() + object OnClickExportDatabase : SettingsViewIntent() + object OnClickImportDatabase : SettingsViewIntent() data class OnClickVibrateAlarm(val vibrate: Boolean) : SettingsViewIntent() data class OnSelectAlarmTone(val uri: Uri) : SettingsViewIntent() + data class OnSelectExportDatabaseUri(val uri: Uri) : SettingsViewIntent() + data class OnConfirmImportDatabase(val uri: Uri) : SettingsViewIntent() } diff --git a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewModel.kt b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewModel.kt index a63e9dba..70c684e8 100644 --- a/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewModel.kt +++ b/feature/settings/impl/src/main/java/br/com/sailboat/todozy/feature/settings/impl/presentation/viewmodel/SettingsViewModel.kt @@ -5,8 +5,14 @@ import br.com.sailboat.todozy.feature.settings.android.domain.usecase.GetAlarmSo import br.com.sailboat.todozy.feature.settings.domain.usecase.GetAlarmVibrateSettingUseCase import br.com.sailboat.todozy.feature.settings.impl.domain.usecase.SetAlarmSoundSettingUseCase import br.com.sailboat.todozy.feature.settings.impl.domain.usecase.SetAlarmVibrateSettingUseCase +import br.com.sailboat.todozy.utility.android.sqlite.DatabaseJsonBackupService import br.com.sailboat.todozy.utility.android.viewmodel.BaseViewModel +import br.com.sailboat.todozy.utility.kotlin.LogService import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale internal class SettingsViewModel( override val viewState: SettingsViewState = SettingsViewState(), @@ -14,14 +20,20 @@ internal class SettingsViewModel( private val setAlarmSoundSettingUseCase: SetAlarmSoundSettingUseCase, private val getAlarmVibrateSettingUseCase: GetAlarmVibrateSettingUseCase, private val setAlarmVibrateSettingUseCase: SetAlarmVibrateSettingUseCase, + private val databaseJsonBackupService: DatabaseJsonBackupService, + private val logService: LogService, ) : BaseViewModel() { override fun dispatchViewIntent(viewIntent: SettingsViewIntent) { when (viewIntent) { is SettingsViewIntent.OnStart -> onStart() is SettingsViewIntent.OnClickMenuAlarmTone -> onClickMenuAlarmTone() is SettingsViewIntent.OnClickAbout -> onClickAbout() + is SettingsViewIntent.OnClickExportDatabase -> onClickExportDatabase() + is SettingsViewIntent.OnClickImportDatabase -> onClickImportDatabase() is SettingsViewIntent.OnClickVibrateAlarm -> onClickVibrateAlarm(viewIntent) is SettingsViewIntent.OnSelectAlarmTone -> onSelectAlarmTone(viewIntent) + is SettingsViewIntent.OnSelectExportDatabaseUri -> onSelectExportDatabaseUri(viewIntent) + is SettingsViewIntent.OnConfirmImportDatabase -> onConfirmImportDatabase(viewIntent) } } @@ -39,6 +51,21 @@ internal class SettingsViewModel( viewState.viewAction.value = SettingsViewAction.NavigateToAbout } + private fun onClickExportDatabase() { + val timestamp = + LocalDateTime + .now(ZoneId.systemDefault()) + .format(BACKUP_FILE_NAME_FORMATTER) + viewState.viewAction.value = + SettingsViewAction.CreateDatabaseBackupDocument( + "todozy-backup_$timestamp.json", + ) + } + + private fun onClickImportDatabase() { + viewState.viewAction.value = SettingsViewAction.OpenDatabaseBackupDocument + } + private fun onClickVibrateAlarm(viewIntent: SettingsViewIntent.OnClickVibrateAlarm) { viewModelScope.launch { viewState.vibrate.value = viewIntent.vibrate @@ -52,4 +79,36 @@ internal class SettingsViewModel( setAlarmSoundSettingUseCase(viewIntent.uri) } } + + private fun onSelectExportDatabaseUri(viewIntent: SettingsViewIntent.OnSelectExportDatabaseUri) { + viewModelScope.launch { + try { + databaseJsonBackupService.exportToJson(viewIntent.uri) + viewState.viewAction.value = SettingsViewAction.ShowDatabaseExportSuccess + } catch (e: Exception) { + logService.error(e) + viewState.viewAction.value = SettingsViewAction.ShowDatabaseBackupError + } + } + } + + private fun onConfirmImportDatabase(viewIntent: SettingsViewIntent.OnConfirmImportDatabase) { + viewModelScope.launch { + try { + databaseJsonBackupService.importFromJson(viewIntent.uri) + viewState.viewAction.value = SettingsViewAction.ShowDatabaseImportSuccess + } catch (e: Exception) { + logService.error(e) + viewState.viewAction.value = SettingsViewAction.ShowDatabaseBackupError + } + } + } + + private companion object { + private val BACKUP_FILE_NAME_FORMATTER = + DateTimeFormatter.ofPattern( + "yyyyMMdd_HHmmss", + Locale.US, + ) + } } diff --git a/feature/settings/impl/src/main/res/layout/frg_settings.xml b/feature/settings/impl/src/main/res/layout/frg_settings.xml index ec6c0386..64c5a3df 100644 --- a/feature/settings/impl/src/main/res/layout/frg_settings.xml +++ b/feature/settings/impl/src/main/res/layout/frg_settings.xml @@ -84,6 +84,40 @@ + + + + + + + + + + - \ No newline at end of file + diff --git a/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/database/backup/DatabaseJsonBackupServiceImpl.kt b/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/database/backup/DatabaseJsonBackupServiceImpl.kt new file mode 100644 index 00000000..b6393986 --- /dev/null +++ b/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/database/backup/DatabaseJsonBackupServiceImpl.kt @@ -0,0 +1,264 @@ +package br.com.sailboat.todozy.platform.impl.database.backup + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.util.Base64 +import br.com.sailboat.todozy.utility.android.sqlite.DatabaseJsonBackupService +import br.com.sailboat.todozy.utility.android.sqlite.DatabaseOpenHelperService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +private const val BACKUP_FORMAT = "todozy-db-backup" +private const val BACKUP_VERSION = 1 +private const val JSON_TYPE_KEY = "__type" +private const val JSON_TYPE_BLOB = "blob" +private const val JSON_BLOB_BASE64_KEY = "base64" + +internal class DatabaseJsonBackupServiceImpl( + private val context: Context, + private val databaseOpenHelperService: DatabaseOpenHelperService, +) : DatabaseJsonBackupService { + override suspend fun exportToJson(uri: Uri) = withContext(Dispatchers.IO) { + val database = databaseOpenHelperService.readable + + val tableNames = getUserTableNames(database) + val tablesJson = JSONObject() + + for (tableName in tableNames) { + val cursor = database.rawQuery("SELECT * FROM \"$tableName\"", null) + cursor.use { + tablesJson.put(tableName, cursorToJsonArray(cursor)) + } + } + + val root = + JSONObject().apply { + put("format", BACKUP_FORMAT) + put("version", BACKUP_VERSION) + put("databaseVersion", database.version) + put("exportedAtEpochMillis", System.currentTimeMillis()) + put("tables", tablesJson) + } + + val outputStream = + context.contentResolver.openOutputStream(uri) + ?: error("Could not open output stream for uri=$uri") + + outputStream.use { stream -> + stream.writer(Charsets.UTF_8).use { writer -> + writer.write(root.toString(2)) + } + } + } + + override suspend fun importFromJson(uri: Uri) = withContext(Dispatchers.IO) { + val inputStream = + context.contentResolver.openInputStream(uri) + ?: error("Could not open input stream for uri=$uri") + + val jsonString = inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val root = JSONObject(jsonString) + + val format = root.optString("format") + require(format == BACKUP_FORMAT) { "Invalid backup format: $format" } + + val version = root.optInt("version") + require(version == BACKUP_VERSION) { "Unsupported backup version: $version" } + + val tablesJson = root.getJSONObject("tables") + + val database = databaseOpenHelperService.writable + val tableNames = getUserTableNames(database) + val insertOrder = sortTablesByForeignKeys(database, tableNames) + val deleteOrder = insertOrder.asReversed() + + database.beginTransactionNonExclusive() + try { + for (tableName in deleteOrder) { + database.delete(tableName, null, null) + } + + for (tableName in insertOrder) { + val rows = tablesJson.optJSONArray(tableName) ?: continue + insertRows(database, tableName, rows) + } + + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } + + private fun getUserTableNames(database: SQLiteDatabase): List { + val cursor = + database.rawQuery( + """ + SELECT name FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'android_%' + AND name NOT LIKE 'sqlite_%' + ORDER BY name + """.trimIndent(), + null, + ) + + return cursor.use { + buildList { + while (cursor.moveToNext()) { + add(cursor.getString(0)) + } + } + } + } + + private fun sortTablesByForeignKeys( + database: SQLiteDatabase, + tableNames: List, + ): List { + val tableSet = tableNames.toSet() + val dependencies = + tableNames.associateWith { tableName -> + getForeignKeyDependencies(database, tableName).filter { it in tableSet }.toSet() + } + + return topologicalSort(dependencies) + } + + private fun getForeignKeyDependencies( + database: SQLiteDatabase, + tableName: String, + ): List { + val cursor = database.rawQuery("PRAGMA foreign_key_list(\"$tableName\")", null) + + return cursor.use { + val referencedTableIndex = cursor.getColumnIndex("table").takeIf { it >= 0 } ?: return emptyList() + buildList { + while (cursor.moveToNext()) { + add(cursor.getString(referencedTableIndex)) + } + } + } + } + + private fun topologicalSort(dependencies: Map>): List { + val pendingDependencies = dependencies.mapValues { it.value.toMutableSet() }.toMutableMap() + val resolved: MutableList = mutableListOf() + val ready = java.util.PriorityQueue() + + for ((table, deps) in pendingDependencies) { + if (deps.isEmpty()) { + ready.add(table) + } + } + + while (ready.isNotEmpty()) { + val table = ready.remove() + resolved.add(table) + + for ((otherTable, deps) in pendingDependencies) { + if (deps.remove(table) && deps.isEmpty() && otherTable !in resolved) { + ready.add(otherTable) + } + } + } + + check(resolved.size == pendingDependencies.size) { + "Cyclic table dependencies detected: $pendingDependencies" + } + + return resolved + } + + private fun cursorToJsonArray(cursor: Cursor): JSONArray { + val jsonArray = JSONArray() + while (cursor.moveToNext()) { + jsonArray.put(cursorRowToJsonObject(cursor)) + } + return jsonArray + } + + private fun cursorRowToJsonObject(cursor: Cursor): JSONObject { + val jsonObject = JSONObject() + for (index in 0 until cursor.columnCount) { + val key = cursor.getColumnName(index) + jsonObject.put(key, cursorColumnValueToJson(cursor, index)) + } + return jsonObject + } + + private fun cursorColumnValueToJson( + cursor: Cursor, + index: Int, + ): Any { + return when (cursor.getType(index)) { + Cursor.FIELD_TYPE_NULL -> JSONObject.NULL + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index) + Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index) + Cursor.FIELD_TYPE_STRING -> cursor.getString(index) + Cursor.FIELD_TYPE_BLOB -> { + val blob = cursor.getBlob(index) + JSONObject() + .put(JSON_TYPE_KEY, JSON_TYPE_BLOB) + .put(JSON_BLOB_BASE64_KEY, Base64.encodeToString(blob, Base64.NO_WRAP)) + } + else -> JSONObject.NULL + } + } + + private fun insertRows( + database: SQLiteDatabase, + tableName: String, + rows: JSONArray, + ) { + for (i in 0 until rows.length()) { + val row = rows.getJSONObject(i) + val values = ContentValues(row.length()) + + for (key in row.keys()) { + val value = row.get(key) + putJsonValue(values, key, value) + } + + database.insertOrThrow(tableName, null, values) + } + } + + private fun putJsonValue( + values: ContentValues, + key: String, + value: Any, + ) { + when (value) { + JSONObject.NULL -> values.putNull(key) + is Int -> values.put(key, value) + is Long -> values.put(key, value) + is Double -> values.put(key, value) + is Float -> values.put(key, value) + is Boolean -> values.put(key, value) + is String -> values.put(key, value) + is JSONObject -> putJsonObjectValue(values, key, value) + else -> values.put(key, value.toString()) + } + } + + private fun putJsonObjectValue( + values: ContentValues, + key: String, + value: JSONObject, + ) { + val type = value.optString(JSON_TYPE_KEY) + when (type) { + JSON_TYPE_BLOB -> { + val base64 = value.optString(JSON_BLOB_BASE64_KEY) + val bytes = Base64.decode(base64, Base64.DEFAULT) + values.put(key, bytes) + } + else -> values.put(key, value.toString()) + } + } +} diff --git a/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/di/PlatformModule.kt b/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/di/PlatformModule.kt index f9539c12..158c00ca 100644 --- a/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/di/PlatformModule.kt +++ b/platform/impl/src/main/java/br/com/sailboat/todozy/platform/impl/di/PlatformModule.kt @@ -4,7 +4,9 @@ import br.com.sailboat.todozy.platform.impl.LogServiceImpl import br.com.sailboat.todozy.platform.impl.StringProviderImpl import br.com.sailboat.todozy.platform.impl.database.DatabaseOpenHelper import br.com.sailboat.todozy.platform.impl.database.DatabaseTableFactoryImpl +import br.com.sailboat.todozy.platform.impl.database.backup.DatabaseJsonBackupServiceImpl import br.com.sailboat.todozy.utility.android.dialog.datetimeselector.viewmodel.DateTimeSelectorViewModel +import br.com.sailboat.todozy.utility.android.sqlite.DatabaseJsonBackupService import br.com.sailboat.todozy.utility.android.sqlite.DatabaseOpenHelperService import br.com.sailboat.todozy.utility.kotlin.DatabaseTableFactory import br.com.sailboat.todozy.utility.kotlin.LogService @@ -19,6 +21,7 @@ private val helper = factory { StringProviderImpl(get()) } factory { DatabaseTableFactoryImpl() } single { DatabaseOpenHelper(get()) } + factory { DatabaseJsonBackupServiceImpl(get(), get()) } } private val presentation = diff --git a/ui-component/impl/src/main/res/values/strings.xml b/ui-component/impl/src/main/res/values/strings.xml index 2ce9c2e9..ab8417f3 100644 --- a/ui-component/impl/src/main/res/values/strings.xml +++ b/ui-component/impl/src/main/res/values/strings.xml @@ -15,6 +15,9 @@ Task not done History Settings + Backup + Export database (JSON) + Import database (JSON) Previous days Before now With alarms @@ -70,6 +73,10 @@ History Details An error occurred while performing the operation + Database exported + Database imported + Importing a backup will replace all current tasks, alarms, and history. Continue? + Import Delete SAVE --- diff --git a/utility/android-util/src/main/java/br/com/sailboat/todozy/utility/android/sqlite/DatabaseJsonBackupService.kt b/utility/android-util/src/main/java/br/com/sailboat/todozy/utility/android/sqlite/DatabaseJsonBackupService.kt new file mode 100644 index 00000000..ad798641 --- /dev/null +++ b/utility/android-util/src/main/java/br/com/sailboat/todozy/utility/android/sqlite/DatabaseJsonBackupService.kt @@ -0,0 +1,8 @@ +package br.com.sailboat.todozy.utility.android.sqlite + +import android.net.Uri + +interface DatabaseJsonBackupService { + suspend fun exportToJson(uri: Uri) + suspend fun importFromJson(uri: Uri) +}