Add now playing to smartspace

This commit is contained in:
Suphon Thanakornpakapong
2022-05-13 02:04:34 +07:00
parent 08f026478d
commit 49f8bed912
11 changed files with 448 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12,3V12.26C11.5,12.09 11,12 10.5,12C8,12 6,14 6,16.5C6,19 8,21 10.5,21C13,21 15,19 15,16.5V6H19V3H12Z" />
</vector>
@@ -0,0 +1,77 @@
package app.lawnchair
import android.content.Context
import android.content.pm.PackageManager
import android.service.notification.StatusBarNotification
import app.lawnchair.util.checkPackagePermission
import com.android.launcher3.notification.NotificationListener
import com.android.launcher3.util.MainThreadInitializedObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NotificationManager(@Suppress("UNUSED_PARAMETER") context: Context) {
private val scope = MainScope()
private val notificationsMap = mutableMapOf<String, StatusBarNotification>()
private val _notifications = MutableStateFlow(emptyList<StatusBarNotification>())
val notifications: Flow<List<StatusBarNotification>> get() = _notifications
fun onNotificationPosted(sbn: StatusBarNotification) {
notificationsMap[sbn.key] = sbn
onChange()
}
fun onNotificationRemoved(sbn: StatusBarNotification) {
notificationsMap.remove(sbn.key)
onChange()
}
fun onNotificationFullRefresh() {
scope.launch(Dispatchers.IO) {
val tmpMap = NotificationListener.getInstanceIfConnected()
?.activeNotifications?.associateBy { it.key }
withContext(Dispatchers.Main) {
notificationsMap.clear()
if (tmpMap != null) {
notificationsMap.putAll(tmpMap)
}
onChange()
}
}
}
private fun onChange() {
_notifications.value = notificationsMap.values.toList()
}
companion object {
@JvmField val INSTANCE = MainThreadInitializedObject(::NotificationManager)
}
}
private const val PERM_SUBSTITUTE_APP_NAME = "android.permission.SUBSTITUTE_NOTIFICATION_APP_NAME"
private const val EXTRA_SUBSTITUTE_APP_NAME = "android.substName"
fun StatusBarNotification.getAppName(context: Context): CharSequence {
val subName = notification.extras.getString(EXTRA_SUBSTITUTE_APP_NAME)
if (subName != null) {
if (context.checkPackagePermission(packageName, PERM_SUBSTITUTE_APP_NAME)) {
return subName
}
}
return context.getAppName(packageName)
}
fun Context.getAppName(name: String): CharSequence {
try {
return packageManager.getApplicationLabel(
packageManager.getApplicationInfo(name, PackageManager.GET_META_DATA))
} catch (ignored: PackageManager.NameNotFoundException) {
}
return name
}
@@ -37,6 +37,8 @@ object BcSmartSpaceUtil {
view.context.startActivity(action.intent)
} else if (action.pendingIntent != null) {
action.pendingIntent.send()
} else if (action.onClick != null) {
action.onClick.run()
}
onClickListener?.onClick(view)
}
@@ -13,7 +13,8 @@ data class SmartspaceAction(
val contentDescription: CharSequence? = null,
val pendingIntent: PendingIntent? = null,
val intent: Intent? = null,
val onClick: Runnable? = null,
val extras: Bundle? = null
)
val SmartspaceAction?.hasIntent get() = this != null && (intent != null || pendingIntent != null)
val SmartspaceAction?.hasIntent get() = this != null && (intent != null || pendingIntent != null || onClick != null)
@@ -3,5 +3,6 @@ package app.lawnchair.smartspace.model
object SmartspaceScores {
const val SCORE_WEATHER = 0f
const val SCORE_BATTERY = 1f
const val SCORE_CALENDAR = 2f
const val SCORE_MEDIA = 2f
const val SCORE_CALENDAR = 3f
}
@@ -0,0 +1,239 @@
package app.lawnchair.smartspace.provider;
import android.app.Notification;
import android.content.Context;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Bundle;
import android.os.Handler;
import android.service.notification.StatusBarNotification;
import android.util.Log;
import android.view.KeyEvent;
import app.lawnchair.NotificationManager;
import app.lawnchair.util.FlowCollector;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Paused mode is not supported on Marshmallow because the MediaSession is missing
* notifications. Without this information, it is impossible to hide on stop.
*/
public class MediaListener extends MediaController.Callback {
private static final String TAG = "MediaListener";
private final Context mContext;
private final Runnable mOnChange;
private List<MediaNotificationController> mControllers = Collections.emptyList();
private MediaNotificationController mTracking;
private final Handler mHandler = new Handler();
private final FlowCollector<List<StatusBarNotification>> mFlowCollector;
private List<StatusBarNotification> mNotifications = Collections.emptyList();
public MediaListener(Context context, Runnable onChange) {
mContext = context;
mOnChange = onChange;
NotificationManager notificationManager = NotificationManager.INSTANCE.get(context);
mFlowCollector = new FlowCollector<>(
notificationManager.getNotifications(),
item -> {
mNotifications = item;
updateTracking();
}
);
}
public void onResume() {
updateTracking();
mFlowCollector.start();
}
public void onPause() {
updateTracking();
mFlowCollector.stop();
}
public MediaNotificationController getTracking() {
return mTracking;
}
public String getPackage() {
return mTracking.controller.getPackageName();
}
private void updateControllers(List<MediaNotificationController> controllers) {
for (MediaNotificationController mnc : mControllers) {
mnc.controller.unregisterCallback(this);
}
for (MediaNotificationController mnc : controllers) {
mnc.controller.registerCallback(this);
}
mControllers = controllers;
}
private void updateTracking() {
updateControllers(getControllers());
if (mTracking != null) {
mTracking.reloadInfo();
}
// If the current controller is not playing, stop tracking it.
if (mTracking != null
&& (!mControllers.contains(mTracking) || !mTracking.isPlaying())) {
mTracking = null;
}
for (MediaNotificationController mnc : mControllers) {
// Either we are not tracking a controller and this one is valid,
// or this one is playing while the one we track is not.
if ((mTracking == null && mnc.isPlaying())
|| (mTracking != null && mnc.isPlaying() && !mTracking.isPlaying())) {
mTracking = mnc;
}
}
mHandler.removeCallbacks(mOnChange);
mHandler.post(mOnChange);
}
private void pressButton(int keyCode) {
if (mTracking != null) {
mTracking.pressButton(keyCode);
}
}
void toggle(boolean finalClick) {
if (!finalClick) {
Log.d(TAG, "Toggle");
pressButton(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE);
}
}
void next(boolean finalClick) {
if (finalClick) {
Log.d(TAG, "Next");
pressButton(KeyEvent.KEYCODE_MEDIA_NEXT);
pressButton(KeyEvent.KEYCODE_MEDIA_PLAY);
}
}
void previous(boolean finalClick) {
if (finalClick) {
Log.d(TAG, "Previous");
pressButton(KeyEvent.KEYCODE_MEDIA_PREVIOUS);
pressButton(KeyEvent.KEYCODE_MEDIA_PLAY);
}
}
private List<MediaNotificationController> getControllers() {
List<MediaNotificationController> controllers = new ArrayList<>();
for (StatusBarNotification notif : mNotifications) {
Bundle extras = notif.getNotification().extras;
MediaSession.Token notifToken = extras.getParcelable(Notification.EXTRA_MEDIA_SESSION);
if (notifToken != null) {
MediaController controller = new MediaController(mContext, notifToken);
controllers.add(new MediaNotificationController(controller, notif));
}
}
return controllers;
}
/**
* Events that refresh the current handler.
*/
public void onPlaybackStateChanged(PlaybackState state) {
super.onPlaybackStateChanged(state);
updateTracking();
}
public void onMetadataChanged(MediaMetadata metadata) {
super.onMetadataChanged(metadata);
updateTracking();
}
public class MediaInfo {
private CharSequence title;
private CharSequence artist;
private CharSequence album;
public CharSequence getTitle() {
return title;
}
public CharSequence getArtist() {
return artist;
}
public CharSequence getAlbum() {
return album;
}
}
public class MediaNotificationController {
private MediaController controller;
private StatusBarNotification sbn;
private MediaInfo info;
private MediaNotificationController(MediaController controller, StatusBarNotification sbn) {
this.controller = controller;
this.sbn = sbn;
reloadInfo();
}
private boolean hasTitle() {
return info != null && info.title != null;
}
private boolean isPlaying() {
return hasTitle()
&& controller.getPlaybackState() != null
&& controller.getPlaybackState().getState() == PlaybackState.STATE_PLAYING;
}
private boolean isPausedOrPlaying() {
if (!hasTitle() || controller.getPlaybackState() == null) {
return false;
}
int state = controller.getPlaybackState().getState();
return state == PlaybackState.STATE_PAUSED
|| state == PlaybackState.STATE_PLAYING;
}
private void pressButton(int keyCode) {
controller.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
controller.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
private void reloadInfo() {
MediaMetadata metadata = controller.getMetadata();
if (metadata != null) {
info = new MediaInfo();
info.title = metadata.getText(MediaMetadata.METADATA_KEY_TITLE);
info.artist = metadata.getText(MediaMetadata.METADATA_KEY_ARTIST);
info.album = metadata.getText(MediaMetadata.METADATA_KEY_ALBUM);
} else if (sbn != null) {
info = new MediaInfo();
info.title = sbn.getNotification().extras.getCharSequence(Notification.EXTRA_TITLE);
}
}
public String getPackageName() {
return controller.getPackageName();
}
public StatusBarNotification getSbn() {
return sbn;
}
public MediaInfo getInfo() {
return info;
}
}
}
@@ -0,0 +1,55 @@
package app.lawnchair.smartspace.provider
import android.content.Context
import android.graphics.drawable.Icon
import android.text.TextUtils
import app.lawnchair.getAppName
import app.lawnchair.smartspace.model.SmartspaceAction
import app.lawnchair.smartspace.model.SmartspaceScores
import app.lawnchair.smartspace.model.SmartspaceTarget
import com.android.launcher3.R
import kotlinx.coroutines.flow.MutableStateFlow
class NowPlayingProvider(private val context: Context) : SmartspaceDataSource {
private val media = MediaListener(context, this::reload).also { it.onResume() }
private val defaultIcon = Icon.createWithResource(context, R.drawable.ic_music_note)
private val targetsFlow = MutableStateFlow(emptyList<SmartspaceTarget>())
override val targets get() = targetsFlow
private fun getSmartspaceTarget(): SmartspaceTarget? {
val tracking = media.tracking ?: return null
val title = tracking.info.title ?: return null
val sbn = tracking.sbn
val icon = sbn.notification.smallIcon ?: defaultIcon
val mediaInfo = tracking.info
val subtitle = if (!TextUtils.isEmpty(mediaInfo.artist)) {
mediaInfo.artist
} else sbn?.getAppName(context) ?: context.getAppName(tracking.packageName)
val intent = sbn?.notification?.contentIntent
return SmartspaceTarget(
id = "nowPlaying",
headerAction = SmartspaceAction(
id = "nowPlayingAction",
icon = icon,
title = title,
subtitle = subtitle,
pendingIntent = intent,
onClick = if (intent == null) Runnable { media.toggle(true) } else null
),
score = SmartspaceScores.SCORE_MEDIA,
featureType = SmartspaceTarget.FeatureType.FEATURE_MEDIA
)
}
private fun reload() {
targetsFlow.value = listOfNotNull(getSmartspaceTarget())
}
override fun destroy() {
media.onPause()
}
}
@@ -16,6 +16,7 @@ class SmartspaceProvider private constructor(context: Context) {
init {
flows.add(SmartspaceWidgetReader(context).targets)
flows.add(BatteryStatusProvider(context).targets)
flows.add(NowPlayingProvider(context).targets)
targets = flows.reduce { acc, flow -> flow.combine(acc) { a, b -> a + b } }
}
@@ -0,0 +1,30 @@
package app.lawnchair.util
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class FlowCollector<T>(
private val flow: Flow<T>,
private val callback: Listener<T>
) {
private val scope = MainScope()
private var job: Job? = null
fun start() {
job = scope.launch {
flow.collect(callback::onItem)
}
}
fun stop() {
job?.cancel()
job = null
}
interface Listener<T> {
fun onItem(item: T)
}
}
@@ -22,6 +22,8 @@ import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
import android.content.pm.PackageManager
import android.content.res.Resources
import android.os.Looper
import android.view.View
@@ -153,3 +155,20 @@ fun getAllAppsScrimColor(context: Context): Int {
val alpha = (opacity * 255).roundToInt()
return ColorUtils.setAlphaComponent(scrimColor, alpha)
}
fun Int.hasFlag(flag: Int): Boolean {
return (this and flag) != 0
}
fun Context.checkPackagePermission(packageName: String, permissionName: String): Boolean {
try {
val info = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS)
info.requestedPermissions.forEachIndexed { index, s ->
if (s == permissionName) {
return info.requestedPermissionsFlags[index].hasFlag(REQUESTED_PERMISSION_GRANTED)
}
}
} catch (e: PackageManager.NameNotFoundException) {
}
return false
}
@@ -48,6 +48,8 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import app.lawnchair.NotificationManager;
/**
* A {@link NotificationListenerService} that sends updates to its
* {@link NotificationsChangedListener} when notifications are posted or canceled,
@@ -84,6 +86,8 @@ public class NotificationListener extends NotificationListenerService {
private SettingsCache mSettingsCache;
private SettingsCache.OnChangeListener mNotificationSettingsChangedListener;
private NotificationManager mNotificationManager;
public NotificationListener() {
mWorkerHandler = new Handler(MODEL_EXECUTOR.getLooper(), this::handleWorkerMessage);
mUiHandler = new Handler(Looper.getMainLooper(), this::handleUiMessage);
@@ -112,6 +116,12 @@ public class NotificationListener extends NotificationListenerService {
sNotificationsChangedListener = null;
}
@Override
public void onCreate() {
super.onCreate();
mNotificationManager = NotificationManager.INSTANCE.get(this);
}
private boolean handleWorkerMessage(Message message) {
switch (message.what) {
case MSG_NOTIFICATION_POSTED: {
@@ -226,6 +236,7 @@ public class NotificationListener extends NotificationListenerService {
private void onNotificationFullRefresh() {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_FULL_REFRESH).sendToTarget();
mNotificationManager.onNotificationFullRefresh();
}
@Override
@@ -240,6 +251,7 @@ public class NotificationListener extends NotificationListenerService {
public void onNotificationPosted(final StatusBarNotification sbn) {
if (sbn != null) {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_POSTED, sbn).sendToTarget();
mNotificationManager.onNotificationPosted(sbn);
}
}
@@ -247,6 +259,7 @@ public class NotificationListener extends NotificationListenerService {
public void onNotificationRemoved(final StatusBarNotification sbn) {
if (sbn != null) {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_REMOVED, sbn).sendToTarget();
mNotificationManager.onNotificationRemoved(sbn);
}
}