package org.mozilla.gecko.media; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.media.session.MediaController; import android.media.session.MediaSession; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.support.v4.app.NotificationManagerCompat; import android.util.Log; import org.mozilla.gecko.AppConstants; import org.mozilla.gecko.BrowserApp; import org.mozilla.gecko.GeckoApp; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.PrefsHelper; import org.mozilla.gecko.R; import org.mozilla.gecko.Tab; import org.mozilla.gecko.Tabs; import org.mozilla.gecko.util.ThreadUtils; import java.lang.ref.WeakReference; public class MediaControlService extends Service implements Tabs.OnTabsChangedListener { private static final String LOGTAG = "MediaControlService"; public static final String ACTION_INIT = "action_init"; public static final String ACTION_RESUME = "action_resume"; public static final String ACTION_PAUSE = "action_pause"; public static final String ACTION_STOP = "action_stop"; public static final String ACTION_RESUME_BY_AUDIO_FOCUS = "action_resume_audio_focus"; public static final String ACTION_PAUSE_BY_AUDIO_FOCUS = "action_pause_audio_focus"; private static final int MEDIA_CONTROL_ID = 1; private static final String MEDIA_CONTROL_PREF = "dom.audiochannel.mediaControl"; private String mActionState = ACTION_STOP; private MediaSession mSession; private MediaController mController; private PrefsHelper.PrefHandler mPrefsObserver; private final String[] mPrefs = { MEDIA_CONTROL_PREF }; private boolean mInitialize = false; private boolean mIsMediaControlPrefOn = true; private static WeakReference mTabReference = new WeakReference<>(null); private int minCoverSize; private int coverSize; @Override public void onCreate() { initialize(); } @Override public void onDestroy() { shutdown(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { handleIntent(intent); return START_NOT_STICKY; } @Override public IBinder onBind(Intent intent) { return null; } @Override public boolean onUnbind(Intent intent) { mSession.release(); return super.onUnbind(intent); } @Override public void onTaskRemoved(Intent rootIntent) { shutdown(); } @Override public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { if (!mInitialize) { return; } final Tab playingTab = mTabReference.get(); switch (msg) { case MEDIA_PLAYING_CHANGE: // The 'MEDIA_PLAYING_CHANGE' would only be received when the // media starts or ends. if (playingTab != tab && tab.isMediaPlaying()) { mTabReference = new WeakReference<>(tab); notifyControlInterfaceChanged(ACTION_PAUSE); } else if (playingTab == tab && !tab.isMediaPlaying()) { notifyControlInterfaceChanged(ACTION_STOP); mTabReference = new WeakReference<>(null); } break; case MEDIA_PLAYING_RESUME: // user resume the paused-by-control media from page so that we // should make the control interface consistent. if (playingTab == tab && !isMediaPlaying()) { notifyControlInterfaceChanged(ACTION_PAUSE); } break; case CLOSED: if (playingTab == null || playingTab == tab) { // Remove the controls when the playing tab disappeared or was closed. notifyControlInterfaceChanged(ACTION_STOP); } break; case FAVICON: if (playingTab == tab) { final String actionForPendingIntent = isMediaPlaying() ? ACTION_PAUSE : ACTION_RESUME; notifyControlInterfaceChanged(actionForPendingIntent); } break; } } private boolean isMediaPlaying() { return mActionState.equals(ACTION_RESUME); } private void initialize() { if (mInitialize || !isAndroidVersionLollopopOrHigher()) { return; } Log.d(LOGTAG, "initialize"); getGeckoPreference(); initMediaSession(); coverSize = (int) getResources().getDimension(R.dimen.notification_media_cover); minCoverSize = getResources().getDimensionPixelSize(R.dimen.favicon_bg); Tabs.registerOnTabsChangedListener(this); mInitialize = true; } private void shutdown() { if (!mInitialize) { return; } Log.d(LOGTAG, "shutdown"); notifyControlInterfaceChanged(ACTION_STOP); PrefsHelper.removeObserver(mPrefsObserver); Tabs.unregisterOnTabsChangedListener(this); mInitialize = false; stopSelf(); } private boolean isAndroidVersionLollopopOrHigher() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; } private void handleIntent(Intent intent) { if (intent == null || intent.getAction() == null || !mInitialize) { return; } Log.d(LOGTAG, "HandleIntent, action = " + intent.getAction() + ", actionState = " + mActionState); switch (intent.getAction()) { case ACTION_INIT : // This action is used to create a service and do the initialization, // the actual operation would be executed via control interface's // pending intent. break; case ACTION_RESUME : mController.getTransportControls().play(); break; case ACTION_PAUSE : mController.getTransportControls().pause(); break; case ACTION_STOP : mController.getTransportControls().stop(); break; case ACTION_PAUSE_BY_AUDIO_FOCUS : mController.getTransportControls().sendCustomAction(ACTION_PAUSE_BY_AUDIO_FOCUS, null); break; case ACTION_RESUME_BY_AUDIO_FOCUS : mController.getTransportControls().sendCustomAction(ACTION_RESUME_BY_AUDIO_FOCUS, null); break; } } private void getGeckoPreference() { mPrefsObserver = new PrefsHelper.PrefHandlerBase() { @Override public void prefValue(String pref, boolean value) { if (pref.equals(MEDIA_CONTROL_PREF)) { mIsMediaControlPrefOn = value; // If media is playing, we just need to create or remove // the media control interface. if (mActionState.equals(ACTION_RESUME)) { notifyControlInterfaceChanged(mIsMediaControlPrefOn ? ACTION_PAUSE : ACTION_STOP); } // If turn off pref during pausing, except removing media // interface, we also need to stop the service and notify // gecko about that. if (mActionState.equals(ACTION_PAUSE) && !mIsMediaControlPrefOn) { Intent intent = new Intent(getApplicationContext(), MediaControlService.class); intent.setAction(ACTION_STOP); handleIntent(intent); } } } }; PrefsHelper.addObserver(mPrefs, mPrefsObserver); } private void initMediaSession() { // Android MediaSession is introduced since version L. mSession = new MediaSession(getApplicationContext(), "fennec media session"); mController = new MediaController(getApplicationContext(), mSession.getSessionToken()); mSession.setCallback(new MediaSession.Callback() { @Override public void onCustomAction(String action, Bundle extras) { if (action.equals(ACTION_PAUSE_BY_AUDIO_FOCUS)) { Log.d(LOGTAG, "Controller, pause by audio focus changed"); notifyControlInterfaceChanged(ACTION_RESUME); } else if (action.equals(ACTION_RESUME_BY_AUDIO_FOCUS)) { Log.d(LOGTAG, "Controller, resume by audio focus changed"); notifyControlInterfaceChanged(ACTION_PAUSE); } } @Override public void onPlay() { Log.d(LOGTAG, "Controller, onPlay"); super.onPlay(); notifyControlInterfaceChanged(ACTION_PAUSE); notifyObservers("MediaControl", "resumeMedia"); // To make sure we always own audio focus during playing. AudioFocusAgent.notifyStartedPlaying(); } @Override public void onPause() { Log.d(LOGTAG, "Controller, onPause"); super.onPause(); notifyControlInterfaceChanged(ACTION_RESUME); notifyObservers("MediaControl", "mediaControlPaused"); AudioFocusAgent.notifyStoppedPlaying(); } @Override public void onStop() { Log.d(LOGTAG, "Controller, onStop"); super.onStop(); notifyControlInterfaceChanged(ACTION_STOP); notifyObservers("MediaControl", "mediaControlStopped"); mTabReference = new WeakReference<>(null); } }); } private void notifyObservers(String topic, String data) { GeckoAppShell.notifyObservers(topic, data); } private boolean isNeedToRemoveControlInterface(String action) { return action.equals(ACTION_STOP); } private void notifyControlInterfaceChanged(final String uiAction) { if (!mInitialize) { return; } Log.d(LOGTAG, "notifyControlInterfaceChanged, action = " + uiAction); if (isNeedToRemoveControlInterface(uiAction)) { stopForeground(false); NotificationManagerCompat.from(this).cancel(MEDIA_CONTROL_ID); setActionState(uiAction); return; } if (!mIsMediaControlPrefOn) { return; } final Tab tab = mTabReference.get(); if (tab == null) { return; } setActionState(uiAction); ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { updateNotification(tab, uiAction); } }); } private void setActionState(final String uiAction) { switch (uiAction) { case ACTION_PAUSE: mActionState = ACTION_RESUME; break; case ACTION_RESUME: mActionState = ACTION_PAUSE; break; case ACTION_STOP: mActionState = ACTION_STOP; break; } } private void updateNotification(Tab tab, String action) { ThreadUtils.assertNotOnUiThread(); final Notification.MediaStyle style = new Notification.MediaStyle(); style.setShowActionsInCompactView(0); final boolean isPlaying = isMediaPlaying(); final int visibility = tab.isPrivate() ? Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC; final Notification notification = new Notification.Builder(this) .setSmallIcon(R.drawable.flat_icon) .setLargeIcon(generateCoverArt(tab)) .setContentTitle(tab.getTitle()) .setContentText(tab.getURL()) .setContentIntent(createContentIntent(tab.getId())) .setDeleteIntent(createDeleteIntent()) .setStyle(style) .addAction(createNotificationAction(action)) .setOngoing(isPlaying) .setShowWhen(false) .setWhen(0) .setVisibility(visibility) .build(); if (isPlaying) { startForeground(MEDIA_CONTROL_ID, notification); } else { stopForeground(false); NotificationManagerCompat.from(this) .notify(MEDIA_CONTROL_ID, notification); } } private Notification.Action createNotificationAction(String action) { boolean isPlayAction = action.equals(ACTION_RESUME); int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause; String title = getString(isPlayAction ? R.string.media_play : R.string.media_pause); final Intent intent = new Intent(getApplicationContext(), MediaControlService.class); intent.setAction(action); final PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0); //noinspection deprecation - The new constructor is only for API > 23 return new Notification.Action.Builder(icon, title, pendingIntent).build(); } private PendingIntent createContentIntent(int tabId) { Intent intent = new Intent(getApplicationContext(), BrowserApp.class); intent.setAction(GeckoApp.ACTION_SWITCH_TAB); intent.putExtra("TabId", tabId); return PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); } private PendingIntent createDeleteIntent() { Intent intent = new Intent(getApplicationContext(), MediaControlService.class); intent.setAction(ACTION_STOP); return PendingIntent.getService(getApplicationContext(), 1, intent, 0); } private Bitmap generateCoverArt(Tab tab) { final Bitmap favicon = tab.getFavicon(); // If we do not have a favicon or if it's smaller than 72 pixels then just use the default icon. if (favicon == null || favicon.getWidth() < minCoverSize || favicon.getHeight() < minCoverSize) { // Use the launcher icon as fallback return BitmapFactory.decodeResource(getResources(), R.drawable.notification_media); } // Favicon should at least have half of the size of the cover int width = Math.max(favicon.getWidth(), coverSize / 2); int height = Math.max(favicon.getHeight(), coverSize / 2); final Bitmap coverArt = Bitmap.createBitmap(coverSize, coverSize, Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(coverArt); canvas.drawColor(0xFF777777); int left = Math.max(0, (coverArt.getWidth() / 2) - (width / 2)); int right = Math.min(coverSize, left + width); int top = Math.max(0, (coverArt.getHeight() / 2) - (height / 2)); int bottom = Math.min(coverSize, top + height); final Paint paint = new Paint(); paint.setAntiAlias(true); canvas.drawBitmap(favicon, new Rect(0, 0, favicon.getWidth(), favicon.getHeight()), new Rect(left, top, right, bottom), paint); return coverArt; } }