Add backup/restore feature
This commit is contained in:
parent
5c9e10bf05
commit
55e8cfa7d5
7 changed files with 352 additions and 2 deletions
|
@ -1,10 +1,13 @@
|
|||
package it.vfsfitvnm.vimusic
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.room.*
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import it.vfsfitvnm.vimusic.models.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
||||
@Dao
|
||||
interface Database {
|
||||
companion object : Database by DatabaseInitializer.Instance.database
|
||||
|
@ -138,3 +141,19 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
|
|||
|
||||
val Database.internal: RoomDatabase
|
||||
get() = DatabaseInitializer.Instance
|
||||
|
||||
fun Database.checkpoint() {
|
||||
internal.openHelper.writableDatabase.run {
|
||||
query("PRAGMA journal_mode").use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
when (cursor.getString(0).lowercase()) {
|
||||
"wal" -> {
|
||||
query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
|
||||
query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst)
|
||||
query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ fun SettingsScreen() {
|
|||
val albumRoute = rememberPlaylistOrAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
val appearanceRoute = rememberAppearanceRoute()
|
||||
val backupAndRestoreRoute = rememberBackupAndRestoreRoute()
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
|
@ -46,11 +47,11 @@ fun SettingsScreen() {
|
|||
listenToGlobalEmitter = true,
|
||||
transitionSpec = {
|
||||
when (targetState.route) {
|
||||
appearanceRoute ->
|
||||
appearanceRoute, backupAndRestoreRoute ->
|
||||
slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
|
||||
else -> when (initialState.route) {
|
||||
appearanceRoute ->
|
||||
appearanceRoute, backupAndRestoreRoute ->
|
||||
slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
|
||||
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
|
||||
else -> fastFade
|
||||
|
@ -74,6 +75,10 @@ fun SettingsScreen() {
|
|||
AppearanceScreen()
|
||||
}
|
||||
|
||||
backupAndRestoreRoute {
|
||||
BackupAndRestoreScreen()
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
@ -172,6 +177,14 @@ fun SettingsScreen() {
|
|||
description = "Change the colors and shapes of the app",
|
||||
route = appearanceRoute,
|
||||
)
|
||||
|
||||
Entry(
|
||||
color = colorPalette.orange,
|
||||
icon = R.drawable.server,
|
||||
title = "Backup & Restore",
|
||||
description = "Backup and restore the app database",
|
||||
route = backupAndRestoreRoute
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
package it.vfsfitvnm.vimusic.ui.screens.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.checkpoint
|
||||
import it.vfsfitvnm.vimusic.internal
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
|
||||
import it.vfsfitvnm.vimusic.ui.screens.ArtistScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.PlaylistOrAlbumScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.rememberPlaylistOrAlbumRoute
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun BackupAndRestoreScreen() {
|
||||
val albumRoute = rememberPlaylistOrAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
PlaylistOrAlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val backupLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.checkpoint()
|
||||
context.applicationContext.contentResolver.openOutputStream(uri)
|
||||
?.use { outputStream ->
|
||||
FileInputStream(Database.internal.openHelper.writableDatabase.path).use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val restoreLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.internal.close()
|
||||
|
||||
FileOutputStream(Database.internal.openHelper.writableDatabase.path).use { outputStream ->
|
||||
context.applicationContext.contentResolver.openInputStream(uri)
|
||||
?.use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
|
||||
var isShowingRestoreDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isShowingRestoreDialog) {
|
||||
ConfirmationDialog(
|
||||
text = "The application will automatically close itself to avoid problems after restoring the database.",
|
||||
onDismiss = {
|
||||
isShowingRestoreDialog = false
|
||||
},
|
||||
onConfirm = {
|
||||
restoreLauncher.launch(
|
||||
arrayOf("application/x-sqlite3", "application/vnd.sqlite3", "application/octet-stream")
|
||||
)
|
||||
},
|
||||
confirmText = "Ok"
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 72.dp)
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "Backup & Restore",
|
||||
style = typography.m.semiBold
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp, horizontal = 32.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
val dateFormat = SimpleDateFormat("yyyyMMddHHmmss")
|
||||
backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db")
|
||||
}
|
||||
.shadow(elevation = 8.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.size(92.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.share),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.blue),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = "Backup",
|
||||
style = typography.xs.semiBold,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp, horizontal = 32.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
isShowingRestoreDialog = true
|
||||
}
|
||||
.shadow(elevation = 8.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.size(92.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.download),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.orange),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = "Restore",
|
||||
style = typography.xs.semiBold,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = "Backup",
|
||||
style = typography.xxs.semiBold.secondary
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "The backup consists in exporting the application database to your device storage.\nThis means playlists, song history, favorites songs will exported.\nThis operation excludes personal preferences such as the theme mode and everything you can set in the Settings page.",
|
||||
style = typography.xxs.secondary
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = "Restore",
|
||||
style = typography.xxs.semiBold.secondary
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "The restore replaces the existing application database with the selected - previously exported - one.\nThis means every currently existing data will be wiped: THE TWO DATABASES WON'T BE MERGED.\nIt is recommended to restore the database immediately after a the application is installed on a new device.",
|
||||
style = typography.xxs.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,3 +10,10 @@ fun rememberAppearanceRoute(): Route0 {
|
|||
Route0("AppearanceRoute")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberBackupAndRestoreRoute(): Route0 {
|
||||
return remember {
|
||||
Route0("BackupAndRestoreRoute")
|
||||
}
|
||||
}
|
||||
|
|
12
app/src/main/res/drawable/download.xml
Normal file
12
app/src/main/res/drawable/download.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M376,160H272V313.37l52.69,-52.68a16,16 0,0 1,22.62 22.62l-80,80a16,16 0,0 1,-22.62 0l-80,-80a16,16 0,0 1,22.62 -22.62L240,313.37V160H136a56.06,56.06 0,0 0,-56 56V424a56.06,56.06 0,0 0,56 56H376a56.06,56.06 0,0 0,56 -56V216A56.06,56.06 0,0 0,376 160Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M272,48a16,16 0,0 0,-32 0V160h32Z"/>
|
||||
</vector>
|
18
app/src/main/res/drawable/server.xml
Normal file
18
app/src/main/res/drawable/server.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M256,428C203.65,428 144.61,416.39 98.07,397 81,389.81 66.38,378.18 54.43,369A4,4 0,0 0,48 372.18v12.58c0,28.07 23.49,53.22 66.14,70.82C152.29,471.33 202.67,480 256,480s103.7,-8.67 141.86,-24.42C440.51,438 464,412.83 464,384.76V372.18a4,4 0,0 0,-6.43 -3.18C445.62,378.17 431,389.81 413.92,397 367.38,416.39 308.35,428 256,428Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M464,126.51c-0.81,-27.65 -24.18,-52.4 -66,-69.85C359.74,40.76 309.34,32 256,32S152.26,40.76 114.09,56.66c-41.78,17.41 -65.15,42.11 -66,69.69L48,144c0,6.41 5.2,16.48 14.63,24.73 11.13,9.73 27.65,19.33 47.78,27.73C153.24,214.36 207.67,225 256,225s102.76,-10.68 145.59,-28.58c20.13,-8.4 36.65,-18 47.78,-27.73C458.8,160.49 464,150.42 464,144Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M413.92,226C367.39,245.43 308.35,257 256,257S144.61,245.43 98.07,226C81,218.85 66.38,207.21 54.43,198A4,4 0,0 0,48 201.22V232c0,6.41 5.2,14.48 14.63,22.73 11.13,9.74 27.65,19.33 47.78,27.74C153.24,300.34 207.67,311 256,311s102.76,-10.68 145.59,-28.57c20.13,-8.41 36.65,-18 47.78,-27.74C458.8,246.47 464,238.41 464,232V201.22a4,4 0,0 0,-6.43 -3.18C445.62,207.21 431,218.85 413.92,226Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M413.92,312C367.38,331.41 308.35,343 256,343S144.61,331.41 98.07,312C81,304.83 66.38,293.19 54.43,284A4,4 0,0 0,48 287.2V317c0,6.41 5.2,14.47 14.62,22.71 11.13,9.74 27.66,19.33 47.79,27.74C153.24,385.32 207.66,396 256,396s102.76,-10.68 145.59,-28.57c20.13,-8.41 36.65,-18 47.78,-27.74C458.8,331.44 464,323.37 464,317V287.2a4,4 0,0 0,-6.43 -3.18C445.62,293.19 431,304.83 413.92,312Z"/>
|
||||
</vector>
|
12
app/src/main/res/drawable/share.xml
Normal file
12
app/src/main/res/drawable/share.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M376,176H272V321a16,16 0,0 1,-32 0V176H136a56.06,56.06 0,0 0,-56 56V424a56.06,56.06 0,0 0,56 56H376a56.06,56.06 0,0 0,56 -56V232A56.06,56.06 0,0 0,376 176Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M272,86.63l52.69,52.68a16,16 0,0 0,22.62 -22.62l-80,-80a16,16 0,0 0,-22.62 0l-80,80a16,16 0,0 0,22.62 22.62L240,86.63V176h32Z"/>
|
||||
</vector>
|
Loading…
Reference in a new issue