From c59fa5b667100e0e0521fdbe87470b7e52328b43 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sun, 16 Apr 2023 18:36:59 +0200 Subject: [PATCH] Let users pick media player apps --- .../preferences/CheckboxPreference.kt | 27 ++++++ .../media/MediaIntegrationSettingsScreen.kt | 75 +++++++++++----- .../media/MediaIntegrationSettingsScreenVM.kt | 89 +++++++++++++++++-- core/i18n/src/main/res/values/strings.xml | 1 + .../de/mm20/launcher2/preferences/Defaults.kt | 1 - .../preferences/src/main/proto/settings.proto | 4 +- .../de/mm20/launcher2/music/MusicService.kt | 52 ++++++----- 7 files changed, 194 insertions(+), 55 deletions(-) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/CheckboxPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/CheckboxPreference.kt index 79a3fd83..9e7fe3e0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/CheckboxPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/CheckboxPreference.kt @@ -29,4 +29,31 @@ fun CheckboxPreference( ) } ) +} + +@Composable +fun CheckboxPreference( + title: String, + icon: @Composable () -> Unit, + iconPadding: Boolean = true, + summary: String? = null, + value: Boolean, + onValueChanged: (Boolean) -> Unit, + enabled: Boolean = true +) { + Preference( + title = title, + icon = icon, + iconPadding = iconPadding, + summary = summary, + enabled = enabled, + onClick = { + onValueChanged(!value) + }, + controls = { + Checkbox( + enabled = enabled, checked = value, onCheckedChange = onValueChanged, + ) + } + ) } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreen.kt index 9cc4b595..d767142c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreen.kt @@ -2,52 +2,87 @@ package de.mm20.launcher2.ui.settings.media import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.ui.BuildConfig import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.component.ShapedLauncherIcon +import de.mm20.launcher2.ui.component.preferences.CheckboxPreference import de.mm20.launcher2.ui.component.preferences.Preference import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen -import de.mm20.launcher2.ui.component.preferences.SwitchPreference @Composable fun MediaIntegrationSettingsScreen() { val context = LocalContext.current val viewModel: MediaIntegrationSettingsScreenVM = viewModel() - val hasPermission by viewModel.hasPermission.observeAsState() + val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(null) + val loading by viewModel.loading + + val density = LocalDensity.current + + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(null) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.onResume(density = density.density) + } + } + PreferenceScreen( - stringResource(R.string.preference_screen_musicwidget), - helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/widgets/clock" + stringResource(R.string.preference_media_integration), ) { + if (loading) { + item { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } item { - PreferenceCategory { - AnimatedVisibility(hasPermission == false) { - MissingPermissionBanner( - text = stringResource(R.string.missing_permission_music_widget), - onClick = { - viewModel.requestNotificationPermission(context as AppCompatActivity) + AnimatedVisibility(hasPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_music_widget), + onClick = { + viewModel.requestNotificationPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + PreferenceCategory( + stringResource(R.string.preference_category_media_apps) + ) { + val apps by viewModel.appList + for (app in apps) { + val icon by app.icon.collectAsState(null) + CheckboxPreference( + icon = { + ShapedLauncherIcon(size = 32.dp, icon = { icon }) }, - modifier = Modifier.padding(16.dp) + title = app.label, + value = app.isChecked, + onValueChanged = { + viewModel.onAppChecked(app, it) + } ) } - val filterSources by viewModel.filterSources.observeAsState(false) - SwitchPreference( - title = stringResource(R.string.preference_music_filter_sources), - summary = stringResource(R.string.preference_music_filter_sources_summary), - value = filterSources, - onValueChanged = { - viewModel.setFilterSources(it) - } - ) } } if (BuildConfig.DEBUG) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt index e14b4d42..8afa8454 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt @@ -1,46 +1,117 @@ package de.mm20.launcher2.ui.settings.media import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.applications.AppRepository +import de.mm20.launcher2.icons.IconService +import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.music.MusicService import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.math.roundToInt class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent { private val permissionsManager: PermissionsManager by inject() private val musicService: MusicService by inject() + private val appRepository: AppRepository by inject() + private val iconService: IconService by inject() private val dataStore: LauncherDataStore by inject() val hasPermission = - permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData() + permissionsManager.hasPermission(PermissionGroup.Notifications) + + val loading = mutableStateOf(false) fun requestNotificationPermission(activity: AppCompatActivity) { permissionsManager.requestPermission(activity, PermissionGroup.Notifications) } - val filterSources = dataStore.data.map { it.musicWidget.filterSources }.asLiveData() - fun setFilterSources(filterSources: Boolean) { + val appList = mutableStateOf(emptyList()) + + fun resetWidget() { + musicService.resetPlayer() + } + + fun onResume(density: Float) { + loading.value = true + viewModelScope.launch(Dispatchers.Default) { + val musicApps = musicService.getInstalledPlayerPackages() + val allApps = appRepository.getAllInstalledApps().first().filter { it.isMainProfile } + .distinctBy { it.`package` } + val settings = dataStore.data.map { it.musicWidget }.first() + val allowList = settings.allowListList + val denyList = settings.denyListList + + appList.value = allApps.map { + AppListItem( + label = it.label, + packageName = it.`package`, + isMusicApp = musicApps.contains(it.`package`), + isChecked = allowList.contains(it.`package`) || (!denyList.contains(it.`package`) && musicApps.contains( + it.`package` + )), + icon = iconService.getIcon(it, (32 * density).roundToInt()) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(10000)) + ) + }.sortedBy { it.label.normalize() } + loading.value = false + } + } + + fun onAppChecked(app: AppListItem, checked: Boolean) { + val list = appList.value.toMutableList() + val index = list.indexOf(app) + list[index] = app.copy(isChecked = checked) + appList.value = list + saveState() + } + + private fun saveState() { + val allowList = mutableListOf() + val denyList = mutableListOf() + val appList = appList.value + + for (app in appList) { + if (app.isChecked && !app.isMusicApp) { + allowList.add(app.packageName) + } else if (!app.isChecked && app.isMusicApp) { + denyList.add(app.packageName) + } + } viewModelScope.launch { dataStore.updateData { it.toBuilder() .setMusicWidget( it.musicWidget.toBuilder() - .setFilterSources(filterSources) + .clearAllowList() + .addAllAllowList(allowList) + .clearDenyList() + .addAllDenyList(denyList) ) .build() } } } - fun resetWidget() { - musicService.resetPlayer() - } +} -} \ No newline at end of file +data class AppListItem( + val label: String, + val packageName: String, + val isMusicApp: Boolean, + val isChecked: Boolean, + val icon: Flow, +) \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index b9a72a24..4c8da071 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -550,6 +550,7 @@ Configure widgets Weather Music + Media apps Clock Configure clock style and components Layout diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt index 35d94dbd..f6c9ecf8 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt @@ -34,7 +34,6 @@ fun createFactorySettings(context: Context): Settings { .setMusicWidget( Settings.MusicWidgetSettings .newBuilder() - .setFilterSources(true) .build() ) .setCalendarWidget( diff --git a/core/preferences/src/main/proto/settings.proto b/core/preferences/src/main/proto/settings.proto index ca3ef06d..bb75f81e 100644 --- a/core/preferences/src/main/proto/settings.proto +++ b/core/preferences/src/main/proto/settings.proto @@ -97,7 +97,9 @@ message Settings { WeatherSettings weather = 5; message MusicWidgetSettings { - bool filter_sources = 1; + reserved 1; + repeated string allow_list = 2; + repeated string deny_list = 3; } MusicWidgetSettings music_widget = 6; diff --git a/services/music/src/main/java/de/mm20/launcher2/music/MusicService.kt b/services/music/src/main/java/de/mm20/launcher2/music/MusicService.kt index 105feda1..65c8cf1c 100644 --- a/services/music/src/main/java/de/mm20/launcher2/music/MusicService.kt +++ b/services/music/src/main/java/de/mm20/launcher2/music/MusicService.kt @@ -4,11 +4,11 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.Bitmap import android.media.AudioManager import android.media.MediaMetadata -import android.media.Rating import android.media.session.MediaController import android.media.session.MediaSession import android.media.session.PlaybackState.CustomAction @@ -17,13 +17,11 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import android.service.notification.StatusBarNotification -import android.util.Log import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.content.edit import androidx.core.graphics.drawable.toBitmap import coil.imageLoader -import coil.request.ErrorResult import coil.request.ImageRequest import coil.size.Scale import de.mm20.launcher2.crashreporter.CrashReporter @@ -75,6 +73,8 @@ interface MusicService { fun openPlayerChooser(context: Context) + suspend fun getInstalledPlayerPackages(): Set + fun resetPlayer() } @@ -107,13 +107,15 @@ internal class MusicServiceImpl( private val currentMediaController: SharedFlow = combine( notificationRepository.notifications, - dataStore.data.map { it.musicWidget.filterSources } - ) { notifications, filter -> + dataStore.data.map { it.musicWidget } + ) { notifications, settings -> withContext(Dispatchers.Default) { - val musicApps = if (filter) getMusicApps() else null + val musicApps = getEnabledPlayerPackages( + settings.allowListList.toSet(), + settings.denyListList.toSet() + ) val sbn: StatusBarNotification? = notifications.filter { - it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null && - (musicApps?.contains(it.packageName) != false) + it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null && musicApps.contains(it.packageName) }.maxByOrNull { it.postTime } return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token) @@ -567,25 +569,27 @@ internal class MusicServiceImpl( } } - private fun getMusicApps(): Set { - // List of known music apps that don't have the correct intent filter - val apps = mutableSetOf( - "com.aspiro.tidal", // Tidal - "com.bandcamp.android", // Bandcamp - "com.qobuz.music", // Qobuz - "tv.plex.labs.plexamp", // Plexamp - "de.ph1b.audiobook", // Voice - "de.eindm.boum", // Boum - ) - var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } - apps.addAll(context.packageManager.queryIntentActivities(intent, 0) - .map { it.activityInfo.packageName }) - intent = Intent("android.intent.action.MUSIC_PLAYER") - apps.addAll(context.packageManager.queryIntentActivities(intent, 0) - .map { it.activityInfo.packageName }) + override suspend fun getInstalledPlayerPackages(): Set { + val apps = mutableSetOf() + withContext(Dispatchers.IO) { + var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } + apps.addAll(context.packageManager.queryIntentActivities(intent, 0) + .map { it.activityInfo.applicationInfo.packageName }) + intent = Intent("android.intent.action.MUSIC_PLAYER") + apps.addAll(context.packageManager.queryIntentActivities(intent, 0) + .map { it.activityInfo.applicationInfo.packageName }) + } return apps } + private suspend fun getEnabledPlayerPackages( + allowList: Set, + denyList: Set + ): Set { + val installed = getInstalledPlayerPackages() + return installed.union(allowList).subtract(denyList).toSet() + } + override fun resetPlayer() { scope.launch { preferences.edit {