Bug 1477041 - Modified MediaControlService and notification flow. r?sdaswani draft
authorVlad Baicu <vlad.baicu@softvision.ro>
Mon, 30 Jul 2018 16:21:09 +0300
changeset 824268 eb3011d304daf31650f5fc05a1ead4cdd0b63c49
parent 824238 dead9fcddd4a25fd36d54ab7eb782d7d9b8bb7a1
push id117848
push uservbaicu@mozilla.com
push dateMon, 30 Jul 2018 13:23:09 +0000
reviewerssdaswani
bugs1477041
milestone63.0a1
Bug 1477041 - Modified MediaControlService and notification flow. r?sdaswani Created a new parcelable which is sent to MediaControlService where the notification is created based on the data of this object instead of referencing a static notification. Toggling the service is now done through startService with a specific action for shutting down instead of calling stopService due to concurrency issues where the service can be stopped before having a chance to call startForeground. MozReview-Commit-ID: 6qNPintkVy
mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
mobile/android/base/java/org/mozilla/gecko/media/MediaNotification.java
--- a/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
@@ -227,22 +227,22 @@ public final class IntentHelper implemen
 
         if (mimeType != null && mimeType.length() > 0) {
             shareIntent.setType(mimeType);
         }
 
         return shareIntent;
     }
 
-    public static Intent getTabSwitchIntent(final Tab tab) {
+    public static Intent getTabSwitchIntent(final int tabId) {
         final Intent intent = new Intent(GeckoApp.ACTION_SWITCH_TAB);
         intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
         intent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true);
-        intent.putExtra(INTENT_EXTRA_TAB_ID, tab.getId());
+        intent.putExtra(INTENT_EXTRA_TAB_ID, tabId);
         intent.putExtra(INTENT_EXTRA_SESSION_UUID, GeckoApplication.getSessionUUID());
         return intent;
     }
 
     public static Intent getPrivacySettingsIntent() {
         final Intent intent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS);
         intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
         GeckoPreferences.setResourceToOpen(intent, "preferences_privacy");
--- a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
@@ -34,37 +34,42 @@ import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.IntentHelper;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.notifications.NotificationHelper;
 import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
+import java.io.ByteArrayOutputStream;
 
 import static org.mozilla.gecko.BuildConfig.DEBUG;
 
 public class GeckoMediaControlAgent {
     private static final String LOGTAG = "GeckoMediaControlAgent";
 
     @SuppressLint("StaticFieldLeak")
     private static GeckoMediaControlAgent instance;
     private Context mContext;
 
-    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          = "action_resume";
+    public static final String ACTION_PAUSE           = "action_pause";
+    public static final String ACTION_STOP            = "action_stop";
+    /* package */ static final String ACTION_SHUTDOWN = "action_shutdown";
     /* package */ static final String ACTION_RESUME_BY_AUDIO_FOCUS = "action_resume_audio_focus";
     /* package */ static final String ACTION_PAUSE_BY_AUDIO_FOCUS  = "action_pause_audio_focus";
     /* package */ static final String ACTION_START_AUDIO_DUCK      = "action_start_audio_duck";
     /* package */ static final String ACTION_STOP_AUDIO_DUCK       = "action_stop_audio_duck";
     /* package */ static final String ACTION_TAB_STATE_PLAYING = "action_tab_state_playing";
     /* package */ static final String ACTION_TAB_STATE_STOPPED = "action_tab_state_stopped";
     /* package */ static final String ACTION_TAB_STATE_RESUMED = "action_tab_state_resumed";
     /* package */ static final String ACTION_TAB_STATE_FAVICON = "action_tab_state_favicon";
+
+    /* package */ static final String EXTRA_NOTIFICATION_DATA = "notification_data";
+
     private static final String MEDIA_CONTROL_PREF = "dom.audiochannel.mediaControl";
 
     // This is maximum volume level difference when audio ducking. The number is arbitrary.
     private static final int AUDIO_DUCK_MAX_STEPS = 3;
     private enum AudioDucking { START, STOP }
     private boolean mSupportsDucking = false;
     private int mAudioDuckCurrentSteps = 0;
 
@@ -76,18 +81,16 @@ public class GeckoMediaControlAgent {
     private final String[] mPrefs = { MEDIA_CONTROL_PREF };
 
     private boolean mInitialized = false;
     private boolean mIsMediaControlPrefOn = true;
 
     private int minCoverSize;
     private int coverSize;
 
-    private Notification currentNotification;
-
     /**
      * Internal state of MediaControlService, to indicate it is playing media, or paused...etc.
      */
     private static State sMediaState = State.STOPPED;
 
     protected enum State {
         PLAYING,
         PAUSED,
@@ -268,17 +271,17 @@ public class GeckoMediaControlAgent {
     private void onStateChanged() {
         if (!mInitialized) {
             return;
         }
 
         Log.d(LOGTAG, "onStateChanged, state = " + sMediaState);
 
         if (isNeedToRemoveControlInterface(sMediaState)) {
-            stopForegroundService();
+            toggleForegroundService(false);
             NotificationManagerCompat.from(mContext).cancel(R.id.mediaControlNotification);
             release();
             return;
         }
 
         if (!mIsMediaControlPrefOn) {
             return;
         }
@@ -371,60 +374,59 @@ public class GeckoMediaControlAgent {
             adjustDirection = AudioManager.ADJUST_RAISE;
         }
 
         for (int i = 0; i < mAudioDuckCurrentSteps; i++) {
             mController.adjustVolume(adjustDirection, 0);
         }
     }
 
+    private void updateNotification(Tab tab) {
+        ThreadUtils.assertNotOnUiThread();
+
+        final boolean isPlaying = isMediaPlaying();
+        final int visibility = tab.isPrivate() ? Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC;
+
+        final MediaNotification mediaNotification = new MediaNotification(isPlaying, visibility, tab.getId(),
+                tab.getTitle(), tab.getURL(), generateCoverArt(tab.getFavicon()));
+
+        if (isPlaying) {
+            toggleForegroundService(true, mediaNotification);
+        } else {
+            toggleForegroundService(false);
+            NotificationManagerCompat.from(mContext).notify(R.id.mediaControlNotification, createNotification(mediaNotification));
+        }
+    }
+
     @SuppressLint("NewApi")
-    private void setCurrentNotification(Tab tab, boolean onGoing, int visibility) {
+    /* package */ Notification createNotification(MediaNotification mediaNotification) {
         final Notification.MediaStyle style = new Notification.MediaStyle();
         style.setShowActionsInCompactView(0);
 
         final Notification.Builder notificationBuilder = new Notification.Builder(mContext)
                 .setSmallIcon(R.drawable.ic_status_logo)
-                .setLargeIcon(generateCoverArt(tab))
-                .setContentTitle(tab.getTitle())
-                .setContentText(tab.getURL())
-                .setContentIntent(createContentIntent(tab))
+                .setLargeIcon(BitmapFactory.decodeByteArray(mediaNotification.getBitmapBytes(),
+                        0, mediaNotification.getBitmapBytes().length))
+                .setContentTitle(mediaNotification.getTitle())
+                .setContentText(mediaNotification.getText())
+                .setContentIntent(createContentIntent(mediaNotification.getTabId()))
                 .setDeleteIntent(createDeleteIntent())
                 .setStyle(style)
                 .addAction(createNotificationAction())
-                .setOngoing(onGoing)
+                .setOngoing(mediaNotification.isOnGoing())
                 .setShowWhen(false)
                 .setWhen(0)
-                .setVisibility(visibility);
+                .setVisibility(mediaNotification.getVisibility());
 
         if (!AppConstants.Versions.preO) {
             notificationBuilder.setChannelId(NotificationHelper.getInstance(mContext)
                     .getNotificationChannel(NotificationHelper.Channel.DEFAULT).getId());
         }
 
-        currentNotification = notificationBuilder.build();
-    }
-
-    /* package */ Notification getCurrentNotification() {
-        return currentNotification;
-    }
-
-    private void updateNotification(Tab tab) {
-        ThreadUtils.assertNotOnUiThread();
-
-        final boolean isPlaying = isMediaPlaying();
-        final int visibility = tab.isPrivate() ? Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC;
-        setCurrentNotification(tab, isPlaying, visibility);
-
-        if (isPlaying) {
-            startForegroundService();
-        } else {
-            stopForegroundService();
-            NotificationManagerCompat.from(mContext).notify(R.id.mediaControlNotification, getCurrentNotification());
-        }
+        return notificationBuilder.build();
     }
 
     private Notification.Action createNotificationAction() {
         final Intent intent = createIntentUponState(sMediaState);
         boolean isPlayAction = intent.getAction().equals(ACTION_RESUME);
 
         int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause;
         String title = mContext.getString(isPlayAction ? R.string.media_play : R.string.media_pause);
@@ -443,75 +445,84 @@ public class GeckoMediaControlAgent {
     @VisibleForTesting
     Intent createIntentUponState(State state) {
         String action = state.equals(State.PLAYING) ? ACTION_PAUSE : ACTION_RESUME;
         final Intent intent = new Intent(mContext, MediaControlService.class);
         intent.setAction(action);
         return intent;
     }
 
-    private PendingIntent createContentIntent(Tab tab) {
-        Intent intent = IntentHelper.getTabSwitchIntent(tab);
+    private PendingIntent createContentIntent(int tabId) {
+        Intent intent = IntentHelper.getTabSwitchIntent(tabId);
         return PendingIntent.getActivity(mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
     }
 
     private PendingIntent createDeleteIntent() {
         Intent intent = new Intent(mContext, MediaControlService.class);
         intent.setAction(ACTION_STOP);
         return PendingIntent.getService(mContext, 1, intent, 0);
     }
 
-    private Bitmap generateCoverArt(Tab tab) {
-        final Bitmap favicon = tab.getFavicon();
+    private byte[] generateCoverArt(Bitmap tabFavicon) {
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
 
         // 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) {
+        if (tabFavicon == null || tabFavicon.getWidth() < minCoverSize || tabFavicon.getHeight() < minCoverSize) {
             // Use the launcher icon as fallback
-            return BitmapFactory.decodeResource(mContext.getResources(), R.drawable.notification_media);
+            BitmapFactory.decodeResource(mContext.getResources(), R.drawable.notification_media).compress(Bitmap.CompressFormat.PNG, 100, stream);
+            return stream.toByteArray();
         }
 
         // 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);
+        int width = Math.max(tabFavicon.getWidth(), coverSize / 2);
+        int height = Math.max(tabFavicon.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()),
+        canvas.drawBitmap(tabFavicon,
+                new Rect(0, 0, tabFavicon.getWidth(), tabFavicon.getHeight()),
                 new Rect(left, top, right, bottom),
                 paint);
 
-        return coverArt;
+        coverArt.compress(Bitmap.CompressFormat.PNG, 100, stream);
+        return stream.toByteArray();
+    }
+
+    private void toggleForegroundService(boolean startService) {
+        toggleForegroundService(startService, null);
     }
 
     @SuppressLint("NewApi")
-    private void startForegroundService() {
+    private void toggleForegroundService(boolean startService, MediaNotification mediaNotification) {
         Intent intent = new Intent(mContext, MediaControlService.class);
+        if (!startService) {
+            intent.setAction(GeckoMediaControlAgent.ACTION_SHUTDOWN);
+        }
+
+        if (mediaNotification != null) {
+            intent.putExtra(GeckoMediaControlAgent.EXTRA_NOTIFICATION_DATA, mediaNotification);
+        }
 
         if (AppConstants.Versions.preO) {
             mContext.startService(intent);
         } else {
             mContext.startForegroundService(intent);
         }
     }
 
-    private void stopForegroundService() {
-        mContext.stopService(new Intent(mContext, MediaControlService.class));
-    }
-
     private void release() {
         if (!mInitialized) {
             return;
         }
         mInitialized = false;
 
         Log.d(LOGTAG, "release");
         if (!sMediaState.equals(State.STOPPED)) {
--- a/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
@@ -1,27 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 package org.mozilla.gecko.media;
 
+import android.annotation.SuppressLint;
+import android.app.Notification;
 import android.app.Service;
 import android.content.Intent;
 import android.os.IBinder;
 import android.util.Log;
 
+import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.notifications.NotificationHelper;
 
 public class MediaControlService extends Service {
     private static final String LOGTAG = "MediaControlService";
 
+    private Notification currentNotification;
+
+    @SuppressLint("NewApi")
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        // Initialize our current notification as a blank notification for cases when the service is started directly with the shutdown
+        // action before being started with a valid notification.
+        if (AppConstants.Versions.preO) {
+            currentNotification = new Notification.Builder(this).build();
+        } else {
+            currentNotification = new Notification.Builder(this, NotificationHelper.getInstance(this)
+                    .getNotificationChannel(NotificationHelper.Channel.DEFAULT).getId()).build();
+        }
+    }
+
     @Override
     public int onStartCommand(Intent intent, int flags, int startId) {
         Log.d(LOGTAG, "onStartCommand");
-        startForeground(R.id.mediaControlNotification, GeckoMediaControlAgent.getInstance().getCurrentNotification());
+
+        if (intent.hasExtra(GeckoMediaControlAgent.EXTRA_NOTIFICATION_DATA)) {
+            currentNotification = GeckoMediaControlAgent.getInstance().createNotification(
+                    (MediaNotification) intent.getParcelableExtra(GeckoMediaControlAgent.EXTRA_NOTIFICATION_DATA));
+        }
+
+        startForeground(R.id.mediaControlNotification, currentNotification);
 
-        if (intent != null && intent.getAction() != null) {
-            GeckoMediaControlAgent.getInstance().handleAction(intent.getAction());
+        if (intent.getAction() != null) {
+            final String action = intent.getAction();
+            if (action.equals(GeckoMediaControlAgent.ACTION_SHUTDOWN)) {
+                stopForeground(true);
+                stopSelfResult(startId);
+            } else {
+                GeckoMediaControlAgent.getInstance().handleAction(action);
+            }
         }
 
         return START_NOT_STICKY;
     }
 
     @Override
     public IBinder onBind(Intent intent) {
         return null;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaNotification.java
@@ -0,0 +1,88 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.media;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public final class MediaNotification implements Parcelable {
+    private final boolean onGoing;
+    private final int visibility;
+    private final int tabId;
+    private final String title;
+    private final String text;
+    private final byte[] bitmapBytes;
+
+    /* package */ MediaNotification(boolean onGoing, int visibility, int tabId,
+                      String title, String content, byte[] bitmapByteArray) {
+        this.onGoing = onGoing;
+        this.visibility = visibility;
+        this.tabId = tabId;
+        this.title = title;
+        this.text = content;
+        this.bitmapBytes = bitmapByteArray;
+    }
+
+    /* package */ boolean isOnGoing() {
+        return onGoing;
+    }
+
+    /* package */ int getVisibility() {
+        return visibility;
+    }
+
+    /* package */ int getTabId() {
+        return tabId;
+    }
+
+    /* package */ String getTitle() {
+        return title;
+    }
+
+    /* package */ String getText() {
+        return text;
+    }
+
+    /* package */ byte[] getBitmapBytes() {
+        return bitmapBytes;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeByte((byte) (onGoing ? 1 : 0));
+        dest.writeInt(visibility);
+        dest.writeInt(tabId);
+        dest.writeString(title);
+        dest.writeString(text);
+        dest.writeInt(bitmapBytes.length);
+        dest.writeByteArray(bitmapBytes);
+    }
+
+    public static final Parcelable.Creator<MediaNotification> CREATOR = new Parcelable.Creator<MediaNotification>() {
+        @Override
+        public MediaNotification createFromParcel(final Parcel source) {
+            final boolean onGoing = source.readByte() != 0;
+            final int visibility = source.readInt();
+            final int tabId = source.readInt();
+            final String title = source.readString();
+            final String text = source.readString();
+            final int arrayLength = source.readInt();
+            final byte[] bitmapBytes = new byte[arrayLength];
+            source.readByteArray(bitmapBytes);
+
+            return new MediaNotification(onGoing, visibility, tabId, title, text, bitmapBytes);
+        }
+
+        @Override
+        public MediaNotification[] newArray(int size) {
+            return new MediaNotification[size];
+        }
+    };
+}