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); } }