Skip to content

Commit

Permalink
Added basic backup & restore function
Browse files Browse the repository at this point in the history
[Added import and export function]
  • Loading branch information
Z-Siqi committed Nov 20, 2024
1 parent 2f286df commit 3a8193c
Show file tree
Hide file tree
Showing 10 changed files with 502 additions and 8 deletions.
1 change: 1 addition & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

<application
android:allowBackup="true"
Expand All @@ -19,14 +19,23 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Checklist">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

</manifest>
3 changes: 2 additions & 1 deletion app/src/main/java/com/sqz/checklist/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.room.Room
import com.sqz.checklist.database.TaskDatabase
import com.sqz.checklist.database.taskDatabaseName
import com.sqz.checklist.ui.MainLayout
import com.sqz.checklist.ui.theme.ChecklistTheme

Expand All @@ -35,7 +36,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
taskDatabase = Room.databaseBuilder(
applicationContext,
TaskDatabase::class.java, "task-database"
TaskDatabase::class.java, taskDatabaseName
).build()
setContent {
var getNavHeight by remember { mutableIntStateOf(0) }
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/com/sqz/checklist/database/Database.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@ package com.sqz.checklist.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SimpleSQLiteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

const val taskDatabaseName = "task-database"

suspend fun mergeDatabaseCheckpoint(database: RoomDatabase) {
withContext(Dispatchers.IO) {
database.query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)"))
}
}

@Database(entities = [Task::class], version = 1)
@TypeConverters(LocalDateConverter::class)
abstract class TaskDatabase : RoomDatabase() {
abstract fun taskDao() : TaskDao
}
}
254 changes: 254 additions & 0 deletions app/src/main/java/com/sqz/checklist/database/DatabaseIO.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package com.sqz.checklist.database

import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import androidx.room.Room
import com.sqz.checklist.MainActivity.Companion.taskDatabase
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

class DatabaseIO(
private val dbPath: String,
private val context: Context,
private val preBackupFileName: String = "__pre-backup"
) {
private var _dbState by mutableStateOf(IOdbState.Default)

fun setIOdbState(state: IOdbState) {
this._dbState = state
}

fun exportDatabase(
exportName: String, uri: Uri?, useChooser: Boolean,
checkpoint: () -> Unit // merge database checkpoint ("PRAGMA wal_checkpoint(FULL)")
): Exception? {
_dbState = IOdbState.Processing
try {
checkpoint()
if (useChooser) {
val exportFile = File(context.cacheDir, "$exportName.db")
val intent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, exportDatabaseToCache(exportFile))
type = "application/octet-stream"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val shareIntent = Intent.createChooser(intent, null)
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(shareIntent)
} else uri?.let {
val dbFile = File(dbPath)
context.contentResolver.openOutputStream(it)?.use { outputStream ->
dbFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
_dbState = IOdbState.Finished
} catch (e: Exception) {
_dbState = IOdbState.Error
Log.e("ChecklistDatabase", "ERROR: $e")
return e
}
return null
}

private fun exportDatabaseToCache(file: File): Uri {
FileInputStream(dbPath).use { input ->
FileOutputStream(file).use { output ->
val buffer = ByteArray(1024)
var length: Int
while (input.read(buffer).also { length = it } > 0) {
output.write(buffer, 0, length)
}
}
}
return FileProvider.getUriForFile(
context, "${context.packageName}.provider", file
)
}

var preBackupFileUri: Uri? = null

fun importDatabase(
uri: Uri?, closeDatabase: () -> Unit, reOpenDatabase: () -> Boolean,
importState: (state: IOdbState) -> Unit = {}
): Exception? {
_dbState = IOdbState.Processing
// Import
uri?.let { url ->
//importState(IOdbState.Default)
try {
context.contentResolver.openInputStream(url)?.use { input ->
closeDatabase()
if (preBackupFileName == "__pre-backup") { // Backup before import
val exportFile = File(context.cacheDir, "$preBackupFileName.db")
preBackupFileUri = exportDatabaseToCache(exportFile)
}
FileOutputStream(dbPath).use { output ->
val buffer = ByteArray(1024)
var length: Int
while (input.read(buffer).also { length = it } > 0) {
output.write(buffer, 0, length)
importState(IOdbState.Processing)
}
}
if (reOpenDatabase()) importState(IOdbState.Finished)
}
} catch (e: Exception) {
importState(IOdbState.Error)
Log.e("ChecklistDatabase", "ERROR: $e")
_dbState = IOdbState.Error
return e
}
}
_dbState = IOdbState.Finished
return null
}

fun getIOdbState(): IOdbState {
return this._dbState
}
}

@Composable
fun GetUri(uri: (Uri?) -> Unit) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { selectedUri: Uri? ->
uri(selectedUri)
}
LaunchedEffect(Unit) {
launcher.launch(arrayOf("application/octet-stream"))
}
}

@Composable
fun ExportTaskDatabase(
state: Boolean, useChooser: Boolean, view: View,
dbPath: String = view.context.getDatabasePath(taskDatabaseName).absolutePath,
dbState: (state: IOdbState) -> Unit = {}
) {
val exportName = "Checklist-Task_Backup"
val databaseIO = remember { DatabaseIO(dbPath, view.context) }
val coroutineScope = rememberCoroutineScope()
val exportDatabase: (useChooser: Boolean, uri: Uri?) -> Unit = { chooser, uri ->
databaseIO.exportDatabase(exportName, uri, chooser) {
taskDatabase.close()
coroutineScope.launch {
mergeDatabaseCheckpoint(taskDatabase)
}
taskDatabase = Room.databaseBuilder(
view.context,
TaskDatabase::class.java, taskDatabaseName
).build()
}.let {
if (it != null) {
Toast.makeText(view.context, "Export failed: $it", Toast.LENGTH_SHORT).show()
}
}
}
val launcher = rememberLauncherForActivityResult( // Init function
contract = ActivityResultContracts.CreateDocument("db/sqlite")
) { selectedUri: Uri? ->
exportDatabase(false, selectedUri).also {
if (databaseIO.getIOdbState() != IOdbState.Error) databaseIO.setIOdbState(IOdbState.Finished)
}
}
if (state) { // Export actions
if (useChooser) exportDatabase(true, null) else {
val currentTime = remember {
val sdf = SimpleDateFormat("msys", Locale.getDefault())
sdf.format(Date())
}
launcher.launch("${exportName}_$currentTime.db").also {
databaseIO.setIOdbState(IOdbState.Processing)
}
}
}
if (databaseIO.getIOdbState() != IOdbState.Default) dbState(databaseIO.getIOdbState())
}

@Composable
fun ImportTaskDatabaseAction(
uri: Uri?, view: View,
dbState: (state: IOdbState) -> Unit = {}
) {
val dbPath = view.context.getDatabasePath(taskDatabaseName).absolutePath
val databaseIO = DatabaseIO(dbPath, view.context)
val coroutineScope = rememberCoroutineScope()
databaseIO.importDatabase(
uri = uri,
closeDatabase = {
taskDatabase.close()
coroutineScope.launch { // merge database checkpoint ("PRAGMA wal_checkpoint(FULL)")
mergeDatabaseCheckpoint(taskDatabase)
}
},
reOpenDatabase = {
taskDatabase = Room.databaseBuilder(
view.context, TaskDatabase::class.java, taskDatabaseName
).build()
if (!isDatabaseValid(dbPath)) {
Log.e("ChecklistDatabase", "Failed to import database: Invalid file!")
dbState(IOdbState.Error)
Log.w("ChecklistDatabase", "Trying to restore to backup..")
DatabaseIO(dbPath, view.context, "").importDatabase(
databaseIO.preBackupFileUri, { taskDatabase.close() }, {
taskDatabase = Room.databaseBuilder(
view.context, TaskDatabase::class.java, taskDatabaseName
).build()
true
}
) {}
}
true
},
importState = { dbState(it) }
)
}

private fun isDatabaseValid(databasePath: String): Boolean {
return try {
val db = SQLiteDatabase.openDatabase(databasePath, null, SQLiteDatabase.OPEN_READONLY)
val cursor = db.rawQuery("PRAGMA integrity_check;", null)
cursor.use {
if (it.moveToFirst()) {
val result = it.getString(0)
db.close()
result == "ok"
} else {
db.close()
false
}
}
} catch (e: Exception) {
Log.e("ChecklistDatabase", "ERROR: $e")
false
}
}

enum class IOdbState {
Default, Processing, Finished, Error
}
9 changes: 7 additions & 2 deletions app/src/main/java/com/sqz/checklist/ui/MainLayout.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.navigation.compose.rememberNavController
import com.sqz.checklist.ui.main.NavBarLayout
import com.sqz.checklist.ui.main.NavExtendedButtonData
import com.sqz.checklist.ui.main.NavMode
import com.sqz.checklist.ui.main.backup.BackupAndRestoreLayout
import com.sqz.checklist.ui.main.task.TaskLayoutViewModel
import com.sqz.checklist.ui.main.task.history.HistoryTopBar
import com.sqz.checklist.ui.main.task.history.TaskHistory
Expand All @@ -40,8 +41,8 @@ import com.sqz.checklist.ui.main.task.layout.taskExtendedNavButton
import com.sqz.checklist.ui.main.task.layout.topBarExtendedMenu

enum class MainLayoutNav {
TaskLayout,
TaskHistory,
TaskLayout, TaskHistory,
BackupRestore,
Unknown,
}

Expand Down Expand Up @@ -134,6 +135,7 @@ fun MainLayout(context: Context, view: View, modifier: Modifier = Modifier) {
bottomBar = {
when (currentRoute) {
MainLayoutNav.TaskHistory.name -> taskHistoryNavBar(navMode)
MainLayoutNav.BackupRestore.name -> nul()
MainLayoutNav.Unknown.name -> nul()
else -> mainNavigationBar(navMode)
}
Expand Down Expand Up @@ -161,6 +163,9 @@ fun MainLayout(context: Context, view: View, modifier: Modifier = Modifier) {
composable(MainLayoutNav.TaskHistory.name) {
TaskHistory(historyState = taskHistoryViewModel)
}
composable(MainLayoutNav.BackupRestore.name) {
BackupAndRestoreLayout(view = view)
}
}
}
}
Expand Down
Loading

0 comments on commit 3a8193c

Please sign in to comment.