Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.media)
implementation(libs.google.material)

// Compose
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@
android:resource="@xml/lock_screen_accessibility_config" />
</service>

<service
android:name=".media.MediaNotificationListenerService"
android:exported="true"
android:label="@string/media_notification_listener_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<receiver
android:name=".accessibility.LongLockAlarmReceiver"
android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.lu4p.fokuslauncher.data.model.DrawerAppSortMode
import com.lu4p.fokuslauncher.data.model.FavoriteApp
import com.lu4p.fokuslauncher.data.model.drawerOpenCountKey
Expand Down Expand Up @@ -90,6 +91,9 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v
private val TEMPERATURE_UNIT_KEY = stringPreferencesKey("temperature_unit")
private val SHOW_HOME_WEATHER_KEY = booleanPreferencesKey("show_home_weather")
private val SHOW_HOME_BATTERY_KEY = booleanPreferencesKey("show_home_battery")
/** Opt-in media widget; off by default since no apps are registered yet. */
private val SHOW_HOME_MEDIA_KEY = booleanPreferencesKey("show_home_media")
/** Package names of media apps the user registered for the widget to connect to. */
/** Vertical category sidebar in the drawer instead of chips + search bar. */
private val DRAWER_SIDEBAR_CATEGORIES_KEY =
booleanPreferencesKey("drawer_sidebar_categories")
Expand Down Expand Up @@ -387,6 +391,9 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v
val showHomeBatteryFlow: Flow<Boolean> = prefFlow(SHOW_HOME_BATTERY_KEY, true)
suspend fun setShowHomeBattery(show: Boolean) = setPref(SHOW_HOME_BATTERY_KEY, show)

val showHomeMediaFlow: Flow<Boolean> = prefFlow(SHOW_HOME_MEDIA_KEY, false)
suspend fun setShowHomeMedia(show: Boolean) = setPref(SHOW_HOME_MEDIA_KEY, show)

val homeWidgetVisibilityFlow: Flow<HomeWidgetVisibility> =
combine(
showHomeClockFlow,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package com.lu4p.fokuslauncher.media

import android.os.Bundle
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.RatingCompat
import android.support.v4.media.session.PlaybackStateCompat

/** A like or save control advertised by the active app through [PlaybackStateCompat] custom actions. */
data class MediaCustomActionButton(
val actionId: String,
val label: String,
val active: Boolean,
val extras: Bundle?,
)

/** Maps session custom actions to like/save buttons using action id and label heuristics. */
object MediaCustomActionsReader {

fun likeButton(
state: PlaybackStateCompat?,
metadata: MediaMetadataCompat? = null,
): MediaCustomActionButton? =
bestMatch(state?.customActions.orEmpty(), ActionKind.LIKE, metadata)

fun saveButton(
state: PlaybackStateCompat?,
metadata: MediaMetadataCompat? = null,
): MediaCustomActionButton? =
bestMatch(state?.customActions.orEmpty(), ActionKind.SAVE, metadata)

private enum class ActionKind {
LIKE,
SAVE,
}

private fun bestMatch(
actions: List<PlaybackStateCompat.CustomAction>,
kind: ActionKind,
metadata: MediaMetadataCompat?,
): MediaCustomActionButton? =
actions
.mapNotNull { action -> score(action, kind, metadata) }
.maxByOrNull { it.second }
?.first

private fun score(
action: PlaybackStateCompat.CustomAction,
kind: ActionKind,
metadata: MediaMetadataCompat?,
): Pair<MediaCustomActionButton, Int>? {
val actionId = action.action.orEmpty()
val label = action.name?.toString().orEmpty()
val haystack = "$actionId $label".lowercase()
if (haystack.isBlank()) return null

val otherKind = if (kind == ActionKind.LIKE) ActionKind.SAVE else ActionKind.LIKE
val thisScore = keywordScore(haystack, keywords(kind))
if (thisScore <= 0) return null
val otherScore = keywordScore(haystack, keywords(otherKind))
if (otherScore >= thisScore) return null

val active = resolveActive(haystack, action.extras, metadata, kind)
return MediaCustomActionButton(
actionId = actionId,
label = label.ifBlank { defaultLabel(kind, active) },
active = active,
extras = action.extras,
) to thisScore
}

private fun resolveActive(
haystack: String,
extras: Bundle?,
metadata: MediaMetadataCompat?,
kind: ActionKind,
): Boolean {
extrasIndicateActive(extras)?.let { return it }
if (kind == ActionKind.LIKE) {
metadataIndicatesLiked(metadata)?.let { return it }
}
return isActiveFromLabel(haystack)
}

private fun extrasIndicateActive(extras: Bundle?): Boolean? {
extras ?: return null
for (key in extras.keySet()) {
val normalizedKey = key.lowercase()
if (
normalizedKey !in ACTIVE_EXTRA_KEYS &&
normalizedKey !in INACTIVE_EXTRA_KEYS
) {
continue
}
when (val value = extras.get(key)) {
is Boolean ->
return if (normalizedKey in INACTIVE_EXTRA_KEYS) !value else value
is Int ->
return when {
value == 1 -> normalizedKey !in INACTIVE_EXTRA_KEYS
value == 0 -> false
else -> null
}
is String -> {
val normalized = value.lowercase()
if (normalized in ACTIVE_EXTRA_VALUES) return true
if (normalized in INACTIVE_EXTRA_VALUES) return false
}
}
}
return null
}

private fun metadataIndicatesLiked(metadata: MediaMetadataCompat?): Boolean? {
val rating = metadata?.getRating(MediaMetadataCompat.METADATA_KEY_USER_RATING) ?: return null
if (!rating.isRated) return false
return when (rating.ratingStyle) {
RatingCompat.RATING_HEART -> rating.hasHeart()
RatingCompat.RATING_THUMB_UP_DOWN -> rating.isThumbUp
else -> null
}
}

/** Liked/saved items usually expose a remove action; new items expose add/save. */
private fun isActiveFromLabel(haystack: String): Boolean {
if (INACTIVE_LABEL_PATTERNS.any { it in haystack }) return false
return ACTIVE_LABEL_PATTERNS.any { it in haystack }
}

private fun keywords(kind: ActionKind): List<String> =
when (kind) {
ActionKind.LIKE ->
listOf(
"like",
"thumb",
"favorite",
"favourite",
"heart",
"love",
"add_to_liked",
"liked_songs",
)
ActionKind.SAVE ->
listOf(
"save",
"library",
"collection",
"bookmark",
"add_to_library",
"add_to_collection",
"add_song",
"your_episodes",
)
}

private fun keywordScore(haystack: String, keywords: List<String>): Int {
var score = 0
for (keyword in keywords) {
if (keyword in haystack) score += 10 + keyword.length
}
return score
}

private fun defaultLabel(kind: ActionKind, active: Boolean): String =
when (kind) {
ActionKind.LIKE -> if (active) "Liked" else "Like"
ActionKind.SAVE -> if (active) "Saved" else "Save"
}

private val ACTIVE_LABEL_PATTERNS =
listOf(
"remove from",
"remove_from",
"delete from",
"delete_from",
"unlike",
"unsave",
"remove",
)

private val INACTIVE_LABEL_PATTERNS =
listOf(
"add to",
"add_to",
"save to",
"save_to",
"download",
)

private val ACTIVE_EXTRA_KEYS =
setOf(
"active",
"enabled",
"liked",
"saved",
"is_liked",
"is_saved",
"favorite",
"favourite",
"in_library",
"checked",
"selected",
)

private val INACTIVE_EXTRA_KEYS =
setOf(
"inactive",
"disabled",
"unchecked",
"unselected",
)

private val ACTIVE_EXTRA_VALUES =
setOf("true", "1", "on", "yes", "liked", "saved", "active", "checked", "selected")

private val INACTIVE_EXTRA_VALUES =
setOf("false", "0", "off", "no", "inactive", "unchecked", "unselected")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.lu4p.fokuslauncher.media

import android.support.v4.media.MediaMetadataCompat

/** Reads now-playing fields the way Android media notifications do (getText + description). */
object MediaMetadataReader {

fun trackTitle(metadata: MediaMetadataCompat?): String? {
metadata ?: return null
return metadata.readText(MediaMetadataCompat.METADATA_KEY_TITLE)
?: metadata.readText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)
?: metadata.description.title?.toString()?.trim()?.takeIf { it.isNotBlank() }
}

fun artistName(metadata: MediaMetadataCompat?, title: String? = trackTitle(metadata)): String? {
metadata ?: return null
val normalizedTitle = title?.trim()?.takeIf { it.isNotBlank() }

for (key in PRIMARY_ARTIST_KEYS) {
metadata.readText(key)?.let { return it }
}

metadata.description.subtitle?.toString()?.trim()?.takeIf { it.isNotBlank() }?.let { subtitle ->
if (normalizedTitle == null || !subtitle.equals(normalizedTitle, ignoreCase = true)) {
return subtitle
}
}

for (key in SECONDARY_ARTIST_KEYS) {
metadata.readText(key)?.let { candidate ->
if (normalizedTitle == null || !candidate.equals(normalizedTitle, ignoreCase = true)) {
return candidate
}
}
}
return null
}

private fun MediaMetadataCompat.readText(key: String): String? =
getString(key)?.trim()?.takeIf { it.isNotBlank() }
?: getText(key)?.toString()?.trim()?.takeIf { it.isNotBlank() }

private val PRIMARY_ARTIST_KEYS =
listOf(
MediaMetadataCompat.METADATA_KEY_ARTIST,
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
MediaMetadataCompat.METADATA_KEY_AUTHOR,
)

private val SECONDARY_ARTIST_KEYS =
listOf(
MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE,
MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.lu4p.fokuslauncher.media

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat

object MediaNotificationHelper {

fun componentName(context: Context): ComponentName =
ComponentName(context, MediaNotificationListenerService::class.java)

fun isListenerEnabled(context: Context): Boolean =
context.packageName in
NotificationManagerCompat.getEnabledListenerPackages(context)

fun openListenerSettings(context: Context) {
context.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.lu4p.fokuslauncher.media

import android.content.ComponentName
import android.service.notification.NotificationListenerService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

/**
* Lets [MediaRepository] read active media sessions (including Spotify) via
* [android.media.session.MediaSessionManager]. The user must grant notification access in system
* settings.
*/
@AndroidEntryPoint
class MediaNotificationListenerService : NotificationListenerService() {

@Inject lateinit var mediaRepository: MediaRepository

override fun onListenerConnected() {
super.onListenerConnected()
mediaRepository.onNotificationListenerConnected(
ComponentName(this, MediaNotificationListenerService::class.java)
)
}

override fun onListenerDisconnected() {
mediaRepository.onNotificationListenerDisconnected()
super.onListenerDisconnected()
}
}
Loading
Loading