From 908f70715e0b0012ae2578812c32ece3ec5bf355 Mon Sep 17 00:00:00 2001 From: Mark Injerd Date: Tue, 28 May 2024 00:35:26 -0400 Subject: [PATCH] Implement backup/restore Closes #106 --- .../com/pilot51/voicenotify/MainActivity.kt | 8 +- .../com/pilot51/voicenotify/MainScreen.kt | 9 ++ .../pilot51/voicenotify/PreferenceDialogs.kt | 40 +++++ .../pilot51/voicenotify/PreferenceHelper.kt | 71 +++++++-- .../voicenotify/PreferencesViewModel.kt | 37 ++--- .../java/com/pilot51/voicenotify/Service.kt | 145 +++++++++--------- .../com/pilot51/voicenotify/db/AppDatabase.kt | 25 ++- app/src/main/res/values/strings.xml | 4 + 8 files changed, 229 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt b/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt index f5f0546..3a64233 100644 --- a/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt +++ b/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt @@ -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 @@ -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 { diff --git a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt index 7b27e8c..99ff26c 100644 --- a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt @@ -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) } @@ -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, @@ -319,6 +325,9 @@ fun MainScreen( if (showLog) { NotificationLogDialog { showLog = false } } + if (showBackupRestore) { + BackupDialog { showBackupRestore = false } + } if (showSupport) { SupportDialog { showSupport = false } } diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt index a1b22d1..fd6e3dd 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt @@ -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.* @@ -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 diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferenceHelper.kt b/app/src/main/java/com/pilot51/voicenotify/PreferenceHelper.kt index df9334d..61595a7 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferenceHelper.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferenceHelper.kt @@ -16,6 +16,7 @@ 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 @@ -23,9 +24,11 @@ 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 @@ -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 { @@ -65,18 +70,16 @@ object PreferenceHelper { private val Context.dataStore: DataStore 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 - } - } } } @@ -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" @@ -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() + } + } } diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferencesViewModel.kt b/app/src/main/java/com/pilot51/voicenotify/PreferencesViewModel.kt index e4ec55c..b148ab3 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferencesViewModel.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferencesViewModel.kt @@ -22,20 +22,18 @@ 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(null) override val configuringSettingsState = MutableStateFlow(Settings.defaults) override val configuringSettingsComboState = MutableStateFlow(Settings.defaults) @@ -43,18 +41,22 @@ class PreferencesViewModel : ViewModel(), IPreferencesViewModel { 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) + } + } } } } @@ -77,7 +79,7 @@ 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?) { @@ -85,14 +87,13 @@ class PreferencesViewModel : ViewModel(), IPreferencesViewModel { } @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) diff --git a/app/src/main/java/com/pilot51/voicenotify/Service.kt b/app/src/main/java/com/pilot51/voicenotify/Service.kt index 5837164..4c881c3 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Service.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Service.kt @@ -50,6 +50,7 @@ import com.pilot51.voicenotify.PreferenceHelper.getPrefFlow import com.pilot51.voicenotify.PreferenceHelper.setPref 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.Settings import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_AUDIO_FOCUS import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_EMPTY @@ -65,15 +66,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import java.util.* class Service : NotificationListenerService() { private val appContext by ::applicationContext - private val settingsDao = AppDatabase.db.settingsDao private val lastMsg = mutableMapOf() private val lastMsgTime = mutableMapOf() private var tts: TextToSpeech? = null @@ -90,8 +89,6 @@ class Service : NotificationListenerService() { .setLegacyStreamType(AudioManager.STREAM_MUSIC).build()) .build() } else null - private val globalSettingsFlow = settingsDao.getGlobalSettings().filterNotNull() - private lateinit var globalSettings: Settings /** * this is used to determine if we are the first, middle, or last thing to be spoken at the moment, for enabling/disabling shake and audio focus request @@ -116,7 +113,6 @@ class Service : NotificationListenerService() { } } } - ioScope.launch { globalSettingsFlow.collect { globalSettings = it } } initTts() super.onCreate() } @@ -208,75 +204,77 @@ class Service : NotificationListenerService() { } override fun onNotificationPosted(sbn: StatusBarNotification) { - val notification = sbn.notification - val app = Common.findOrAddApp(sbn.packageName) - val settings = getCombinedSettings(app) - if (settings.ignoreGroups ?: DEFAULT_IGNORE_GROUPS - && notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) { - return // Completely ignore group summary notifications. - } - val info = NotificationInfo(app, notification, settings) - val msgTime = info.calendar.timeInMillis - val ttsMsg = info.ttsMessage - if (app != null && !app.enabled) { - info.ignoreReasons.add(IgnoreReason.APP) - } - if (info.isEmpty && settings.ignoreEmpty ?: DEFAULT_IGNORE_EMPTY) { - info.ignoreReasons.add(IgnoreReason.EMPTY_MSG) - } - if (ttsMsg != null) { - val requireStrings = settings.requireStrings?.split("\n") - val stringRequired = requireStrings?.all { - it.isNotEmpty() && !ttsMsg.contains(it, true) - } ?: false - if (stringRequired) { - info.ignoreReasons.add(IgnoreReason.STRING_REQUIRED) + CoroutineScope(Dispatchers.IO).launch { + val notification = sbn.notification + val app = Common.findOrAddApp(sbn.packageName) + val settings = getCombinedSettings(app) + if (settings.ignoreGroups ?: DEFAULT_IGNORE_GROUPS + && notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) { + return@launch // Completely ignore group summary notifications. } - val ignoreStrings = settings.ignoreStrings?.split("\n") - val stringIgnored = ignoreStrings?.any { - it.isNotEmpty() && ttsMsg.contains(it, true) - } ?: false - if (stringIgnored) { - info.ignoreReasons.add(IgnoreReason.STRING_IGNORED) + val info = NotificationInfo(app, notification, settings) + val msgTime = info.calendar.timeInMillis + val ttsMsg = info.ttsMessage + if (app != null && !app.enabled) { + info.ignoreReasons.add(IgnoreReason.APP) } - } - val ignoreRepeat = settings.ignoreRepeat ?: -1 - if (lastMsg.containsKey(app)) { - if (lastMsg[app] == ttsMsg && (ignoreRepeat == -1 || msgTime - lastMsgTime[app]!! < ignoreRepeat * 1000)) { - info.addIgnoreReasonIdentical(ignoreRepeat) + if (info.isEmpty && settings.ignoreEmpty ?: DEFAULT_IGNORE_EMPTY) { + info.ignoreReasons.add(IgnoreReason.EMPTY_MSG) } - } - NotifyList.addNotification(info) - if (info.ignoreReasons.isEmpty()) { - val delay = settings.ttsDelay ?: 0 - if (!isScreenOn()) { - val interval = settings.ttsRepeat ?: 0.0 - if (interval > 0) { - synchronized(repeatList) { repeatList.add(info) } - if (repeater == null) { - repeater = RepeatTimer(interval) - } + if (ttsMsg != null) { + val requireStrings = settings.requireStrings?.split("\n") + val stringRequired = requireStrings?.all { + it.isNotEmpty() && !ttsMsg.contains(it, true) + } ?: false + if (stringRequired) { + info.ignoreReasons.add(IgnoreReason.STRING_REQUIRED) + } + val ignoreStrings = settings.ignoreStrings?.split("\n") + val stringIgnored = ignoreStrings?.any { + it.isNotEmpty() && ttsMsg.contains(it, true) + } ?: false + if (stringIgnored) { + info.ignoreReasons.add(IgnoreReason.STRING_IGNORED) } } - Timer().schedule(object : TimerTask() { - override fun run() { - val ignoreReasons = ignore(info.settings) - if (ignoreReasons.isNotEmpty()) { - Log.i(TAG, "Notification ignored for reason(s): " - + ignoreReasons.joinToString()) - info.ignoreReasons.addAll(ignoreReasons) - return - } - CoroutineScope(Dispatchers.Main).launch { - speak(info) + val ignoreRepeat = settings.ignoreRepeat ?: -1 + if (lastMsg.containsKey(app)) { + if (lastMsg[app] == ttsMsg && (ignoreRepeat == -1 || msgTime - lastMsgTime[app]!! < ignoreRepeat * 1000)) { + info.addIgnoreReasonIdentical(ignoreRepeat) + } + } + NotifyList.addNotification(info) + if (info.ignoreReasons.isEmpty()) { + val delay = settings.ttsDelay ?: 0 + if (!isScreenOn()) { + val interval = settings.ttsRepeat ?: 0.0 + if (interval > 0) { + synchronized(repeatList) { repeatList.add(info) } + if (repeater == null) { + repeater = RepeatTimer(interval) + } } } - }, (delay * 1000).toLong()) // A delay of 0 works fine, and means that all speak calls anywhere are running in their own thread and not blocking. - lastMsg[app] = ttsMsg - lastMsgTime[app] = msgTime - } else { - Log.i(TAG, "Notification from " + app?.label - + " ignored for reason(s): " + info.getIgnoreReasonsAsText()) + Timer().schedule(object : TimerTask() { + override fun run() { + val ignoreReasons = ignore(info.settings) + if (ignoreReasons.isNotEmpty()) { + Log.i(TAG, "Notification ignored for reason(s): " + + ignoreReasons.joinToString()) + info.ignoreReasons.addAll(ignoreReasons) + return + } + CoroutineScope(Dispatchers.Main).launch { + speak(info) + } + } + }, (delay * 1000).toLong()) // A delay of 0 works fine, and means that all speak calls anywhere are running in their own thread and not blocking. + lastMsg[app] = ttsMsg + lastMsgTime[app] = msgTime + } else { + Log.i(TAG, "Notification from " + app?.label + + " ignored for reason(s): " + info.getIgnoreReasonsAsText()) + } } } @@ -512,11 +510,12 @@ class Service : NotificationListenerService() { } } - private fun getCombinedSettings(app: App?) = app?.let { - runBlocking(Dispatchers.IO) { - settingsDao.getAppSettings(app.packageName).firstOrNull() - }?.let { globalSettings.merge(it) } - } ?: globalSettings + private suspend fun getCombinedSettings(app: App?): Settings { + val gs = AppDatabase.globalSettingsFlow.first() + return app?.run { + db.settingsDao.getAppSettings(packageName).firstOrNull()?.let { gs.merge(it) } + } ?: gs + } companion object { private val TAG = Service::class.simpleName diff --git a/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt b/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt index 3f982ca..221241c 100644 --- a/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt +++ b/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt @@ -17,8 +17,9 @@ package com.pilot51.voicenotify.db import androidx.room.* import androidx.room.migration.AutoMigrationSpec -import com.pilot51.voicenotify.VNApplication -import kotlinx.coroutines.flow.Flow +import com.pilot51.voicenotify.VNApplication.Companion.appContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* @Database( version = 2, @@ -131,11 +132,21 @@ abstract class AppDatabase : RoomDatabase() { class Migration1To2 : AutoMigrationSpec companion object { - val db by lazy { - Room.databaseBuilder( - VNApplication.appContext, - AppDatabase::class.java, "apps.db" - ).build() + const val DB_NAME = "apps.db" + private val _db = MutableStateFlow(buildDB()) + val db: AppDatabase get() = _db.value + @OptIn(ExperimentalCoroutinesApi::class) + val globalSettingsFlow = _db.flatMapMerge { it.settingsDao.getGlobalSettings().filterNotNull() } + + private fun buildDB() = Room.databaseBuilder(appContext, AppDatabase::class.java, DB_NAME).build() + + fun resetInstance() { _db.value = buildDB() } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getAppSettingsFlow(app: App) = _db.flatMapMerge { db -> + db.settingsDao.getAppSettings(app.packageName).map { + it ?: Settings(appPackage = app.packageName) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de7201d..b6e0f90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,10 @@ Ignore %s? Unignore %s? Yes + Backup / Restore + Backup or restore all Voice Notify settings + Backup Settings + Restore Settings Help & Support Rate & review, email the developer, community chat, translations, source code, issue tracker, privacy policy Rate & Review