diff --git a/lawnchair/res/drawable/ic_music_note.xml b/lawnchair/res/drawable/ic_music_note.xml
new file mode 100644
index 0000000000..31ecdefe6f
--- /dev/null
+++ b/lawnchair/res/drawable/ic_music_note.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/lawnchair/src/app/lawnchair/NotificationManager.kt b/lawnchair/src/app/lawnchair/NotificationManager.kt
new file mode 100644
index 0000000000..01a1620124
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/NotificationManager.kt
@@ -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()
+ private val _notifications = MutableStateFlow(emptyList())
+ val notifications: Flow> 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
+}
diff --git a/lawnchair/src/app/lawnchair/smartspace/BcSmartSpaceUtil.kt b/lawnchair/src/app/lawnchair/smartspace/BcSmartSpaceUtil.kt
index 1525fb3c3a..82aa71c505 100644
--- a/lawnchair/src/app/lawnchair/smartspace/BcSmartSpaceUtil.kt
+++ b/lawnchair/src/app/lawnchair/smartspace/BcSmartSpaceUtil.kt
@@ -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)
}
diff --git a/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceAction.kt b/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceAction.kt
index 1e6b43f0f3..a6b883e7cf 100644
--- a/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceAction.kt
+++ b/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceAction.kt
@@ -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)
diff --git a/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceScores.kt b/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceScores.kt
index 662c2d5333..2fd2efe794 100644
--- a/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceScores.kt
+++ b/lawnchair/src/app/lawnchair/smartspace/model/SmartspaceScores.kt
@@ -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
}
diff --git a/lawnchair/src/app/lawnchair/smartspace/provider/MediaListener.java b/lawnchair/src/app/lawnchair/smartspace/provider/MediaListener.java
new file mode 100644
index 0000000000..4b0ef99aab
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/smartspace/provider/MediaListener.java
@@ -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 mControllers = Collections.emptyList();
+ private MediaNotificationController mTracking;
+ private final Handler mHandler = new Handler();
+ private final FlowCollector> mFlowCollector;
+ private List 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 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 getControllers() {
+ List 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lawnchair/src/app/lawnchair/smartspace/provider/NowPlayingProvider.kt b/lawnchair/src/app/lawnchair/smartspace/provider/NowPlayingProvider.kt
new file mode 100644
index 0000000000..75c9e94c06
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/smartspace/provider/NowPlayingProvider.kt
@@ -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())
+ 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()
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/smartspace/provider/SmartspaceProvider.kt b/lawnchair/src/app/lawnchair/smartspace/provider/SmartspaceProvider.kt
index c8838d6203..42e17a5c7f 100644
--- a/lawnchair/src/app/lawnchair/smartspace/provider/SmartspaceProvider.kt
+++ b/lawnchair/src/app/lawnchair/smartspace/provider/SmartspaceProvider.kt
@@ -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 } }
}
diff --git a/lawnchair/src/app/lawnchair/util/FlowCollector.kt b/lawnchair/src/app/lawnchair/util/FlowCollector.kt
new file mode 100644
index 0000000000..6b53bc57a9
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/util/FlowCollector.kt
@@ -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(
+ private val flow: Flow,
+ private val callback: Listener
+) {
+
+ 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 {
+ fun onItem(item: T)
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/util/LawnchairUtils.kt b/lawnchair/src/app/lawnchair/util/LawnchairUtils.kt
index b51d324802..97ec02eefb 100644
--- a/lawnchair/src/app/lawnchair/util/LawnchairUtils.kt
+++ b/lawnchair/src/app/lawnchair/util/LawnchairUtils.kt
@@ -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
+}
diff --git a/src/com/android/launcher3/notification/NotificationListener.java b/src/com/android/launcher3/notification/NotificationListener.java
index e58f5fa2d5..35bb3aa755 100644
--- a/src/com/android/launcher3/notification/NotificationListener.java
+++ b/src/com/android/launcher3/notification/NotificationListener.java
@@ -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);
}
}