From 771291ac2543ccbfedd5661bc5003b783b9dd660 Mon Sep 17 00:00:00 2001 From: Mark Injerd Date: Sun, 15 Dec 2024 14:58:18 -0500 Subject: [PATCH] Fixes & improvements - Remove "by widget" from reason_suspended string. - Fix global Speak Emojis setting not reflected in app overrides. - Prevent lateinit crash when opening App List. - Prevent ANR when fetching app info. - Change app isEnabled to non-nullable. - Migrate to version catalog. - Update Gradle and dependencies. - Other fixes and code improvements. Not targeting API 35 yet because it breaks audio focus in Android 15. See: https://github.com/androidx/media/issues/1815 --- .github/workflows/auto-build.yml | 2 +- app/build.gradle.kts | 39 ++- .../2.json | 8 +- .../com/pilot51/voicenotify/AppListScreen.kt | 13 +- .../pilot51/voicenotify/AppListViewModel.kt | 77 +++--- .../java/com/pilot51/voicenotify/Common.kt | 60 +++-- .../com/pilot51/voicenotify/MainScreen.kt | 80 +++--- .../com/pilot51/voicenotify/NotifyList.kt | 8 +- .../pilot51/voicenotify/PreferenceDialogs.kt | 23 +- .../java/com/pilot51/voicenotify/Service.kt | 242 ++++++++++-------- .../pilot51/voicenotify/TtsConfigScreen.kt | 3 +- .../java/com/pilot51/voicenotify/Utils.kt | 9 + .../java/com/pilot51/voicenotify/db/App.kt | 16 +- .../com/pilot51/voicenotify/db/AppDatabase.kt | 55 +--- app/src/main/res/values/strings.xml | 2 +- build.gradle.kts | 6 +- gradle/libs.versions.toml | 35 +++ gradle/wrapper/gradle-wrapper.properties | 4 +- 18 files changed, 359 insertions(+), 323 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/.github/workflows/auto-build.yml b/.github/workflows/auto-build.yml index c31c693..16a788a 100644 --- a/.github/workflows/auto-build.yml +++ b/.github/workflows/auto-build.yml @@ -26,7 +26,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: 'gradle' diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 570bfc4..d55f3f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,9 +20,10 @@ import java.io.FileInputStream import java.util.* plugins { - id("com.android.application") kotlin("android") - id("com.google.devtools.ksp") + alias(libs.plugins.android.application) + alias(libs.plugins.compose) + alias(libs.plugins.ksp) } val keystorePropertiesFile: File = rootProject.file("keystore.properties") @@ -64,13 +65,9 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } signingConfigs { @@ -115,17 +112,17 @@ android { } dependencies { - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.activity:activity-compose:1.9.0") - implementation("androidx.compose.material3:material3:1.3.0-beta01") - implementation("androidx.compose.material:material-icons-extended-android:1.6.7") - implementation("androidx.compose.ui:ui-tooling-preview:1.6.7") - debugImplementation("androidx.compose.ui:ui-tooling:1.6.7") - implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("androidx.glance:glance-appwidget:1.0.0") - implementation("androidx.preference:preference-ktx:1.2.1") - implementation("androidx.room:room-ktx:2.6.1") - ksp("androidx.room:room-compiler:2.6.1") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.24") - implementation("com.google.accompanist:accompanist-permissions:0.34.0") + implementation(libs.accompanist.permissions) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.preference) + implementation(libs.androidx.room.ktx) + implementation(libs.kotlin.reflect) + ksp(libs.androidx.room.compiler) + debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json b/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json index a746bba..bc61a9f 100644 --- a/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json +++ b/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "9fb9997d731f3b0c50e64706618b12c1", + "identityHash": "17d2b6035ddbc39718141ef47062f137", "entities": [ { "tableName": "apps", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `is_enabled` INTEGER, PRIMARY KEY(`package`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`package`))", "fields": [ { "fieldPath": "packageName", @@ -24,7 +24,7 @@ "fieldPath": "isEnabled", "columnName": "is_enabled", "affinity": "INTEGER", - "notNull": false + "notNull": true } ], "primaryKey": { @@ -208,7 +208,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9fb9997d731f3b0c50e64706618b12c1')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '17d2b6035ddbc39718141ef47062f137')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt b/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt index 88dee3a..73f43fe 100644 --- a/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt @@ -42,11 +42,12 @@ import com.pilot51.voicenotify.AppListViewModel.IgnoreType import com.pilot51.voicenotify.db.App import kotlinx.coroutines.delay -private lateinit var vmStoreOwner: ViewModelStoreOwner +private val vmStoreOwner = mutableStateOf(null) @Composable fun AppListActions() { - val vm: AppListViewModel = viewModel(vmStoreOwner) + val vmOwner by vmStoreOwner + val vm: AppListViewModel = viewModel(vmOwner ?: return) var showSearchBar by remember { mutableStateOf(false) } if (showSearchBar) { val focusRequester = remember { FocusRequester() } @@ -131,8 +132,9 @@ fun AppListActions() { fun AppListScreen( onConfigureApp: (app: App) -> Unit ) { - vmStoreOwner = LocalViewModelStoreOwner.current!! - val vm: AppListViewModel = viewModel(vmStoreOwner) + val vmOwner = LocalViewModelStoreOwner.current!!.also { vmStoreOwner.value = it } + DisposableEffect(vmOwner) { onDispose { vmStoreOwner.value = null } } + val vm: AppListViewModel = viewModel(vmOwner) val packagesWithOverride by vm.packagesWithOverride AppList( vm.filteredApps, @@ -164,6 +166,7 @@ private fun AppList( } } +@NonSkippableComposable @Composable private fun AppListItem( app: App, @@ -197,7 +200,7 @@ private fun AppListItem( } } Checkbox( - checked = app.enabled, + checked = app.isEnabled, modifier = Modifier.focusable(false), onCheckedChange = { toggleIgnore(app) } ) diff --git a/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt b/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt index 0703ab1..516013c 100644 --- a/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt +++ b/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt @@ -18,6 +18,7 @@ package com.pilot51.voicenotify import android.app.Application import android.content.pm.PackageManager import android.os.Build +import android.util.Log import android.widget.Toast import androidx.compose.runtime.* import androidx.lifecycle.AndroidViewModel @@ -28,13 +29,11 @@ import com.pilot51.voicenotify.PreferenceHelper.KEY_APP_DEFAULT_ENABLE 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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.pilot51.voicenotify.db.AppDatabase.Companion.db +import kotlinx.coroutines.* import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration.Companion.seconds class AppListViewModel(application: Application) : AndroidViewModel(application) { private val appContext = application.applicationContext @@ -44,7 +43,7 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) private val syncAppsMutex by Common::syncAppsMutex var searchQuery by mutableStateOf(null) var showList by mutableStateOf(false) - private val settingsDao = AppDatabase.db.settingsDao + private val settingsDao = db.settingsDao val packagesWithOverride @Composable get() = settingsDao.packagesWithOverride().collectAsState(listOf()) @@ -65,47 +64,58 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) CoroutineScope(Dispatchers.IO).launch { syncAppsMutex.withLock { apps.clear() - apps.addAll(AppDatabase.db.appDao.getAll()) + apps.addAll(db.appDao.getAll()) val isFirstLoad = apps.isEmpty() val packMan = appContext.packageManager - - // Remove uninstalled - for (a in apps.indices.reversed()) { - val app = apps[a] - try { + val installedApps = try { + withTimeoutInterruptible(10.seconds) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packMan.getApplicationInfo(app.packageName, PackageManager.ApplicationInfoFlags.of(0L)) + packMan.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0L)) } else { - packMan.getApplicationInfo(app.packageName, 0) + packMan.getInstalledApplications(0) } - } catch (e: PackageManager.NameNotFoundException) { + } + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Timed out fetching list of installed apps") + return@withLock + } + + // Remove uninstalled + val appIter = apps.iterator() + for (app in appIter) { + if (installedApps.none { it.packageName == app.packageName }) { if (!isFirstLoad) app.remove() - apps.removeAt(a) + appIter.remove() } } // Add new - val installedApps = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packMan.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0L)) - } else { - packMan.getInstalledApplications(0) - } - inst@ for (appInfo in installedApps) { - for (app in apps) { - if (app.packageName == appInfo.packageName) { - continue@inst - } + for (appInfo in installedApps) { + if (apps.any { it.packageName == appInfo.packageName }) { + continue + } + val label = try { + withTimeoutInterruptible(1.seconds) { + appInfo.loadLabel(packMan) + }.toString() + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Timed out fetching app label for package ${appInfo.packageName}") + continue } val app = App( packageName = appInfo.packageName, - label = appInfo.loadLabel(packMan).toString(), + label = label, isEnabled = appDefaultEnable ) apps.add(app) if (!isFirstLoad) app.updateDb() } + + // Sort list apps.sortWith { app1, app2 -> app1.label.compareTo(app2.label, ignoreCase = true) } - if (isFirstLoad) AppDatabase.db.appDao.upsert(apps) + + // Bulk add apps to DB if this is the first load + if (isFirstLoad) db.appDao.insert(apps) } isUpdating = false filterApps() @@ -136,22 +146,22 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) CoroutineScope(Dispatchers.IO).launch { syncAppsMutex.withLock { if (apps.isEmpty()) return@launch - for (app in apps) { - setIgnore(app, ignoreType) + apps.forEach { + setIgnore(it, ignoreType) } filterApps() + db.appDao.update(apps) } - AppDatabase.db.appDao.upsert(apps) } } fun setIgnore(app: App, ignoreType: IgnoreType) { - if (!app.enabled && (ignoreType == IGNORE_TOGGLE || ignoreType == IGNORE_NONE)) { + if (!app.isEnabled && (ignoreType == IGNORE_TOGGLE || ignoreType == IGNORE_NONE)) { app.setEnabled(true, ignoreType == IGNORE_TOGGLE) if (ignoreType == IGNORE_TOGGLE) { Toast.makeText(appContext, appContext.getString(R.string.app_is_not_ignored, app.label), Toast.LENGTH_SHORT).show() } - } else if (app.enabled && (ignoreType == IGNORE_TOGGLE || ignoreType == IGNORE_ALL)) { + } else if (app.isEnabled && (ignoreType == IGNORE_TOGGLE || ignoreType == IGNORE_ALL)) { app.setEnabled(false, ignoreType == IGNORE_TOGGLE) if (ignoreType == IGNORE_TOGGLE) { Toast.makeText(appContext, appContext.getString(R.string.app_is_ignored, app.label), Toast.LENGTH_SHORT).show() @@ -169,6 +179,7 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) } companion object { + private val TAG = AppListViewModel::class.simpleName /** The default enabled value for new apps. */ var appDefaultEnable = runBlocking(Dispatchers.IO) { diff --git a/app/src/main/java/com/pilot51/voicenotify/Common.kt b/app/src/main/java/com/pilot51/voicenotify/Common.kt index d490d4d..8d0b0e2 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Common.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Common.kt @@ -17,20 +17,23 @@ package com.pilot51.voicenotify import android.content.Intent import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException import android.os.Build import android.provider.Settings -import android.util.Pair +import android.util.Log import androidx.compose.runtime.mutableStateListOf import com.pilot51.voicenotify.AppListViewModel.Companion.appDefaultEnable import com.pilot51.voicenotify.VNApplication.Companion.appContext import com.pilot51.voicenotify.db.App -import com.pilot51.voicenotify.db.AppDatabase -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking +import com.pilot51.voicenotify.db.AppDatabase.Companion.db +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration.Companion.seconds object Common { + private val TAG = Common::class.simpleName + val notificationListenerSettingsIntent: Intent by lazy { Intent( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { @@ -46,35 +49,38 @@ object Common { * @param pkg Package name used to find [App] in current list or create a new one from system. * @return Found or created [App], otherwise null if app not found on system. */ - fun findOrAddApp(pkg: String): App? { - return runBlocking(Dispatchers.IO) { - syncAppsMutex.withLock { - if (apps.isEmpty()) { - apps.addAll(AppDatabase.db.appDao.getAll()) - } - for (app in apps) { - if (app.packageName == pkg) { - return@runBlocking app - } - } - return@runBlocking try { + suspend fun findOrAddApp(pkg: String): App? { + syncAppsMutex.withLock { + if (apps.isEmpty()) { + apps.addAll(db.appDao.getAll()) + } + apps.find { it.packageName == pkg }?.let { + return it + } + val appLabel = try { + withTimeoutInterruptible(2.seconds) { val packMan = appContext.packageManager - val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packMan.getApplicationInfo(pkg, PackageManager.ApplicationInfoFlags.of(0L)) } else { packMan.getApplicationInfo(pkg, 0) + }.run { + loadLabel(packMan).toString() } - val app = App( - packageName = pkg, - label = appInfo.loadLabel(packMan).toString(), - isEnabled = appDefaultEnable - ) - apps.add(app.updateDb()) - app - } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() - null } + } catch (e: NameNotFoundException) { + Log.w(TAG, "App not found for package $pkg") + return null + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Timed out fetching app info/label for package $pkg") + return null + } + return App( + packageName = pkg, + label = appLabel, + isEnabled = appDefaultEnable + ).apply { + apps.add(updateDb()) } } } diff --git a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt index 99ff26c..ec2d129 100644 --- a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberPermissionState import com.pilot51.voicenotify.NotifyList.NotificationLogDialog @@ -45,8 +45,12 @@ import com.pilot51.voicenotify.db.App import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_AUDIO_FOCUS import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_EMPTY import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_GROUPS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import java.util.* +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -350,44 +354,48 @@ fun MainScreen( private const val NOTIFICATION_CHANNEL_ID = "test" private fun runTestNotification(context: Context) { - val vnApp = Common.findOrAddApp(context.packageName)!! - if (!vnApp.enabled) { - Toast.makeText(context, context.getString(R.string.test_ignored), Toast.LENGTH_LONG).show() - } - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val intent = Intent(context, MainActivity::class.java) - Timer().schedule(object : TimerTask() { - override fun run() { - val id = NOTIFICATION_CHANNEL_ID - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - var channel = notificationManager.getNotificationChannel(id) - if (channel == null) { - channel = NotificationChannel(id, context.getString(R.string.test), NotificationManager.IMPORTANCE_LOW) - channel.description = context.getString(R.string.notification_channel_desc) - notificationManager.createNotificationChannel(channel) - } + CoroutineScope(Dispatchers.IO).launch { + val vnApp = Common.findOrAddApp(context.packageName)!! + if (!vnApp.isEnabled) { + Toast.makeText(context, context.getString(R.string.test_ignored), Toast.LENGTH_LONG).show() + } + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val intent = Intent(context, MainActivity::class.java) + delay(5.seconds) + val id = NOTIFICATION_CHANNEL_ID + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + var channel = notificationManager.getNotificationChannel(id) + if (channel == null) { + channel = NotificationChannel( + id, + context.getString(R.string.test), + NotificationManager.IMPORTANCE_LOW + ) + channel.description = context.getString(R.string.notification_channel_desc) + notificationManager.createNotificationChannel(channel) } - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else PendingIntent.FLAG_UPDATE_CURRENT - val pi = PendingIntent.getActivity(context, 0, intent, flags) - val builder = NotificationCompat.Builder(context, id) - .setAutoCancel(true) - .setContentIntent(pi) - .setSmallIcon(R.drawable.ic_notification) - .setTicker(context.getString(R.string.test_ticker)) - .setSubText(context.getString(R.string.test_subtext)) - .setContentTitle(context.getString(R.string.test_content_title)) - .setContentText(context.getString(R.string.test_content_text)) - .setContentInfo(context.getString(R.string.test_content_info)) - .setStyle(NotificationCompat.BigTextStyle() + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else PendingIntent.FLAG_UPDATE_CURRENT + val pi = PendingIntent.getActivity(context, 0, intent, flags) + val builder = NotificationCompat.Builder(context, id) + .setAutoCancel(true) + .setContentIntent(pi) + .setSmallIcon(R.drawable.ic_notification) + .setTicker(context.getString(R.string.test_ticker)) + .setSubText(context.getString(R.string.test_subtext)) + .setContentTitle(context.getString(R.string.test_content_title)) + .setContentText(context.getString(R.string.test_content_text)) + .setContentInfo(context.getString(R.string.test_content_info)) + .setStyle( + NotificationCompat.BigTextStyle() .setBigContentTitle(context.getString(R.string.test_big_content_title)) .setSummaryText(context.getString(R.string.test_big_content_summary)) .bigText(context.getString(R.string.test_big_content_text)) - ) - notificationManager.notify(0, builder.build()) - } - }, 5000) + ) + notificationManager.notify(0, builder.build()) + } } @VNPreview diff --git a/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt b/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt index 2efde26..97c66bd 100644 --- a/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt +++ b/app/src/main/java/com/pilot51/voicenotify/NotifyList.kt @@ -102,7 +102,7 @@ private fun ItemList(list: List) { onShowIgnore = { ignoreDialogApp = item.app } ) if (index < list.lastIndex) { - Divider( + HorizontalDivider( modifier = Modifier.padding(vertical = 16.dp), color = Color.Gray, thickness = 1.dp @@ -203,7 +203,7 @@ private fun DetailDialog( ) { Text(item.time) item.app?.apply { - var isEnabled by remember(this) { mutableStateOf(enabled) } + var isEnabled by remember(this) { mutableStateOf(isEnabled) } Text(text = label, fontSize = 24.sp) Text(packageName) Row( @@ -325,7 +325,7 @@ private fun IgnoreDialog( confirmButton = { val context = LocalContext.current TextButton(onClick = { - app.setEnabledWithToast(!app.enabled, context) + app.setEnabledWithToast(!app.isEnabled, context) onDismiss() }) { Text(stringResource(R.string.yes)) @@ -339,7 +339,7 @@ private fun IgnoreDialog( title = { Text( text = stringResource( - if (app.enabled) R.string.ignore_app else R.string.unignore_app, + if (app.isEnabled) R.string.ignore_app else R.string.unignore_app, app.label ), modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt index 5bdd12e..c172547 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt @@ -22,7 +22,6 @@ import android.media.AudioManager import android.net.Uri import android.os.Build import android.speech.tts.TextToSpeech -import android.text.format.DateFormat import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -38,7 +37,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -476,8 +474,7 @@ fun QuietTimeDialog( } ?: DEFAULT_QUIET_TIME val timePickerState = rememberTimePickerState( initialHour = quietTime / 60, - initialMinute = quietTime % 60, - key = settings + initialMinute = quietTime % 60 ) AlertDialog( onDismissRequest = onDismiss, @@ -511,24 +508,6 @@ fun QuietTimeDialog( ) } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun rememberTimePickerState( - initialHour: Int = 0, - initialMinute: Int = 0, - is24Hour: Boolean = DateFormat.is24HourFormat(LocalContext.current), - key: Any -): TimePickerState = rememberSaveable( - saver = TimePickerState.Saver(), - inputs = arrayOf(key) -) { - TimePickerState( - initialHour = initialHour, - initialMinute = initialMinute, - is24Hour = is24Hour, - ) -} - @Composable fun BackupDialog(onDismiss: () -> Unit) { val exportBackupLauncher = rememberLauncherForActivityResult( diff --git a/app/src/main/java/com/pilot51/voicenotify/Service.kt b/app/src/main/java/com/pilot51/voicenotify/Service.kt index fd89d77..6e7055b 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Service.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Service.kt @@ -30,7 +30,6 @@ import android.media.AudioManager import android.media.AudioManager.OnModeChangedListener import android.os.Build import android.os.Bundle -import android.os.IBinder import android.os.PowerManager import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification @@ -62,18 +61,21 @@ import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_SPEAK_SCREEN_OFF import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_SPEAK_SCREEN_ON import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_SPEAK_SILENT_ON import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_TTS_STREAM -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.* import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds class Service : NotificationListenerService() { private val appContext by ::applicationContext + private val ioScope = CoroutineScope(Dispatchers.IO) private val lastMsg = mutableMapOf() private val lastMsgTime = mutableMapOf() private var tts: TextToSpeech? = null @@ -81,15 +83,17 @@ class Service : NotificationListenerService() { private lateinit var audioMan: AudioManager private lateinit var telephonyMan: TelephonyManager private val stateReceiver = DeviceStateReceiver() - private var repeater: RepeatTimer? = null + private var repeaterJob: Job? = null private val shake by lazy { Shake(appContext) } private val repeatList = mutableListOf() - private val audioFocusRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + @get:RequiresApi(26) + @delegate:RequiresApi(26) + private val audioFocusRequest by lazy { AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) .setAudioAttributes(AudioAttributes.Builder() .setLegacyStreamType(AudioManager.STREAM_MUSIC).build()) .build() - } else null + } private val phoneStateListener by lazy { @Suppress("DEPRECATION") object : PhoneStateListener() { @@ -114,12 +118,11 @@ class Service : NotificationListenerService() { private val ttsQueue = linkedMapOf() override fun onCreate() { - val ioScope = CoroutineScope(Dispatchers.IO) ioScope.launch { isSuspended.collect { if (!it) return@collect tts?.run { - synchronized(ttsQueue) { + ttsQueueMutex.withLock { for (info in ttsQueue.values) { info.ignoreReasons.add(IgnoreReason.SUSPENDED) } @@ -128,7 +131,6 @@ class Service : NotificationListenerService() { } } } - super.onCreate() } private fun initTts(onInit: () -> Unit) { @@ -147,8 +149,12 @@ class Service : NotificationListenerService() { return@OnInitListener } tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { - override fun onStart(utteranceId: String) {} + override fun onStart(utteranceId: String) { + Log.d(TAG, "TTS starting utterance ID $utteranceId") + speakingUtteranceId = utteranceId.toLong() + } override fun onStop(utteranceId: String, interrupted: Boolean) { + Log.d(TAG, "Stopped utterance ID $utteranceId - interrupted? $interrupted") if (interrupted) { val info = ttsQueue[utteranceId.toLong()] if (info != null) { @@ -164,31 +170,27 @@ class Service : NotificationListenerService() { } override fun onDone(utteranceId: String) { - synchronized(ttsQueue) { ttsQueue.remove(utteranceId.toLong()) } - if (ttsQueue.isEmpty()) { - if (shouldRequestFocus) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioMan.abandonAudioFocusRequest(audioFocusRequest!!) - } else { - @Suppress("DEPRECATION") - audioMan.abandonAudioFocus(null) - } + Log.d(TAG, "Completed utterance ID $utteranceId") + speakingUtteranceId = null + ioScope.launch { + ttsQueueMutex.withLock { + ttsQueue.remove(utteranceId.toLong()) } - shake.disable() - shutdownTts() + if (ttsQueue.isEmpty()) onDoneSpeaking() } } @Deprecated("Deprecated in Java") override fun onError(utteranceId: String) { - Log.e(TAG, "Utterance error") + Log.e(TAG, "Error on utterance ID $utteranceId") + speakingUtteranceId = null } }) }) } - private fun restartTts() { - synchronized(ttsQueue) { + private suspend fun restartTts() { + ttsQueueMutex.withLock { ttsQueue.values.forEach { if (it.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) return@forEach it.ignoreReasons.add(IgnoreReason.TTS_RESTARTED) @@ -198,37 +200,55 @@ class Service : NotificationListenerService() { } shutdownTts() initTts { - CoroutineScope(Dispatchers.IO).launch { - synchronized(ttsQueue) { - val queueIterator = ttsQueue.iterator() - queueIterator.forEach { - val info = it.value - val isFailed = tts?.speak( - info.ttsMessage, TextToSpeech.QUEUE_ADD, - getTtsParams(info.settings), it.key.toString() - ) != TextToSpeech.SUCCESS - if (isFailed) { - Log.e(TAG, "Error adding notification to queue after TTS restart. Not retrying again.") - info.ignoreReasons.add(IgnoreReason.TTS_FAILED) - info.isInterrupted = false - queueIterator.remove() - } else if (info.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) { - info.isInterrupted = true - } - NotifyList.updateInfo(info) + ttsQueueMutex.launchWithLock { + val queueIterator = ttsQueue.iterator() + queueIterator.forEach { + val info = it.value + val isFailed = tts?.speak( + info.ttsMessage, TextToSpeech.QUEUE_ADD, + getTtsParams(info.settings), it.key.toString() + ) != TextToSpeech.SUCCESS + if (isFailed) { + Log.e(TAG, "Error adding notification to queue after TTS restart. Not retrying again.") + info.ignoreReasons.add(IgnoreReason.TTS_FAILED) + info.isInterrupted = false + queueIterator.remove() + } else if (info.ignoreReasons.contains(IgnoreReason.TTS_FAILED)) { + info.isInterrupted = true } + NotifyList.updateInfo(info) + } + Log.d(TAG, "Messages in TTS queue after restart: ${ttsQueue.size}") + if (ttsQueue.isEmpty()) { + onDoneSpeaking() } } } } + private fun onDoneSpeaking() { + if (shouldRequestFocus) { + Log.d(TAG, "Abandoning audio focus") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioMan.abandonAudioFocusRequest(audioFocusRequest!!) + } else { + @Suppress("DEPRECATION") + audioMan.abandonAudioFocus(null) + } + } + shake.disable() + shutdownTts() + } + private fun shutdownTts() { - tts?.shutdown() - tts = null + tts?.run { + tts = null + shutdown() + } } override fun onNotificationPosted(sbn: StatusBarNotification) { - CoroutineScope(Dispatchers.IO).launch { + ioScope.launch { val notification = sbn.notification val app = Common.findOrAddApp(sbn.packageName) val settings = getCombinedSettings(app) @@ -239,7 +259,7 @@ class Service : NotificationListenerService() { val info = NotificationInfo(app, notification, settings) val msgTime = info.calendar.timeInMillis val ttsMsg = info.ttsMessage - if (app != null && !app.enabled) { + if (app != null && !app.isEnabled) { info.ignoreReasons.add(IgnoreReason.APP) } if (info.isEmpty && settings.ignoreEmpty ?: DEFAULT_IGNORE_EMPTY) { @@ -273,31 +293,24 @@ class Service : NotificationListenerService() { if (!isScreenOn()) { val interval = settings.ttsRepeat ?: 0.0 if (interval > 0) { - synchronized(repeatList) { repeatList.add(info) } - if (repeater == null) { - repeater = RepeatTimer(interval) - } + repeatListMutex.withLock { repeatList.add(info) } + startRepeatTimer(interval) } } - 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) - } + launch prepSpeak@{ + delay(delay.seconds) + val ignoreReasons = ignore(info.settings) + if (ignoreReasons.isNotEmpty()) { + Log.i(TAG, "Notification ignored for reason(s): ${ignoreReasons.joinToString()}") + info.ignoreReasons.addAll(ignoreReasons) + return@prepSpeak } - }, (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. + speak(info) + } lastMsg[app] = ttsMsg lastMsgTime[app] = msgTime } else { - Log.i(TAG, "Notification from " + app?.label - + " ignored for reason(s): " + info.getIgnoreReasonsAsText()) + Log.i(TAG, "Notification from ${app?.label} ignored for reason(s): ${info.getIgnoreReasonsAsText()}") } } } @@ -308,41 +321,53 @@ class Service : NotificationListenerService() { * Send a notification to be spoken by TTS. * @param info The info for the notification to be spoken. */ - private fun speak(info: NotificationInfo) { + private suspend fun speak(info: NotificationInfo) { if (!isRunning.value) { Log.w(TAG, "Speak failed due to service destroyed") info.ignoreReasons.add(IgnoreReason.SERVICE_STOPPED) NotifyList.updateInfo(info) return } + if (ttsQueue.any { it.key != speakingUtteranceId && it.value == info }) { + Log.d(TAG, "Notification already waiting in TTS queue, not adding again") + return + } if ((info.ttsMessage?.length ?: 0) > TextToSpeech.getMaxSpeechInputLength()) { info.ignoreReasons.add(IgnoreReason.TTS_LENGTH_LIMIT) info.isInterrupted = true } - val notificationTime = info.calendar.timeInMillis - synchronized(ttsQueue) { + val utteranceId = ++lastQueuedUtteranceId + ttsQueueMutex.withLock { if (ttsQueue.isEmpty()) { //if there are no messages in the queue, start up shake detection and audio focus requesting shake.enable() shouldRequestFocus = info.settings.audioFocus ?: DEFAULT_AUDIO_FOCUS if (shouldRequestFocus) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioMan.requestAudioFocus(audioFocusRequest!!) + audioMan.requestAudioFocus(audioFocusRequest) } else { @Suppress("DEPRECATION") audioMan.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + }.let { + val focusResult = when (it) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> "granted" + AudioManager.AUDIOFOCUS_REQUEST_FAILED -> "failed" + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> "delayed" + else -> " result unknown ($it)" + } + Log.d(TAG, "Audio focus request $focusResult") } } } //regardless, add the message to the queue, parallelling the TextToSpeech queue since we can't access it. - ttsQueue.put(notificationTime, info) + ttsQueue.put(utteranceId, info) } //once the message is in our queue, send it to the real one with the necessary parameters - val utteranceId = notificationTime.toString() + Log.d(TAG, "Adding to ttsQueue with utterance ID $utteranceId") initTts { - CoroutineScope(Dispatchers.IO).launch { + ioScope.launch { val isSpeakFailed = tts?.speak( - info.ttsMessage, TextToSpeech.QUEUE_ADD, getTtsParams(info.settings), utteranceId + info.ttsMessage, TextToSpeech.QUEUE_ADD, getTtsParams(info.settings), utteranceId.toString() ) != TextToSpeech.SUCCESS if (isSpeakFailed) { Log.e(TAG, "Error adding notification to TTS queue. Attempting to restart TTS.") @@ -396,32 +421,29 @@ class Service : NotificationListenerService() { return ignoreReasons } - private inner class RepeatTimer(minuteInterval: Double) : TimerTask() { - init { - if (minuteInterval > 0) { - val interval = (minuteInterval * 60000).toLong() - Timer().schedule(this, interval, interval) - } - } - - override fun run() { - synchronized(repeatList) { - for (info in repeatList) { - if (ignore(info.settings).isNotEmpty()) continue - speak(info) + private fun startRepeatTimer(minuteInterval: Double) { + if (minuteInterval <= 0 || repeaterJob != null) return + repeaterJob = ioScope.launch { + while (isActive) { + delay(minuteInterval.minutes) + if (isScreenOn()) { + Log.d(TAG, "Screen is on, canceling repeater") + cancel() + repeaterJob = null + } + repeatListMutex.withLock { + for (info in repeatList) { + if (ignore(info.settings).isNotEmpty()) continue + speak(info) + } } } } - - override fun cancel(): Boolean { - repeater = null - return super.cancel() - } } - - override fun onBind(intent: Intent): IBinder? { - if (isRunning.value) return super.onBind(intent) + override fun onListenerConnected() { + Log.i(TAG, "Notification listener connected") + if (isRunning.value) return audioMan = getSystemService(AUDIO_SERVICE) as AudioManager if (usePhoneState) { telephonyMan = getSystemService(TELEPHONY_SERVICE) as TelephonyManager @@ -441,7 +463,7 @@ class Service : NotificationListenerService() { registerReceiver(stateReceiver, filter) shake.onShake = { Log.i(TAG, "TTS silenced by shake") - synchronized(ttsQueue) { + ttsQueueMutex.launchWithLock { for (info in ttsQueue.values) { info.ignoreReasons.add(IgnoreReason.SHAKE) NotifyList.updateInfo(info) @@ -450,10 +472,10 @@ class Service : NotificationListenerService() { shutdownTts() } setInitialized(true) - return super.onBind(intent) } - override fun onUnbind(intent: Intent): Boolean { + override fun onListenerDisconnected() { + Log.i(TAG, "Notification listener disconnected") if (isRunning.value) { shutdownTts() if (usePhoneState) { @@ -465,12 +487,6 @@ class Service : NotificationListenerService() { unregisterReceiver(stateReceiver) setInitialized(false) } - return false - } - - override fun onDestroy() { - shutdownTts() - super.onDestroy() } private fun setInitialized(initialized: Boolean) { @@ -534,13 +550,16 @@ class Service : NotificationListenerService() { private inner class DeviceStateReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val action = intent.action + Log.d(TAG, "Received device state: $action") var interruptIfIgnored = true when (action) { Intent.ACTION_SCREEN_ON -> { if (!isScreenOn()) return - if (repeater != null) { - repeater!!.cancel() - synchronized(repeatList) { repeatList.clear() } + repeaterJob?.let { + repeaterJob = null + it.cancel() + repeatListMutex.launchWithLock { repeatList.clear() } + Log.d(TAG, "Canceled repeater") } interruptIfIgnored = false } @@ -557,7 +576,7 @@ class Service : NotificationListenerService() { private fun processIgnoreForQueue() { tts?.run { - synchronized(ttsQueue) { + ttsQueueMutex.launchWithLock { for (info in ttsQueue.values) { val ignoreReasons = ignore(info.settings) if (ignoreReasons.isNotEmpty()) { @@ -578,10 +597,17 @@ class Service : NotificationListenerService() { } ?: gs } + /** Convenience for calling [withLock] inside a new [ioScope] coroutine. */ + private fun Mutex.launchWithLock(action: () -> T) = ioScope.launch { withLock(action = action) } + companion object { private val TAG = Service::class.simpleName + private val ttsQueueMutex = Mutex() + private val repeatListMutex = Mutex() private val usePhoneState = Build.VERSION.SDK_INT < Build.VERSION_CODES.S private val isInitialized = MutableStateFlow(false) + private var lastQueuedUtteranceId = 0L + private var speakingUtteranceId: Long? = null val isRunning: StateFlow = isInitialized var isSuspended = MutableStateFlow(false) diff --git a/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt b/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt index dfe0a46..dddc645 100644 --- a/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt @@ -29,6 +29,7 @@ fun TtsConfigScreen(vm: IPreferencesViewModel) { val context = LocalContext.current val configApp by vm.configuringAppState.collectAsState() val settings by vm.configuringSettingsState.collectAsState() + val settingsCombo by vm.configuringSettingsComboState.collectAsState() var ttsEnabled by remember { mutableStateOf(true) } var ttsSummary by remember { mutableStateOf("") } var ttsIntent by remember { mutableStateOf(null) } @@ -80,7 +81,7 @@ fun TtsConfigScreen(vm: IPreferencesViewModel) { titleRes = R.string.tts_speak_emojis, summaryResOn = R.string.tts_speak_emojis_summary_on, summaryResOff = R.string.tts_speak_emojis_summary_off, - initialValue = settings.ttsSpeakEmojis ?: DEFAULT_SPEAK_EMOJIS, + initialValue = settingsCombo.ttsSpeakEmojis ?: DEFAULT_SPEAK_EMOJIS, app = configApp, showRemove = !settings.isGlobal && settings.ttsSpeakEmojis != null, onRemove = { diff --git a/app/src/main/java/com/pilot51/voicenotify/Utils.kt b/app/src/main/java/com/pilot51/voicenotify/Utils.kt index 6ff3b1d..1a3b15a 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Utils.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Utils.kt @@ -20,9 +20,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration fun T.isAny(vararg list: T) = list.any { this == it } +/** Same as [withTimeout] except [block] is interrupted when it times out. */ +@Throws(TimeoutCancellationException::class) +suspend fun withTimeoutInterruptible(timeout: Duration, block: () -> T) = + withTimeout(timeout) { runInterruptible(block = block) } + val isPreview @Composable get() = LocalInspectionMode.current @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/app/src/main/java/com/pilot51/voicenotify/db/App.kt b/app/src/main/java/com/pilot51/voicenotify/db/App.kt index 17d21f2..b7f771f 100644 --- a/app/src/main/java/com/pilot51/voicenotify/db/App.kt +++ b/app/src/main/java/com/pilot51/voicenotify/db/App.kt @@ -19,7 +19,6 @@ import android.content.Context import android.widget.Toast import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.Ignore import androidx.room.PrimaryKey import com.pilot51.voicenotify.R import com.pilot51.voicenotify.db.AppDatabase.Companion.db @@ -33,26 +32,21 @@ data class App( @ColumnInfo(name = "package") val packageName: String, @ColumnInfo(name = "name", collate = ColumnInfo.NOCASE) - val label: String, + var label: String, @ColumnInfo(name = "is_enabled") - var isEnabled: Boolean? + var isEnabled: Boolean ) { - @get:Ignore - var enabled: Boolean - get() = isEnabled!! - set(value) { isEnabled = value } - /** * Updates self in database. * @return This instance. */ suspend fun updateDb(): App { - db.appDao.addOrUpdateApp(this) + db.appDao.update(this) return this } fun setEnabled(enable: Boolean, updateDb: Boolean = true) { - enabled = enable + isEnabled = enable if (updateDb) CoroutineScope(Dispatchers.IO).launch { db.appDao.updateAppEnable(this@App) } @@ -72,7 +66,7 @@ data class App( /** Removes self from database. */ fun remove() { CoroutineScope(Dispatchers.IO).launch { - db.appDao.removeApp(this@App) + db.appDao.delete(this@App) } } } 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 221241c..1a3cf99 100644 --- a/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt +++ b/app/src/main/java/com/pilot51/voicenotify/db/AppDatabase.kt @@ -40,6 +40,15 @@ abstract class AppDatabase : RoomDatabase() { @Insert suspend fun insert(entity: T) + @Insert + suspend fun insert(entities: Collection) + + @Update + suspend fun update(entity: T) + + @Update + suspend fun update(entity: Collection) + @Upsert suspend fun upsert(entity: T) @@ -55,59 +64,17 @@ abstract class AppDatabase : RoomDatabase() { @Query("SELECT * FROM apps") suspend fun getAll(): List - @Query("SELECT EXISTS (SELECT * FROM apps WHERE package = :pkg)") - suspend fun existsByPackage(pkg: String): Boolean - - @Query("UPDATE apps SET name = :name, is_enabled = :enabled WHERE package = :pkg") - suspend fun updateByPackage(pkg: String, name: String, enabled: Boolean) - - /** - * Updates app in database matching package name or adds if no match found. - * @param app The app to add or update in the database. - */ - @Transaction - suspend fun addOrUpdateApp(app: App) { - if (existsByPackage(app.packageName)) { - updateByPackage( - pkg = app.packageName, - name = app.label, - enabled = app.enabled - ) - } else { - insert(app) - } - } - - @Transaction - suspend fun upsert(apps: List) { - apps.forEach { - addOrUpdateApp(it) - } - } - /** * Updates enabled value of app in database matching package name. * @param app The app to update in the database. */ @Transaction suspend fun updateAppEnable(app: App) { - updateAppEnable(app.packageName, app.enabled) + updateAppEnable(app.packageName, app.isEnabled) } @Query("UPDATE apps SET is_enabled = :enabled WHERE package = :pkg") - suspend fun updateAppEnable(pkg: String, enabled: Boolean?) - - /** - * Removes app from database matching package name. - * @param app The app to remove from the database. - */ - @Transaction - suspend fun removeApp(app: App) { - removeApp(app.packageName) - } - - @Query("DELETE FROM apps WHERE package = :pkg") - suspend fun removeApp(pkg: String) + suspend fun updateAppEnable(pkg: String, enabled: Boolean) } @Dao diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd4777c..5caa224 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,7 +212,7 @@ Headset off Headset on Silenced by shake - Voice Notify suspended by widget + Voice Notify suspended Service stopped TTS failed. Restart and retry attempted.\nThis text should be yellow if retry successful or red if not.\nPlease try restarting the Voice Notify service if notifications fail to be spoken. TTS restarted while message in queue or speaking. Re-queued. diff --git a/build.gradle.kts b/build.gradle.kts index f5e5874..94ead1f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false + alias(libs.plugins.ksp) apply false } buildscript { @@ -8,8 +8,8 @@ buildscript { google() } dependencies { - classpath("com.android.tools.build:gradle:8.4.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24") + classpath(libs.android.gradlePlugin) + classpath(libs.kotlin.gradlePlugin) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..1e99343 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] +accompanist = "0.36.0" +androidGradlePlugin = "8.7.3" +androidxActivity = "1.9.3" +androidxCompose = "1.7.6" +androidxComposeMaterial3 = "1.3.1" +androidxCore = "1.13.1" +androidxGlance = "1.1.1" +androidxNavigation = "2.8.5" +androidxPreference = "1.2.1" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" +room = "2.6.1" + +[libraries] +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" } +androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "androidxCompose" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxCompose" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "androidxGlance" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } +androidx-preference = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidxPreference" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } + +[plugins] +android-application = { id = "com.android.application" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8a9abd1..1111d48 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun May 19 13:37:27 EDT 2024 +#Wed Dec 11 02:15:13 EST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists