Commit ff020476 authored by MozLando's avatar MozLando
Browse files

Merge #6954

6954: Closes #6893 Fulfill the foreground service contract on AbstractFetchDownloadService r=notWoods,pocmo a=Amejia481

For fulfilling the Android foreground service api, we need to provide a notification, this notification will be marked as ongoing by the OS, and we won't be able to remove it until we are not longer a foreground a service by calling `stopForeground`. For this reason, I used two approaches:

**Devices that support notification group:**  [Demo 🎮 ](https://drive.google.com/file/d/1xaGh4qx_3913t4s1GenvIJphRFWNQvWP/view?usp=sharing)

A separate notification (a summary of all notification) which will be the foreground notification, it will be always present until we don't have more active downloads.

**Devices that DO NOT support notification group:**  [Demo 🎮  (Tested on Android 5.1.1) ](https://drive.google.com/file/d/10f26Ggh5uXVn5gCbrCBp3o5s2eNoAbzU/view?usp=sharing)

Set the latest active notification as the foreground notification and  keep changing it to the latest active download.

**Note:**
To reduce battery consumption when all the downloads notifications have been dismissed we stop the service.

Related issues https://github.com/mozilla-mobile/android-components/issues/4910

Co-authored-by: default avatarArturo Mejia <arturomejiamarmol@gmail.com>
parents 0f6e197c fd18d393
......@@ -12,28 +12,64 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_NONE
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_CANCEL
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_DISMISS
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_OPEN
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_PAUSE
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_RESUME
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.ACTION_TRY_AGAIN
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobState
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.CANCELLED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.COMPLETED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.ACTIVE
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.FAILED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.PAUSED
import kotlin.random.Random
@Suppress("TooManyFunctions")
@Suppress("LargeClass", "TooManyFunctions")
internal object DownloadNotification {
private const val NOTIFICATION_CHANNEL_ID = "mozac.feature.downloads.generic"
private const val NOTIFICATION_GROUP_KEY = "mozac.feature.downloads.group"
internal const val NOTIFICATION_DOWNLOAD_GROUP_ID = 100
private const val LEGACY_NOTIFICATION_CHANNEL_ID = "Downloads"
private const val PERCENTAGE_MULTIPLIER = 100
internal const val PERCENTAGE_MULTIPLIER = 100
internal const val EXTRA_DOWNLOAD_ID = "downloadId"
@VisibleForTesting
internal fun createDownloadGroupNotification(
context: Context,
notifications: List<DownloadJobState>
): Notification {
val allDownloadsHaveFinished = notifications.all { it.status != ACTIVE }
val icon = if (allDownloadsHaveFinished) {
R.drawable.mozac_feature_download_ic_download_complete
} else {
R.drawable.mozac_feature_download_ic_ongoing_download
}
val summaryList = getSummaryList(context, notifications)
val summaryLine1 = summaryList.first()
val summaryLine2 = if (summaryList.size == 2) summaryList[1] else ""
return NotificationCompat.Builder(context, ensureChannelExists(context))
.setSmallIcon(icon)
.setColor(ContextCompat.getColor(context, R.color.mozac_feature_downloads_notification))
.setContentTitle(context.getString(R.string.mozac_feature_downloads_notification_channel))
.setContentText(summaryList.joinToString("\n"))
.setStyle(NotificationCompat.InboxStyle().addLine(summaryLine1).addLine(summaryLine2))
.setGroup(NOTIFICATION_GROUP_KEY)
.setGroupSummary(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
}
/**
* Build the notification to be displayed while the download service is active.
*/
......@@ -44,18 +80,12 @@ internal object DownloadNotification {
val downloadState = downloadJobState.state
val bytesCopied = downloadJobState.currentBytesCopied
val channelId = ensureChannelExists(context)
val fileSizeText = (downloadState.contentLength?.toMegabyteString() ?: "")
val isIndeterminate = downloadState.contentLength == null
val contentText = if (isIndeterminate) {
fileSizeText
} else {
"${PERCENTAGE_MULTIPLIER * bytesCopied / downloadState.contentLength!!}%"
}
return NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.mozac_feature_download_ic_ongoing_download)
.setContentTitle(downloadState.fileName)
.setContentText(contentText)
.setContentText(downloadJobState.getProgress())
.setColor(ContextCompat.getColor(context, R.color.mozac_feature_downloads_notification))
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setProgress(downloadState.contentLength?.toInt() ?: 0, bytesCopied.toInt(), isIndeterminate)
......@@ -65,6 +95,7 @@ internal object DownloadNotification {
.addAction(getPauseAction(context, downloadState.id))
.addAction(getCancelAction(context, downloadState.id))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCompatGroup(NOTIFICATION_GROUP_KEY)
.build()
}
......@@ -86,6 +117,8 @@ internal object DownloadNotification {
.setOnlyAlertOnce(true)
.addAction(getResumeAction(context, downloadState.id))
.addAction(getCancelAction(context, downloadState.id))
.setDeleteIntent(createDismissPendingIntent(context, downloadState.id))
.setCompatGroup(NOTIFICATION_GROUP_KEY)
.build()
}
......@@ -105,6 +138,8 @@ internal object DownloadNotification {
.setColor(ContextCompat.getColor(context, R.color.mozac_feature_downloads_notification))
.setContentIntent(createPendingIntent(context, ACTION_OPEN, downloadState.id))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDeleteIntent(createDismissPendingIntent(context, downloadState.id))
.setCompatGroup(NOTIFICATION_GROUP_KEY)
.build()
}
......@@ -126,9 +161,18 @@ internal object DownloadNotification {
.setWhen(downloadJobState.createdTime)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDeleteIntent(createDismissPendingIntent(context, downloadState.id))
.setCompatGroup(NOTIFICATION_GROUP_KEY)
.build()
}
@VisibleForTesting
internal fun getSummaryList(context: Context, notifications: List<DownloadJobState>): List<String> {
return notifications.take(2).map { downloadState ->
"${downloadState.state.fileName} ${downloadState.getStatusDescription(context)}"
}
}
/**
* Check if notifications from the download channel are enabled.
* Verifies that app notifications, channel notifications, and group notifications are enabled.
......@@ -211,6 +255,10 @@ internal object DownloadNotification {
).build()
}
private fun createDismissPendingIntent(context: Context, downloadStateId: Long): PendingIntent {
return createPendingIntent(context, ACTION_DISMISS, downloadStateId)
}
private fun createPendingIntent(context: Context, action: String, downloadStateId: Long): PendingIntent {
val intent = Intent(action)
intent.setPackage(context.applicationContext.packageName)
......@@ -221,3 +269,44 @@ internal object DownloadNotification {
return PendingIntent.getBroadcast(context.applicationContext, Random.nextInt(), intent, 0)
}
}
@VisibleForTesting
internal fun NotificationCompat.Builder.setCompatGroup(groupKey: String): NotificationCompat.Builder {
return if (SDK_INT >= Build.VERSION_CODES.N) {
setGroup(groupKey)
} else this
}
@VisibleForTesting
internal fun DownloadJobState.getProgress(): String {
val bytesCopied = currentBytesCopied
val isIndeterminate = state.contentLength == null || bytesCopied == 0L
return if (isIndeterminate) {
""
} else {
"${DownloadNotification.PERCENTAGE_MULTIPLIER * bytesCopied / state.contentLength!!}%"
}
}
@VisibleForTesting
internal fun DownloadJobState.getStatusDescription(context: Context): String {
return when (this.status) {
ACTIVE -> {
getProgress()
}
PAUSED -> {
context.getString(R.string.mozac_feature_downloads_paused_notification_text)
}
COMPLETED -> {
context.getString(R.string.mozac_feature_downloads_completed_notification_text2)
}
FAILED -> {
context.getString(R.string.mozac_feature_downloads_failed_notification_text2)
}
CANCELLED -> ""
}
}
/* 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.downloads
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobState
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.PAUSED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.FAILED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.ACTIVE
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.CANCELLED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.COMPLETED
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class DownloadNotificationTest {
@Test
fun getProgress() {
val downloadJobState = DownloadJobState(
job = null,
state = DownloadState(url = "mozilla.org/mozilla.txt", contentLength = 100L),
currentBytesCopied = 10,
status = ACTIVE,
foregroundServiceId = 1,
downloadDeleted = false
)
assertEquals("10%", downloadJobState.getProgress())
val newDownload = downloadJobState.copy(state = downloadJobState.state.copy(contentLength = null))
assertEquals("", newDownload.getProgress())
}
@Test
fun setCompatGroup() {
val notificationBuilder = NotificationCompat.Builder(testContext, "")
.setCompatGroup("myGroup").build()
assertEquals("myGroup", notificationBuilder.group)
}
@Test
@Config(sdk = [Build.VERSION_CODES.M])
fun `setCompatGroup will not set the group`() {
val notificationBuilder = NotificationCompat.Builder(testContext, "")
.setCompatGroup("myGroup").build()
assertNotEquals("myGroup", notificationBuilder.group)
}
@Test
fun getStatusDescription() {
val pausedText = testContext.getString(R.string.mozac_feature_downloads_paused_notification_text)
val completedText = testContext.getString(R.string.mozac_feature_downloads_completed_notification_text2)
val failedText = testContext.getString(R.string.mozac_feature_downloads_failed_notification_text2)
var downloadJobState = DownloadJobState(state = mock(), status = ACTIVE)
assertEquals(downloadJobState.getProgress(), downloadJobState.getStatusDescription(testContext))
downloadJobState = DownloadJobState(state = mock(), status = PAUSED)
assertEquals(pausedText, downloadJobState.getStatusDescription(testContext))
downloadJobState = DownloadJobState(state = mock(), status = COMPLETED)
assertEquals(completedText, downloadJobState.getStatusDescription(testContext))
downloadJobState = DownloadJobState(state = mock(), status = FAILED)
assertEquals(failedText, downloadJobState.getStatusDescription(testContext))
downloadJobState = DownloadJobState(state = mock(), status = CANCELLED)
assertEquals("", downloadJobState.getStatusDescription(testContext))
}
@Test
fun getDownloadSummary() {
val download1 = DownloadJobState(
job = null,
state = DownloadState(fileName = "mozilla.txt", url = "mozilla.org/mozilla.txt", contentLength = 100L),
currentBytesCopied = 10,
status = ACTIVE,
foregroundServiceId = 1,
downloadDeleted = false
)
val download2 = DownloadJobState(
job = null,
state = DownloadState(fileName = "mozilla2.txt", url = "mozilla.org/mozilla.txt", contentLength = 100L),
currentBytesCopied = 20,
status = ACTIVE,
foregroundServiceId = 1,
downloadDeleted = false
)
val summary = DownloadNotification.getSummaryList(testContext, listOf(download1, download2))
assertEquals(listOf("mozilla.txt 10%", "mozilla2.txt 20%"), summary)
}
}
\ No newline at end of file
......@@ -12,6 +12,10 @@ 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-downloads**
* Fixed issue [#6893](https://github.com/mozilla-mobile/android-components/issues/6893).
* Add notification grouping to downloads Fenix issue [#4910](https://github.com/mozilla-mobile/android-components/issues/4910).
* **feature-tabs**
* Makes `TabsAdapter` open to subclassing.
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment