Commit 1559d1c8 authored by Sebastian Kaspari's avatar Sebastian Kaspari
Browse files

Closes #2450: Add MediaNotificationFeature to display notification while web...

Closes #2450: Add MediaNotificationFeature to display notification while web content media is playing.
parent 8ba1a8d0
......@@ -39,6 +39,7 @@ dependencies {
testImplementation project(':support-test')
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_junit
testImplementation Dependencies.testing_mockito
testImplementation Dependencies.testing_robolectric
......
......@@ -2,4 +2,12 @@
- 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/. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="mozilla.components.feature.media" />
package="mozilla.components.feature.media">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application>
<service android:name=".service.MediaService" />
</application>
</manifest>
/* 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 mozilla.components.feature.media.notification
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import mozilla.components.browser.session.Session
import mozilla.components.feature.media.R
import mozilla.components.feature.media.state.MediaState
import java.lang.IllegalArgumentException
private const val NOTIFICATION_CHANNEL_ID = "Media"
/**
* Helper to display a notification for web content playing media.
*/
internal class MediaNotification(
private val context: Context
) {
/**
* Creates a new [Notification] for the given [state].
*/
fun create(state: MediaState): Notification {
MediaNotificationChannel.ensureChannelExists(context)
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val data = state.toNotificationData()
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(data.icon)
.setContentTitle(data.title)
.setContentText(data.description)
.setContentIntent(pendingIntent)
.setLargeIcon(data.largeIcon)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}
private fun MediaState.toNotificationData(): NotificationData {
return when (this) {
is MediaState.Playing -> NotificationData(
title = session.titleOrUrl,
description = session.url,
icon = R.drawable.mozac_feature_media_playing,
largeIcon = session.icon
)
is MediaState.Paused -> NotificationData(
title = session.titleOrUrl,
description = session.url,
icon = R.drawable.mozac_feature_media_paused,
largeIcon = session.icon
)
else -> throw IllegalArgumentException("Cannot create notification for state: $this")
}
}
private val Session.titleOrUrl
get() = if (title.isNotEmpty()) title else url
private data class NotificationData(
val title: String,
val description: String,
@DrawableRes val icon: Int,
val largeIcon: Bitmap? = null
)
/* 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 mozilla.components.feature.media.notification
import android.content.Context
import android.content.Intent
import mozilla.components.feature.media.service.MediaService
import mozilla.components.feature.media.state.MediaState
import mozilla.components.feature.media.state.MediaStateMachine
import java.lang.ref.WeakReference
/**
* Feature for displaying an ongoing notification (keeping the app process alive) while web content is playing media.
*
* This feature should get initialized globally once on app start.
*/
class MediaNotificationFeature(
private val context: Context,
private val stateMachine: MediaStateMachine
) {
private var serviceRunning = false
/**
* Enables the feature.
*/
fun enable() {
stateMachine.register(MediaObserver(this))
}
internal fun startMediaService(state: MediaState) {
lastState = WeakReference(state)
context.startService(Intent(context, MediaService::class.java))
serviceRunning = true
}
internal fun stopMediaService() {
if (serviceRunning) {
lastState.clear()
context.stopService(Intent(context, MediaService::class.java))
}
}
companion object {
private var lastState = WeakReference<MediaState>(null)
internal fun getState(): MediaState {
return lastState.get() ?: MediaState.None
}
}
}
internal class MediaObserver(
private val feature: MediaNotificationFeature
) : MediaStateMachine.Observer {
override fun onStateChanged(state: MediaState) {
if (state is MediaState.Playing || state is MediaState.Paused) {
feature.startMediaService(state)
} else if (state is MediaState.None) {
feature.stopMediaService()
}
}
}
/* 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 mozilla.components.feature.media.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import mozilla.components.feature.media.notification.MediaNotification
import mozilla.components.feature.media.notification.MediaNotificationFeature
import mozilla.components.feature.media.state.MediaState
import mozilla.components.support.base.ids.NotificationIds
import mozilla.components.support.base.log.logger.Logger
private const val NOTIFICATION_TAG = "mozac.feature.media.foreground-service"
/**
* A foreground service that will keep the process alive while we are playing media (with the app possibly in the
* background) and shows an ongoing notification
*/
internal class MediaService : Service() {
private val logger = Logger("MediaService")
private val notification = MediaNotification(this)
override fun onCreate() {
super.onCreate()
logger.debug("Service created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
logger.debug("Command received")
updateNotification()
return START_NOT_STICKY
}
private fun updateNotification() {
val state = MediaNotificationFeature.getState()
if (state == MediaState.None) {
stopSelf()
return
}
val notificationId = NotificationIds.getIdForTag(this, NOTIFICATION_TAG)
startForeground(notificationId, notification.create(state))
}
override fun onBind(intent: Intent?): IBinder? = null
}
......@@ -20,6 +20,9 @@ sealed class MediaState {
/**
* Playing: [media] of [session] is currently playing.
*
* @property session The [Session] with currently playing media.
* @property media The playing [Media] of the [Session].
*/
data class Playing(
val session: Session,
......@@ -28,6 +31,9 @@ sealed class MediaState {
/**
* Paused: [media] of [session] is currently paused.
*
* @property session The [Session] with currently paused media.
* @property media The paused [Media] of the [Session].
*/
data class Paused(
val session: Session,
......
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M7,9v6h4l5,5V4l-5,5H7z"/>
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>
/* 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 mozilla.components.feature.media.notification
import android.content.Context
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.media.Media
import mozilla.components.feature.media.state.MediaStateMachine
import mozilla.components.feature.media.state.MockMedia
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Test
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
class MediaNotificationFeatureTest {
@Test
fun `Media playing in Session starts service`() {
val context: Context = mock()
val sessionManager = SessionManager(engine = mock())
val stateMachine = MediaStateMachine(sessionManager)
stateMachine.start()
val feature = MediaNotificationFeature(context, stateMachine)
feature.enable()
// A session gets added
val session = Session("https://www.mozilla.org")
sessionManager.add(session)
// A media object gets added to the session
val media = MockMedia(Media.PlaybackState.UNKNOWN)
session.media = listOf(media)
media.playbackState = Media.PlaybackState.WAITING
// So far nothing has happened yet
verify(context, never()).startService(any())
// Media starts playing!
media.playbackState = Media.PlaybackState.PLAYING
verify(context).startService(any())
}
@Test
fun `Media switching from playing to pause send Intent to service`() {
val context: Context = mock()
val media = MockMedia(Media.PlaybackState.PLAYING)
val sessionManager = SessionManager(engine = mock()).apply {
add(Session("https://www.mozilla.org").also { it.media = listOf(media) })
}
val stateMachine = MediaStateMachine(sessionManager)
stateMachine.start()
val feature = MediaNotificationFeature(context, stateMachine)
feature.enable()
reset(context)
verify(context, never()).startService(any())
media.playbackState = Media.PlaybackState.PAUSE
verify(context).startService(any())
}
@Test
fun `Media stopping to play with stop service`() {
val context: Context = mock()
val media = MockMedia(Media.PlaybackState.UNKNOWN)
val sessionManager = SessionManager(engine = mock()).apply {
add(Session("https://www.mozilla.org").also { it.media = listOf(media) })
}
val stateMachine = MediaStateMachine(sessionManager)
stateMachine.start()
val feature = MediaNotificationFeature(context, stateMachine)
feature.enable()
media.playbackState = Media.PlaybackState.PLAYING
verify(context).startService(any())
verify(context, never()).stopService(any())
media.playbackState = Media.PlaybackState.ENDED
verify(context).stopService(any())
}
}
/* 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 mozilla.components.feature.media.notification
import android.app.Notification
import androidx.core.app.NotificationCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.session.Session
import mozilla.components.concept.engine.media.Media
import mozilla.components.feature.media.R
import mozilla.components.feature.media.state.MediaState
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.IllegalArgumentException
@RunWith(AndroidJUnit4::class)
class MediaNotificationTest {
@Test
fun `media notification for playing state`() {
val state = MediaState.Playing(
Session("https://www.mozilla.org").apply {
title = "Mozilla"
},
listOf(
MockMedia(Media.PlaybackState.PLAYING)
))
val notification = MediaNotification(testContext)
.create(state)
assertEquals("https://www.mozilla.org", notification.text)
assertEquals("Mozilla", notification.title)
assertEquals(R.drawable.mozac_feature_media_playing, notification.iconResource)
}
@Test
fun `media notification for paused state`() {
val state = MediaState.Paused(
Session("https://www.mozilla.org").apply {
title = "Mozilla"
},
listOf(
MockMedia(Media.PlaybackState.PAUSE)
))
val notification = MediaNotification(testContext)
.create(state)
assertEquals("https://www.mozilla.org", notification.text)
assertEquals("Mozilla", notification.title)
assertEquals(R.drawable.mozac_feature_media_paused, notification.iconResource)
}
@Test(expected = IllegalArgumentException::class)
fun `media notification for none state`() {
// This notification will actually never get displayed
val state = MediaState.None
MediaNotification(testContext)
.create(state)
}
@Test
fun `media notification for playing state with session without title`() {
val state = MediaState.Playing(
Session("https://www.mozilla.org"),
listOf(
MockMedia(Media.PlaybackState.PLAYING)
))
val notification = MediaNotification(testContext)
.create(state)
assertEquals("https://www.mozilla.org", notification.text)
assertEquals("https://www.mozilla.org", notification.title)
assertEquals(R.drawable.mozac_feature_media_playing, notification.iconResource)
}
}
internal class MockMedia(
initialState: PlaybackState
) : Media() {
init {
playbackState = initialState
}
override val controller: Controller = mock()
}
private val Notification.text: String?
get() = extras.getString(NotificationCompat.EXTRA_TEXT)
private val Notification.title: String?
get() = extras.getString(NotificationCompat.EXTRA_TITLE)
private val Notification.iconResource: Int
@Suppress("DEPRECATION")
get() = icon
\ No newline at end of file
/* 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 mozilla.components.feature.media.service
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.media.Media
import mozilla.components.feature.media.notification.MediaNotificationFeature
import mozilla.components.feature.media.state.MediaStateMachine
import mozilla.components.feature.media.state.MockMedia
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.Mockito.never
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
import org.robolectric.Robolectric
@RunWith(AndroidJUnit4::class)
class MediaServiceTest {
@Test
fun `Playing state starts service in foreground`() {
val media = MockMedia(Media.PlaybackState.UNKNOWN)
val sessionManager = SessionManager(engine = mock()).apply {
add(Session("https://www.mozilla.org").also { it.media = listOf(media) })
}
val stateMachine = MediaStateMachine(sessionManager)
stateMachine.start()
val feature = MediaNotificationFeature(mock(), stateMachine)
feature.enable()
media.playbackState = Media.PlaybackState.PLAYING
val service = spy(Robolectric.buildService(MediaService::class.java)
.create()
.get())
service.onStartCommand(Intent(), 0, 0)
verify(service).startForeground(ArgumentMatchers.anyInt(), any())
}
@Test
fun `Switching from playing to none stops service`() {
val media = MockMedia(Media.PlaybackState.UNKNOWN)
val sessionManager = SessionManager(engine = mock()).apply {
add(Session("https://www.mozilla.org").also { it.media = listOf(media) })
}
val stateMachine = MediaStateMachine(sessionManager)
stateMachine.start()
val feature = MediaNotificationFeature(mock(), stateMachine)
feature.enable()
media.playbackState = Media.PlaybackState.PLAYING
val service = spy(Robolectric.buildService(MediaService::class.java)
.create()
.get())
service.onStartCommand(Intent(), 0, 0)
verify(service, never()).stopSelf()
media.playbackState = Media.PlaybackState.ENDED
service.onStartCommand(Intent(), 0, 0)
verify(service).stopSelf()
}
}
......@@ -12,6 +12,9 @@ permalink: /changelog/
* [Gecko](https://github.com/mozilla-mobile/android-components/blob/master/buildSrc/src/main/java/Gecko.kt)
* [Configuration](https://github.com/mozilla-mobile/android-components/blob/master/buildSrc/src/main/java/Config.kt)
* **feature-media**
* Added `MediaNotificationFeature` - a feature implementation to show an ongoing notification (keeping the app process alive) while web content is playing media.