feat: whitelist for notification listening

This commit is contained in:
2024-07-12 02:36:17 +02:00
parent edd5b24d92
commit 1a105f8413
10 changed files with 256 additions and 8 deletions
+2
View File
@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application <application
android:name=".LGApp" android:name=".LGApp"
@@ -35,6 +35,7 @@ import com.illusionman1212.lyricsgrabbr.ui.screens.HomePage
import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage
import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme
import com.illusionman1212.lyricsgrabbr.ui.screens.SettingsPage import com.illusionman1212.lyricsgrabbr.ui.screens.SettingsPage
import com.illusionman1212.lyricsgrabbr.ui.screens.WhitelistPage
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -130,6 +131,17 @@ class MainActivity : AppCompatActivity() {
} }
composable(Routes.Settings.route) { composable(Routes.Settings.route) {
SettingsPage( SettingsPage(
goBack = {
navController.popBackStack()
},
navigateToWhitelist = {
navController.navigate(Routes.Whitelist.route)
},
viewModel = settingsViewModel,
)
}
composable(Routes.Whitelist.route) {
WhitelistPage(
goBack = { goBack = {
navController.popBackStack() navController.popBackStack()
}, },
@@ -20,15 +20,29 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.BundleCompat import androidx.core.os.BundleCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class NotificationListener: NotificationListenerService() { class NotificationListener: NotificationListenerService() {
private var notificationManager : NotificationManagerCompat? = null private var notificationManager : NotificationManagerCompat? = null
private lateinit var settingsPrefsRepo: SettingsPreferencesRepository
private val serviceScope = CoroutineScope(Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
initNotificationChannels(this) initNotificationChannels(this)
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(applicationContext)
settingsPrefsRepo = (application as LGApp).settingsPreferencesRepository
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -53,8 +67,12 @@ class NotificationListener: NotificationListenerService() {
} }
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
if (sbn.packageName !in whitelist) return@launch
sendNotificationBroadcast(sbn, applicationContext) sendNotificationBroadcast(sbn, applicationContext)
} }
}
private fun getStopServiceNotificationAction(): PendingIntent { private fun getStopServiceNotificationAction(): PendingIntent {
val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -91,10 +109,15 @@ class NotificationListener: NotificationListenerService() {
private fun getCurrentNotifications(context: Context) { private fun getCurrentNotifications(context: Context) {
val sbns = activeNotifications val sbns = activeNotifications
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
for (sbn in sbns) { for (sbn in sbns) {
if (sbn.packageName !in whitelist) continue
sendNotificationBroadcast(sbn, context) sendNotificationBroadcast(sbn, context)
} }
} }
}
private fun getInitialNotification(): Notification { private fun getInitialNotification(): Notification {
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
@@ -1,8 +1,9 @@
package com.illusionman1212.lyricsgrabbr package com.illusionman1212.lyricsgrabbr
sealed class Routes(val route: String) { sealed class Routes(val route: String) {
object Home : Routes("home") data object Home : Routes("home")
object Lyrics : Routes("lyrics") data object Lyrics : Routes("lyrics")
object Settings : Routes("settings") data object Settings : Routes("settings")
data object Whitelist : Routes("settings/whitelist")
} }
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
enum class Theme { enum class Theme {
@@ -14,6 +15,7 @@ enum class Theme {
data class SettingsPreferences( data class SettingsPreferences(
val appTheme: Int = Theme.SYSTEM.ordinal, val appTheme: Int = Theme.SYSTEM.ordinal,
val whitelist: Set<String> = emptySet(),
) )
class SettingsPreferencesRepository( class SettingsPreferencesRepository(
@@ -21,6 +23,7 @@ class SettingsPreferencesRepository(
) { ) {
companion object { companion object {
private val APP_THEME = intPreferencesKey("app_theme") private val APP_THEME = intPreferencesKey("app_theme")
private val WHITELIST = stringSetPreferencesKey("apps_whitelist")
} }
val preferences = dataStore.data.map { mapSettingsPreferences(it) } val preferences = dataStore.data.map { mapSettingsPreferences(it) }
@@ -31,9 +34,22 @@ class SettingsPreferencesRepository(
} }
} }
suspend fun addToWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) + packageName
}
}
suspend fun removeFromWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) - packageName
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences { private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences( return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal, prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[WHITELIST] ?: emptySet(),
) )
} }
} }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.Translate
@@ -61,6 +62,7 @@ private val Themes = hashMapOf(
@Composable @Composable
fun SettingsPage( fun SettingsPage(
goBack: () -> Unit, goBack: () -> Unit,
navigateToWhitelist: () -> Unit,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -94,6 +96,7 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Appearance(viewModel, uiState) Appearance(viewModel, uiState)
Listening(navigateToWhitelist)
} }
} }
} }
@@ -138,6 +141,35 @@ fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
} }
} }
@Composable
private fun Listening(navigateToWhitelist: () -> Unit) {
SettingGroup(title = stringResource(id = R.string.listening)) {
Box(
Modifier
.fillMaxWidth()
.clickable { navigateToWhitelist() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(imageVector = Icons.Default.Apps, contentDescription = null, modifier = Modifier.width(56.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(id = R.string.whitelist_apps), color = MaterialTheme.colorScheme.onBackground)
Text(
text = stringResource(id = R.string.whitelist_desc),
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp)
)
}
}
}
}
}
@Composable @Composable
fun ThemeDialog( fun ThemeDialog(
uiState: SettingsState, uiState: SettingsState,
@@ -0,0 +1,94 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WhitelistPage(goBack: () -> Unit, viewModel: SettingsViewModel) {
Scaffold(topBar = {
TopAppBar(
title = {
Text(text = stringResource(id = R.string.whitelist))
},
navigationIcon = {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
id = R.string.go_back
))
}
}
)
}) { padding ->
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Box(Modifier.padding(padding)) {
LazyColumn {
for ((packageName, appInfo) in uiState.applications) {
item {
var isWhitelisted by remember { mutableStateOf(appInfo.isWhitelisted) }
Box(
Modifier
.fillMaxWidth()
.clickable(
role = Role.Checkbox,
onClick = {
if (isWhitelisted) {
viewModel.removeFromWhitelist(packageName)
} else {
viewModel.addToWhitelist(packageName)
}
isWhitelisted = !isWhitelisted
}
)) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(modifier = Modifier.size(32.dp), model = appInfo.icon, contentDescription = null)
Text(appInfo.displayName, Modifier.weight(1f))
Checkbox(
checked = isWhitelisted,
onCheckedChange = null,
)
}
}
}
}
}
}
}
}
@@ -1,5 +1,8 @@
package com.illusionman1212.lyricsgrabbr.viewmodels package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -14,13 +17,21 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class AppInfo(
val displayName: String,
val icon: Drawable,
var isWhitelisted: Boolean,
)
data class SettingsState( data class SettingsState(
val appTheme: Int = Theme.SYSTEM.ordinal, val appTheme: Int = Theme.SYSTEM.ordinal,
val applications: MutableMap<String, AppInfo> = mutableMapOf(),
) )
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() { class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository, context: Context): ViewModel() {
private val _uiState = MutableStateFlow(SettingsState()) private val _uiState = MutableStateFlow(SettingsState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -34,7 +45,56 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
} }
} }
private suspend fun getWhitelistedApps(): Set<String> {
return settingsPrefsRepo.preferences.first().whitelist
}
private fun getPackages(context: Context) {
if (uiState.value.applications.isNotEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val pm = context.packageManager
val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA)
.filter { it.packageName != context.packageName && pm.getLaunchIntentForPackage(it.packageName) != null }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.loadLabel(pm).toString() })
val apps: MutableMap<String, AppInfo> = mutableMapOf()
val whitelistedApps = getWhitelistedApps()
packages.forEach { pkg ->
val label = pkg.loadLabel(pm)
val icon = pkg.loadIcon(pm)
apps[pkg.packageName] = AppInfo(
displayName = label.toString(),
icon = icon,
isWhitelisted = pkg.packageName in whitelistedApps
)
}
_uiState.update {
SettingsState(
appTheme = uiState.value.appTheme,
applications = apps,
)
}
}
}
fun addToWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value.applications[packageName]?.let { it.isWhitelisted = true }
settingsPrefsRepo.addToWhitelist(packageName)
}
}
fun removeFromWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value.applications[packageName]?.let { it.isWhitelisted = false }
settingsPrefsRepo.removeFromWhitelist(packageName)
}
}
init { init {
getPackages(context)
viewModelScope.launch { viewModelScope.launch {
settingsPrefsRepo.preferences.collectLatest { settingsPrefsRepo.preferences.collectLatest {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -48,7 +108,7 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val application = (this[APPLICATION_KEY] as LGApp) val application = (this[APPLICATION_KEY] as LGApp)
SettingsViewModel(application.settingsPreferencesRepository) SettingsViewModel(application.settingsPreferencesRepository, application.applicationContext)
} }
} }
} }
+4
View File
@@ -34,6 +34,10 @@
<string name="theme_system">استخدم مظهر النظام</string> <string name="theme_system">استخدم مظهر النظام</string>
<string name="language">اللغة</string> <string name="language">اللغة</string>
<string name="_default">الإفتراضي</string> <string name="_default">الإفتراضي</string>
<string name="listening">الإستماع</string>
<string name="whitelist">القائمة البيضاء</string>
<string name="whitelist_apps">تطبيقات القائمة البيضاء</string>
<string name="whitelist_desc">اختر التطبيقات التي سيستمع التطبيق إلى إشعاراتها</string>
<!-- Lyrics --> <!-- Lyrics -->
<string name="lyrics">كلمات الأغنية</string> <string name="lyrics">كلمات الأغنية</string>
+4
View File
@@ -33,6 +33,10 @@
<string name="theme_system">Use System Theme</string> <string name="theme_system">Use System Theme</string>
<string name="language">Language</string> <string name="language">Language</string>
<string name="_default">Default</string> <string name="_default">Default</string>
<string name="listening">Listening</string>
<string name="whitelist">Whitelist</string>
<string name="whitelist_apps">Whitelist Apps</string>
<string name="whitelist_desc">Choose which apps Lyrics Grabbr will listen to</string>
<!-- Lyrics --> <!-- Lyrics -->
<string name="lyrics">Lyrics</string> <string name="lyrics">Lyrics</string>