Commit 34035640 authored by Sawyer Blatz's avatar Sawyer Blatz
Browse files

Closes #4896: Adds download management

parent 84f9a06e
......@@ -184,7 +184,7 @@ sealed class ContentAction : BrowserAction() {
/**
* Removes the [DownloadState] of the [ContentState] with the given [sessionId].
*/
data class ConsumeDownloadAction(val sessionId: String, val downloadId: String) : ContentAction()
data class ConsumeDownloadAction(val sessionId: String, val downloadId: Long) : ContentAction()
/**
* Updates the [HitResult] of the [ContentState] with the given [sessionId].
......
......@@ -5,7 +5,7 @@
package mozilla.components.browser.state.state.content
import android.os.Environment
import java.util.UUID
import kotlin.random.Random
/**
* Value type that represents a download request.
......@@ -16,8 +16,12 @@ import java.util.UUID
* @property contentLength The file size reported by the server.
* @property userAgent The user agent to be used for the download.
* @property destinationDirectory The matching destination directory for this type of download.
* @property filePath The file path the file was saved at.
* @property referrerUrl The site that linked to this download.
* @property skipConfirmation Whether or not the confirmation dialog should be shown before the download begins.
* @property id The unique identifier of this download.
*/
@Suppress("Deprecation")
data class DownloadState(
val url: String,
val fileName: String? = null,
......@@ -25,7 +29,9 @@ data class DownloadState(
val contentLength: Long? = null,
val userAgent: String? = null,
val destinationDirectory: String = Environment.DIRECTORY_DOWNLOADS,
val filePath: String =
Environment.getExternalStoragePublicDirectory(destinationDirectory).path + "/" + fileName,
val referrerUrl: String? = null,
val skipConfirmation: Boolean = false,
val id: String = UUID.randomUUID().toString()
val id: Long = Random.nextLong()
)
......@@ -283,7 +283,7 @@ class ContentActionTest {
@Test
fun `ConsumeDownloadAction removes download`() {
val download: DownloadState = mock()
doReturn("1337").`when`(download).id
doReturn(1337L).`when`(download).id
store.dispatch(
ContentAction.UpdateDownloadAction(tab.id, download)
......@@ -292,7 +292,7 @@ class ContentActionTest {
assertEquals(download, tab.content.download)
store.dispatch(
ContentAction.ConsumeDownloadAction(tab.id, downloadId = "1337")
ContentAction.ConsumeDownloadAction(tab.id, downloadId = 1337)
).joinBlocking()
assertNull(tab.content.download)
......@@ -301,7 +301,7 @@ class ContentActionTest {
@Test
fun `ConsumeDownloadAction does not remove download with different id`() {
val download: DownloadState = mock()
doReturn("1337").`when`(download).id
doReturn(1337L).`when`(download).id
store.dispatch(
ContentAction.UpdateDownloadAction(tab.id, download)
......@@ -310,7 +310,7 @@ class ContentActionTest {
assertEquals(download, tab.content.download)
store.dispatch(
ContentAction.ConsumeDownloadAction(tab.id, downloadId = "4223")
ContentAction.ConsumeDownloadAction(tab.id, downloadId = 4223)
).joinBlocking()
assertNotNull(tab.content.download)
......
......@@ -51,6 +51,8 @@ interface Headers : Iterable<Header> {
*/
object Names {
const val CONTENT_DISPOSITION = "Content-Disposition"
const val CONTENT_RANGE = "Content-Range"
const val RANGE = "Range"
const val CONTENT_LENGTH = "Content-Length"
const val CONTENT_TYPE = "Content-Type"
const val COOKIE = "Cookie"
......
......@@ -35,6 +35,7 @@ dependencies {
implementation project(':support-ktx')
implementation project(':support-base')
implementation project(':support-utils')
implementation project(':ui-icons')
implementation Dependencies.androidx_core_ktx
implementation Dependencies.androidx_localbroadcastmanager
......
......@@ -7,9 +7,13 @@ package mozilla.components.feature.downloads
import android.annotation.TargetApi
import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE
import android.app.DownloadManager.EXTRA_DOWNLOAD_ID
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.content.IntentFilter
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.Environment
......@@ -18,27 +22,28 @@ import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Header
import mozilla.components.concept.fetch.Headers.Names.CONTENT_LENGTH
import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE
import mozilla.components.concept.fetch.Headers.Names.REFERRER
import mozilla.components.concept.fetch.Headers.Names.CONTENT_RANGE
import mozilla.components.concept.fetch.Headers.Names.RANGE
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.toMutableHeaders
import mozilla.components.feature.downloads.ext.addCompletedDownload
import mozilla.components.feature.downloads.ext.getDownloadExtra
import mozilla.components.feature.downloads.ext.withResponse
import mozilla.components.support.base.ids.NotificationIds
import mozilla.components.support.base.ids.notify
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.random.Random
/**
* Service that performs downloads through a fetch [Client] rather than through the native
......@@ -46,7 +51,8 @@ import java.io.OutputStream
*
* To use this service, you must create a subclass in your application and it to the manifest.
*/
abstract class AbstractFetchDownloadService : CoroutineService() {
@Suppress("TooManyFunctions", "LargeClass")
abstract class AbstractFetchDownloadService : Service() {
protected abstract val httpClient: Client
@VisibleForTesting
......@@ -54,55 +60,221 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
@VisibleForTesting
internal val context: Context get() = this
override fun onCreate() {
startForeground(
NotificationIds.getIdForTag(context, ONGOING_DOWNLOAD_NOTIFICATION_TAG),
DownloadNotification.createOngoingDownloadNotification(context)
)
super.onCreate()
internal var downloadJobs = mutableMapOf<Long, DownloadJobState>()
internal data class DownloadJobState(
var job: Job? = null,
var state: DownloadState,
var currentBytesCopied: Long = 0,
var status: DownloadJobStatus,
var foregroundServiceId: Int = 0
)
internal enum class DownloadJobStatus {
ACTIVE,
PAUSED,
CANCELLED,
FAILED
}
internal val broadcastReceiver by lazy {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val downloadId =
intent?.extras?.getLong(DownloadNotification.EXTRA_DOWNLOAD_ID) ?: return
val currentDownloadJobState = downloadJobs[downloadId] ?: return
when (intent.action) {
ACTION_PAUSE -> {
currentDownloadJobState.status = DownloadJobStatus.PAUSED
currentDownloadJobState.job?.cancel()
}
ACTION_RESUME -> {
NotificationManagerCompat.from(context).cancel(
currentDownloadJobState.foregroundServiceId
)
currentDownloadJobState.status = DownloadJobStatus.ACTIVE
currentDownloadJobState.job = CoroutineScope(IO).launch {
startDownloadJob(currentDownloadJobState.state)
}
}
ACTION_CANCEL -> {
currentDownloadJobState.status = DownloadJobStatus.CANCELLED
stopForeground(true)
currentDownloadJobState.job?.cancel()
}
ACTION_TRY_AGAIN -> {
NotificationManagerCompat.from(context).cancel(
currentDownloadJobState.foregroundServiceId
)
currentDownloadJobState.status = DownloadJobStatus.ACTIVE
currentDownloadJobState.job = CoroutineScope(IO).launch {
startDownloadJob(currentDownloadJobState.state)
}
}
ACTION_OPEN -> {
// Create a new file with the location of the saved file to extract the correct path
// `file` has the wrong path, so we must construct it based on the `fileName` and `dir.path`s
val fileLocation = File(currentDownloadJobState.state.filePath)
val filePath = FileProvider.getUriForFile(
context,
context.packageName + FILE_PROVIDER_EXTENSION,
fileLocation
)
val newIntent = Intent(ACTION_VIEW).apply {
setDataAndType(filePath, currentDownloadJobState.state.contentType ?: "*/*")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
startActivity(newIntent)
}
}
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override suspend fun onStartCommand(intent: Intent?, flags: Int) {
val download = intent?.getDownloadExtra() ?: return
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val download = intent?.getDownloadExtra() ?: return START_REDELIVER_INTENT
registerForUpdates()
val foregroundServiceId = Random.nextInt()
// Create a new job and add it, with its downloadState to the map
downloadJobs[download.id] = DownloadJobState(
state = download,
foregroundServiceId = foregroundServiceId,
status = DownloadJobStatus.ACTIVE
)
downloadJobs[download.id]?.job = CoroutineScope(IO).launch {
startDownloadJob(download)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
downloadJobs.values.forEach {
it.job?.cancel()
}
}
internal fun startDownloadJob(download: DownloadState) {
val notification = try {
performDownload(download)
DownloadNotification.createDownloadCompletedNotification(context, download.fileName)
when (downloadJobs[download.id]?.status) {
DownloadJobStatus.CANCELLED -> { return }
DownloadJobStatus.PAUSED -> {
DownloadNotification.createPausedDownloadNotification(context, download)
}
DownloadJobStatus.ACTIVE -> {
DownloadNotification.createDownloadCompletedNotification(context, download)
}
DownloadJobStatus.FAILED -> {
DownloadNotification.createDownloadFailedNotification(context, download)
}
null -> { return }
}
} catch (e: IOException) {
DownloadNotification.createDownloadFailedNotification(context, download.fileName)
DownloadNotification.createDownloadFailedNotification(context, download)
}
NotificationManagerCompat.from(context).notify(
downloadJobs[download.id]?.foregroundServiceId ?: 0,
notification
)
sendDownloadCompleteBroadcast(download.id)
}
private fun registerForUpdates() {
val filter = IntentFilter().apply {
addAction(ACTION_PAUSE)
addAction(ACTION_RESUME)
addAction(ACTION_CANCEL)
addAction(ACTION_TRY_AGAIN)
addAction(ACTION_OPEN)
}
context.registerReceiver(broadcastReceiver, filter)
}
private fun displayOngoingDownloadNotification(download: DownloadState) {
val ongoingDownloadNotification = DownloadNotification.createOngoingDownloadNotification(
context,
COMPLETED_DOWNLOAD_NOTIFICATION_TAG,
notification
download
)
val downloadID = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1)
sendDownloadCompleteBroadcast(downloadID)
// We want to startForeground so that the system is less likely to kill our service under memory pressure.
startForeground(
downloadJobs[download.id]?.foregroundServiceId ?: 0,
ongoingDownloadNotification
)
}
private suspend fun performDownload(download: DownloadState) = withContext(IO) {
val headers = listOf(
CONTENT_TYPE to download.contentType,
CONTENT_LENGTH to download.contentLength?.toString(),
REFERRER to download.referrerUrl
).mapNotNull { (name, value) ->
if (value.isNullOrBlank()) null else Header(name, value)
}.toMutableHeaders()
@Suppress("ComplexCondition")
internal fun performDownload(download: DownloadState) {
val isResumingDownload = downloadJobs[download.id]?.currentBytesCopied ?: 0L > 0L
val headers = MutableHeaders()
val request = Request(download.url, headers = headers)
if (isResumingDownload) {
headers.append(RANGE, "bytes=${downloadJobs[download.id]?.currentBytesCopied}-")
}
val request = Request(download.url, headers = headers)
val response = httpClient.fetch(request)
// If we are resuming a download and the response does not contain a CONTENT_RANGE
// we cannot be sure that the request will properly be handled
if (response.status != PARTIAL_CONTENT_STATUS && response.status != OK_STATUS ||
(isResumingDownload && !response.headers.contains(CONTENT_RANGE))) {
// We experienced a problem trying to fetch the file, send a failure notification
downloadJobs[download.id]?.currentBytesCopied = 0
downloadJobs[download.id]?.status = DownloadJobStatus.FAILED
return
}
response.body.useStream { inStream ->
useFileStream(download.withResponse(response.headers, inStream)) { outStream ->
inStream.copyTo(outStream)
val newDownloadState = download.withResponse(response.headers, inStream)
downloadJobs[download.id]?.state = newDownloadState
displayOngoingDownloadNotification(newDownloadState)
useFileStream(newDownloadState, isResumingDownload) { outStream ->
copyInChunks(downloadJobs[download.id]!!, inStream, outStream)
}
}
}
private fun copyInChunks(downloadJobState: DownloadJobState, inStream: InputStream, outStream: OutputStream) {
// To ensure that we copy all files (even ones that don't have fileSize, we must NOT check < fileSize
while (downloadJobState.status == DownloadJobStatus.ACTIVE) {
val data = ByteArray(CHUNK_SIZE)
val bytesRead = inStream.read(data)
// If bytesRead is -1, there's no data left to read from the stream
if (bytesRead == -1) { break }
downloadJobState.currentBytesCopied += bytesRead
outStream.write(data, 0, bytesRead)
}
}
/**
* Informs [mozilla.components.feature.downloads.manager.FetchDownloadManager] that a download
* has been completed.
......@@ -121,12 +293,13 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
*/
internal fun useFileStream(
download: DownloadState,
append: Boolean,
block: (OutputStream) -> Unit
) {
if (SDK_INT >= Build.VERSION_CODES.Q) {
useFileStreamScopedStorage(download, block)
} else {
useFileStreamLegacy(download, block)
useFileStreamLegacy(download, append, block)
}
}
......@@ -153,10 +326,11 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
@TargetApi(Build.VERSION_CODES.P)
@Suppress("Deprecation")
private fun useFileStreamLegacy(download: DownloadState, block: (OutputStream) -> Unit) {
private fun useFileStreamLegacy(download: DownloadState, append: Boolean, block: (OutputStream) -> Unit) {
val dir = Environment.getExternalStoragePublicDirectory(download.destinationDirectory)
val file = File(dir, download.fileName!!)
FileOutputStream(file).use(block)
FileOutputStream(file, append).use(block)
addCompletedDownload(
title = download.fileName!!,
......@@ -173,7 +347,15 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
}
companion object {
private const val ONGOING_DOWNLOAD_NOTIFICATION_TAG = "OngoingDownload"
private const val COMPLETED_DOWNLOAD_NOTIFICATION_TAG = "CompletedDownload"
private const val FILE_PROVIDER_EXTENSION = ".fileprovider"
private const val CHUNK_SIZE = 4 * 1024
private const val PARTIAL_CONTENT_STATUS = 206
private const val OK_STATUS = 200
const val ACTION_OPEN = "mozilla.components.feature.downloads.OPEN"
const val ACTION_PAUSE = "mozilla.components.feature.downloads.PAUSE"
const val ACTION_RESUME = "mozilla.components.feature.downloads.RESUME"
const val ACTION_CANCEL = "mozilla.components.feature.downloads.CANCEL"
const val ACTION_TRY_AGAIN = "mozilla.components.feature.downloads.TRY_AGAIN"
}
}
/* 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.app.Service
import android.content.Intent
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PROTECTED
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Service that runs suspend functions in parallel.
* When all jobs are completed, the service is stopped automatically.
*/
abstract class CoroutineService(
jobDispatcher: CoroutineDispatcher = Dispatchers.IO
) : Service() {
private val scope = CoroutineScope(jobDispatcher)
private val runningJobs = mutableSetOf<Job>()
/**
* Called by every time a client explicitly starts the service by calling
* [android.content.Context.startService], providing the arguments it supplied.
* Do not call this method directly.
*
* @param intent The Intent supplied to [android.content.Context.startService], as given.
* This may be null if the service is being restarted after its process has gone away.
* @param flags Additional data about this start request.
*/
@VisibleForTesting(otherwise = PROTECTED)
internal abstract suspend fun onStartCommand(intent: Intent?, flags: Int)
/**
* Starts a job using [onStartCommand] then stops the service once all jobs are complete.
*/
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val job = scope.launch { onStartCommand(intent, flags) }
synchronized(runningJobs) {
runningJobs.add(job)
}
job.invokeOnCompletion { cleanupJob(job) }
return START_REDELIVER_INTENT
}
/**
* Stops all jobs when the service is destroyed.
*/
@CallSuper
override fun onDestroy() {
scope.cancel()
}
private fun cleanupJob(job: Job) = synchronized(runningJobs) {
runningJobs.remove(job)
if (runningJobs.isEmpty()) {
stopSelf()
}
}
}
......@@ -5,8 +5,9 @@
package mozilla.components.feature.downloads
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.appcompat.app.AppCompatDialogFragment
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.MEGABYTE
import mozilla.components.support.utils.DownloadUtils
/**
......@@ -15,7 +16,7 @@ import mozilla.components.support.utils.DownloadUtils
* If [SimpleDownloadDialogFragment] is not flexible enough for your use case you should inherit for this class.
* Be mindful to call [onStartDownload] when you want to start the download.
*/
abstract class DownloadDialogFragment : DialogFragment() {
abstract class DownloadDialogFragment : AppCompatDialogFragment() {
/**
* A callback to trigger a download, call it when you are ready to start a download. For instance,
......@@ -27,7 +28,7 @@ abstract class DownloadDialogFragment : DialogFragment() {
var onCancelDownload: () -> Unit = {}
/**
* add the metadata of this download object to the arguments of this fragment.
* Add the metadata of this download object to the arguments of this fragment.
*/
fun setDownload(download: DownloadState) {
val args = arguments ?: Bundle()
......@@ -53,5 +54,11 @@ abstract class DownloadDialogFragment : DialogFragment() {
const val KEY_URL = "KEY_URL"
const val FRAGMENT_TAG = "SHOULD_DOWNLOAD_PROMPT_DIALOG"
const val MEGABYTE = 1024.0 * 1024.0
}
}