Add now playing to smartspace
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user