Skip to content

Commit

Permalink
Implement backup/restore
Browse files Browse the repository at this point in the history
Closes #106
  • Loading branch information
pilot51 committed May 28, 2024
1 parent e35a464 commit 908f707
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 110 deletions.
8 changes: 6 additions & 2 deletions app/src/main/java/com/pilot51/voicenotify/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
Expand All @@ -54,8 +56,10 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
val vm: PreferencesViewModel by viewModels()
lifecycleScope.launch(Dispatchers.IO) {
vm.configuringSettingsComboState.collect {
volumeControlStream = it.ttsStream ?: Settings.DEFAULT_TTS_STREAM
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.configuringSettingsComboState.collect {
volumeControlStream = it.ttsStream ?: Settings.DEFAULT_TTS_STREAM
}
}
}
setContent {
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/pilot51/voicenotify/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fun MainScreen(
var showQuietTimeStart by remember { mutableStateOf(false) }
var showQuietTimeEnd by remember { mutableStateOf(false) }
var showLog by remember { mutableStateOf(false) }
var showBackupRestore by remember { mutableStateOf(false) }
var showSupport by remember { mutableStateOf(false) }
var showReadPhoneStateRationale by remember { mutableStateOf(false) }
var showPostNotificationRationale by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -282,6 +283,11 @@ fun MainScreen(
summary = stringResource(R.string.notify_log_summary, NotifyList.HISTORY_LIMIT),
onClick = { showLog = true }
)
PreferenceRowLink(
titleRes = R.string.backup_restore,
summaryRes = R.string.backup_restore_summary,
onClick = { showBackupRestore = true }
)
PreferenceRowLink(
titleRes = R.string.support,
summaryRes = R.string.support_summary,
Expand Down Expand Up @@ -319,6 +325,9 @@ fun MainScreen(
if (showLog) {
NotificationLogDialog { showLog = false }
}
if (showBackupRestore) {
BackupDialog { showBackupRestore = false }
}
if (showSupport) {
SupportDialog { showSupport = false }
}
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import android.net.Uri
import android.os.Build
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
Expand Down Expand Up @@ -526,6 +528,44 @@ private fun rememberTimePickerState(
)
}

@Composable
fun BackupDialog(onDismiss: () -> Unit) {
val exportBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip")
) {
it?.let { PreferenceHelper.exportBackup(it) }
onDismiss()
}
val importBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) {
it?.let { PreferenceHelper.importBackup(it) }
onDismiss()
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
title = { Text(stringResource(R.string.backup_restore)) },
text = {
LazyColumn {
supportItem(title = R.string.backup_settings) {
val version = BuildConfig.VERSION_NAME
.replace(" ", "-").replace(Regex("[\\[\\]]"), "")
exportBackupLauncher.launch("voice_notify_${version}_backup.zip")
}
supportItem(title = R.string.restore_settings) {
importBackupLauncher.launch(arrayOf("application/zip"))
}
}
}
)
}

private const val DEV_EMAIL = "pilota51@gmail.com"

@Composable
Expand Down
71 changes: 61 additions & 10 deletions app/src/main/java/com/pilot51/voicenotify/PreferenceHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@
package com.pilot51.voicenotify

import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.preference.PreferenceManager
import com.pilot51.voicenotify.VNApplication.Companion.appContext
import com.pilot51.voicenotify.db.AppDatabase
import com.pilot51.voicenotify.db.AppDatabase.Companion.db
import com.pilot51.voicenotify.db.Settings
import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_AUDIO_FOCUS
import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_EMPTY
Expand All @@ -42,10 +45,12 @@ import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_TTS_STREAM
import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_TTS_STRING
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import kotlin.math.roundToInt

object PreferenceHelper {
Expand All @@ -65,18 +70,16 @@ object PreferenceHelper {

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore("prefs")
private val dataStore = appContext.dataStore
private val settingsDao = AppDatabase.db.settingsDao
private val globalSettingsFlow = settingsDao.getGlobalSettings().filterNotNull()
private lateinit var globalSettings: Settings
private val dataFiles get() = arrayOf(
appContext.getDatabasePath(AppDatabase.DB_NAME),
appContext.preferencesDataStoreFile("prefs")
)
private val backupDir get() = appContext.getExternalFilesDir(null)


init {
CoroutineScope(Dispatchers.IO).launch {
initSettings()
launch {
globalSettingsFlow.collect {
globalSettings = it
}
}
}
}

Expand All @@ -97,6 +100,7 @@ object PreferenceHelper {
* with default values or migrated from shared preferences.
*/
private suspend fun initSettings() {
val settingsDao = db.settingsDao
if (settingsDao.hasGlobalSettings()) return
val spDir = File(appContext.applicationInfo.dataDir, "shared_prefs")
val spName = "${BuildConfig.APPLICATION_ID}_preferences"
Expand Down Expand Up @@ -143,4 +147,51 @@ object PreferenceHelper {
}
} else settingsDao.insert(Settings.defaults)
}

fun exportBackup(uri: Uri) {
CoroutineScope(Dispatchers.IO).launch {
db.close()
appContext.contentResolver.openOutputStream(uri)?.use { outStream ->
ZipOutputStream(BufferedOutputStream(outStream)).use { zipOut ->
dataFiles.forEach { file ->
BufferedInputStream(FileInputStream(file)).use { origin ->
val buffer = ByteArray(1024)
val entry = ZipEntry(file.name)
zipOut.putNextEntry(entry)
var length: Int
while (origin.read(buffer).also { length = it } != -1) {
zipOut.write(buffer, 0, length)
}
}
}
}
}
AppDatabase.resetInstance()
}
}

fun importBackup(uri: Uri) {
CoroutineScope(Dispatchers.IO).launch {
db.close()
appContext.contentResolver.openInputStream(uri)?.use { inStream ->
ZipInputStream(BufferedInputStream(inStream)).use { zipIn ->
var entry = zipIn.nextEntry
while (entry != null) {
val outFile = dataFiles.find { it.name == entry.name } ?: continue
FileOutputStream(outFile).use { fos ->
val buffer = ByteArray(1024)
var length: Int
while (zipIn.read(buffer).also { length = it } > 0) {
fos.write(buffer, 0, length)
}
fos.flush()
}
zipIn.closeEntry()
entry = zipIn.nextEntry
}
}
}
AppDatabase.resetInstance()
}
}
}
37 changes: 19 additions & 18 deletions app/src/main/java/com/pilot51/voicenotify/PreferencesViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,41 @@ import androidx.lifecycle.viewModelScope
import com.pilot51.voicenotify.PreferenceHelper.DEFAULT_SHAKE_THRESHOLD
import com.pilot51.voicenotify.PreferenceHelper.KEY_SHAKE_THRESHOLD
import com.pilot51.voicenotify.db.App
import com.pilot51.voicenotify.db.AppDatabase
import com.pilot51.voicenotify.db.AppDatabase.Companion.db
import com.pilot51.voicenotify.db.AppDatabase.Companion.getAppSettingsFlow
import com.pilot51.voicenotify.db.AppDatabase.Companion.globalSettingsFlow
import com.pilot51.voicenotify.db.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

class PreferencesViewModel : ViewModel(), IPreferencesViewModel {
private val settingsDao = AppDatabase.db.settingsDao
private val globalSettingsFlow = settingsDao.getGlobalSettings().filterNotNull()
override val configuringAppState = MutableStateFlow<App?>(null)
override val configuringSettingsState = MutableStateFlow(Settings.defaults)
override val configuringSettingsComboState = MutableStateFlow(Settings.defaults)

init {
viewModelScope.launch(Dispatchers.IO) {
var settingsFlowJob: Job? = null
var gSettingsFlowJob: Job? = null
configuringAppState.collect { app ->
settingsFlowJob?.cancel()
settingsFlowJob = launch {
val settingsFlow = app?.let {
settingsDao.getAppSettings(app.packageName).map {
it ?: Settings(appPackage = app.packageName)
}
} ?: globalSettingsFlow
val settingsFlow = app?.let { getAppSettingsFlow(it) } ?: globalSettingsFlow
settingsFlow.collect {
gSettingsFlowJob?.cancel()
configuringSettingsState.value = it
configuringSettingsComboState.value = globalSettingsFlow.first().let { gs ->
if (app == null) gs else gs.merge(it)
if (settingsFlow == globalSettingsFlow) {
configuringSettingsComboState.value = it
} else {
gSettingsFlowJob = launch {
globalSettingsFlow.collect { gs ->
configuringSettingsComboState.value = gs.merge(it)
}
}
}
}
}
Expand All @@ -77,22 +79,21 @@ class PreferencesViewModel : ViewModel(), IPreferencesViewModel {
}

override fun getApp(appPkg: String) = runBlocking(Dispatchers.IO) {
AppDatabase.db.appDao.get(appPkg)
db.appDao.get(appPkg)
}

override fun setCurrentConfigApp(app: App?) {
configuringAppState.value = app
}

@Composable
override fun getSettingsState(app: App?) = (app?.let {
settingsDao.getAppSettings(app.packageName).map {
it ?: Settings(appPackage = app.packageName)
}
} ?: globalSettingsFlow).collectAsState(initial = Settings.defaults)
override fun getSettingsState(app: App?) =
(app?.let { getAppSettingsFlow(it) } ?: globalSettingsFlow.filterNotNull())
.collectAsState(initial = Settings.defaults)

override fun save(settings: Settings) {
viewModelScope.launch(Dispatchers.IO) {
val settingsDao = db.settingsDao
if (settings.areAllSettingsNull()) {
settingsDao.delete(settings)
} else settingsDao.upsert(settings)
Expand Down
Loading

0 comments on commit 908f707

Please sign in to comment.