Commit c59ff6f1 authored by Christian Sadilek's avatar Christian Sadilek
Browse files

Closes #7103 #5217: Move queued download state to browser store

parent ff020476
......@@ -558,3 +558,23 @@ sealed class MediaAction : BrowserAction() {
val aggregate: MediaState.Aggregate
) : MediaAction()
}
/**
* [BrowserAction] implementations related to updating the global download state.
*/
sealed class DownloadAction : BrowserAction() {
/**
* Updates the [BrowserState] to track the provided [download] as queued.
*/
data class QueueDownloadAction(val download: DownloadState) : DownloadAction()
/**
* Updates the [BrowserState] to remove the queued download with the provided [downloadId].
*/
data class RemoveQueuedDownloadAction(val downloadId: Long) : DownloadAction()
/**
* Updates the [BrowserState] to remove all queued downloads.
*/
object RemoveAllQueuedDownloadsAction : DownloadAction()
}
......@@ -7,6 +7,7 @@ package mozilla.components.browser.state.reducer
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.MediaAction
import mozilla.components.browser.state.action.ReaderAction
......@@ -39,6 +40,7 @@ internal object BrowserStateReducer {
is TrackingProtectionAction -> TrackingProtectionStateReducer.reduce(state, action)
is WebExtensionAction -> WebExtensionReducer.reduce(state, action)
is MediaAction -> MediaReducer.reduce(state, action)
is DownloadAction -> DownloadStateReducer.reduce(state, action)
}
}
}
......
/* 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.browser.state.reducer
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.BrowserState
internal object DownloadStateReducer {
/**
* [DownloadAction] Reducer function for modifying [BrowserState.queuedDownloads].
*/
fun reduce(state: BrowserState, action: DownloadAction): BrowserState {
return when (action) {
is DownloadAction.QueueDownloadAction -> {
state.copy(queuedDownloads = state.queuedDownloads + (action.download.id to action.download))
}
is DownloadAction.RemoveQueuedDownloadAction -> {
state.copy(queuedDownloads = state.queuedDownloads - action.downloadId)
}
is DownloadAction.RemoveAllQueuedDownloadsAction -> {
state.copy(queuedDownloads = emptyMap())
}
}
}
}
......@@ -4,6 +4,7 @@
package mozilla.components.browser.state.state
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.lib.state.State
/**
......@@ -16,11 +17,13 @@ import mozilla.components.lib.state.State
* The extensions here represent the default values for all [BrowserState.extensions] and can
* be overridden per [SessionState].
* @property media The state of all media elements and playback states for all tabs.
* @property queuedDownloads queued downloads ([DownloadState]s) mapped to their IDs.
*/
data class BrowserState(
val tabs: List<TabSessionState> = emptyList(),
val selectedTabId: String? = null,
val customTabs: List<CustomTabSessionState> = emptyList(),
val extensions: Map<String, WebExtensionState> = emptyMap(),
val media: MediaState = MediaState()
val media: MediaState = MediaState(),
val queuedDownloads: Map<Long, DownloadState> = emptyMap()
) : State
/* 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.browser.state.action
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DownloadActionTest {
@Test
fun `QueueDownloadAction adds download`() {
val store = BrowserStore(BrowserState())
val download1 = DownloadState("https://mozilla.org/download1", destinationDirectory = "")
store.dispatch(DownloadAction.QueueDownloadAction(download1)).joinBlocking()
assertEquals(download1, store.state.queuedDownloads[download1.id])
assertEquals(1, store.state.queuedDownloads.size)
val download2 = DownloadState("https://mozilla.org/download2", destinationDirectory = "")
store.dispatch(DownloadAction.QueueDownloadAction(download2)).joinBlocking()
assertEquals(download2, store.state.queuedDownloads[download2.id])
assertEquals(2, store.state.queuedDownloads.size)
}
@Test
fun `RemoveQueuedDownloadAction removes download`() {
val store = BrowserStore(BrowserState())
val download = DownloadState("https://mozilla.org/download1", destinationDirectory = "")
store.dispatch(DownloadAction.QueueDownloadAction(download)).joinBlocking()
assertEquals(download, store.state.queuedDownloads[download.id])
assertFalse(store.state.queuedDownloads.isEmpty())
store.dispatch(DownloadAction.RemoveQueuedDownloadAction(download.id)).joinBlocking()
assertTrue(store.state.queuedDownloads.isEmpty())
}
@Test
fun `RemoveAllQueuedDownloadsAction removes all downloads`() {
val store = BrowserStore(BrowserState())
val download = DownloadState("https://mozilla.org/download1", destinationDirectory = "")
val download2 = DownloadState("https://mozilla.org/download2", destinationDirectory = "")
store.dispatch(DownloadAction.QueueDownloadAction(download)).joinBlocking()
store.dispatch(DownloadAction.QueueDownloadAction(download2)).joinBlocking()
assertFalse(store.state.queuedDownloads.isEmpty())
assertEquals(2, store.state.queuedDownloads.size)
store.dispatch(DownloadAction.RemoveAllQueuedDownloadsAction).joinBlocking()
assertTrue(store.state.queuedDownloads.isEmpty())
}
}
......@@ -40,7 +40,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Headers.Names.CONTENT_RANGE
import mozilla.components.concept.fetch.Headers.Names.RANGE
......@@ -53,7 +55,6 @@ import mozilla.components.feature.downloads.AbstractFetchDownloadService.Downloa
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.PAUSED
import mozilla.components.feature.downloads.DownloadNotification.NOTIFICATION_DOWNLOAD_GROUP_ID
import mozilla.components.feature.downloads.ext.addCompletedDownload
import mozilla.components.feature.downloads.ext.getDownloadExtra
import mozilla.components.feature.downloads.ext.withResponse
import mozilla.components.feature.downloads.facts.emitNotificationResumeFact
import mozilla.components.feature.downloads.facts.emitNotificationPauseFact
......@@ -77,6 +78,7 @@ import kotlin.random.Random
*/
@Suppress("TooManyFunctions", "LargeClass")
abstract class AbstractFetchDownloadService : Service() {
protected abstract val store: BrowserStore
private val notificationUpdateScope = MainScope()
......@@ -90,6 +92,8 @@ abstract class AbstractFetchDownloadService : Service() {
internal var downloadJobs = mutableMapOf<Long, DownloadJobState>()
// TODO Move this to browser store and make immutable:
// https://github.com/mozilla-mobile/android-components/issues/7050
internal data class DownloadJobState(
var job: Job? = null,
@Volatile var state: DownloadState,
......@@ -230,7 +234,9 @@ abstract class AbstractFetchDownloadService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val download = intent?.getDownloadExtra() ?: return START_REDELIVER_INTENT
val download = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.let {
store.state.queuedDownloads[it]
} ?: return START_REDELIVER_INTENT
// If the job already exists, then don't create a new ID. This can happen when calling tryAgain
val foregroundServiceId = downloadJobs[download.id]?.foregroundServiceId ?: Random.nextInt()
......@@ -376,6 +382,7 @@ abstract class AbstractFetchDownloadService : Service() {
@VisibleForTesting
internal fun removeDownloadJob(downloadJobState: DownloadJobState) {
downloadJobs.remove(downloadJobState.state.id)
store.dispatch(DownloadAction.RemoveQueuedDownloadAction(downloadJobState.state.id))
if (downloadJobs.isEmpty()) {
stopSelf()
} else {
......@@ -570,7 +577,6 @@ abstract class AbstractFetchDownloadService : Service() {
val intent = Intent(ACTION_DOWNLOAD_COMPLETE)
intent.putExtra(EXTRA_DOWNLOAD_STATUS, getDownloadJobStatus(downloadState))
intent.putExtra(EXTRA_DOWNLOAD, downloadState.state)
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadState.state.id)
broadcastManager.sendBroadcast(intent)
......@@ -725,7 +731,6 @@ abstract class AbstractFetchDownloadService : Service() {
*/
internal const val PROGRESS_UPDATE_INTERVAL = 750L
const val EXTRA_DOWNLOAD = "mozilla.components.feature.downloads.extras.DOWNLOAD"
const val EXTRA_DOWNLOAD_STATUS = "mozilla.components.feature.downloads.extras.DOWNLOAD_STATUS"
const val ACTION_OPEN = "mozilla.components.feature.downloads.OPEN"
const val ACTION_PAUSE = "mozilla.components.feature.downloads.PAUSE"
......
/* 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.DownloadManager
import android.content.Context
import android.content.Intent
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareStore
/**
* [Middleware] implementation for managing downloads via the provided download service. Its
* purpose is to react to global download state changes (e.g. of [BrowserState.queuedDownloads])
* and notify the download service, as needed.
*/
class DownloadMiddleware(
private val applicationContext: Context,
private val downloadServiceClass: Class<*>
) : Middleware<BrowserState, BrowserAction> {
override fun invoke(
store: MiddlewareStore<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction
) {
next(action)
when (action) {
is DownloadAction.QueueDownloadAction -> {
val intent = Intent(applicationContext, downloadServiceClass)
intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, action.download.id)
applicationContext.startService(intent)
}
}
}
}
......@@ -52,14 +52,14 @@ import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
* @property dialog a reference to a [DownloadDialogFragment]. If not provided, an
* instance of [SimpleDownloadDialogFragment] will be used.
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LongParameterList")
class DownloadsFeature(
private val applicationContext: Context,
private val store: BrowserStore,
private val useCases: DownloadsUseCases,
override var onNeedToRequestPermissions: OnNeedToRequestPermissions = { },
onDownloadStopped: onDownloadStopped = noop,
private val downloadManager: DownloadManager = AndroidDownloadManager(applicationContext),
private val downloadManager: DownloadManager = AndroidDownloadManager(applicationContext, store),
private val tabId: String? = null,
private val fragmentManager: FragmentManager? = null,
private val promptsStyling: PromptsStyling? = 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.downloads.ext
import android.app.DownloadManager.EXTRA_DOWNLOAD_ID
import android.content.Intent
import androidx.core.os.bundleOf
import mozilla.components.browser.state.state.content.DownloadState
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.USER_AGENT
private const val INTENT_DOWNLOAD = "mozilla.components.feature.downloads.DOWNLOAD"
private const val INTENT_URL = "mozilla.components.feature.downloads.URL"
private const val INTENT_FILE_NAME = "mozilla.components.feature.downloads.FILE_NAME"
private const val INTENT_DESTINATION = "mozilla.components.feature.downloads.DESTINATION"
fun Intent.putDownloadExtra(download: DownloadState) {
download.apply {
putExtra(INTENT_DOWNLOAD, bundleOf(
INTENT_URL to url,
INTENT_FILE_NAME to fileName,
CONTENT_TYPE to contentType,
CONTENT_LENGTH to contentLength,
USER_AGENT to userAgent,
INTENT_DESTINATION to destinationDirectory,
REFERRER to referrerUrl,
EXTRA_DOWNLOAD_ID to id
))
}
}
fun Intent.getDownloadExtra(): DownloadState? =
getBundleExtra(INTENT_DOWNLOAD)?.run {
val url = getString(INTENT_URL)
val fileName = getString(INTENT_FILE_NAME)
val destination = getString(INTENT_DESTINATION)
val id = getLong(EXTRA_DOWNLOAD_ID)
if (url == null || destination == null) return null
DownloadState(
url = url,
fileName = fileName,
contentType = getString(CONTENT_TYPE),
contentLength = get(CONTENT_LENGTH) as? Long?,
userAgent = getString(USER_AGENT),
destinationDirectory = destination,
referrerUrl = getString(REFERRER),
id = id
)
}
......@@ -18,7 +18,9 @@ import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.core.util.set
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.fetch.Headers.Names.COOKIE
import mozilla.components.concept.fetch.Headers.Names.REFERRER
import mozilla.components.concept.fetch.Headers.Names.USER_AGENT
......@@ -36,20 +38,13 @@ typealias SystemRequest = android.app.DownloadManager.Request
*/
class AndroidDownloadManager(
private val applicationContext: Context,
private val store: BrowserStore,
override var onDownloadStopped: onDownloadStopped = noop
) : BroadcastReceiver(), DownloadManager {
private val queuedDownloads = LongSparseArray<DownloadStateWithRequest>()
private val downloadRequests = LongSparseArray<SystemRequest>()
private var isSubscribedReceiver = false
/**
* Holds both the state and the Android DownloadManager.Request for the queued download
*/
data class DownloadStateWithRequest(
val state: DownloadState,
val request: SystemRequest
)
override val permissions = arrayOf(INTERNET, WRITE_EXTERNAL_STORAGE)
/**
......@@ -60,7 +55,6 @@ class AndroidDownloadManager(
*/
@RequiresPermission(allOf = [INTERNET, WRITE_EXTERNAL_STORAGE])
override fun download(download: DownloadState, cookie: String): Long? {
val androidDownloadManager: SystemDownloadManager = applicationContext.getSystemService()!!
if (!download.isScheme(listOf("http", "https"))) {
......@@ -73,22 +67,16 @@ class AndroidDownloadManager(
validatePermissionGranted(applicationContext)
val request = download.toAndroidRequest(cookie)
val downloadID = androidDownloadManager.enqueue(request)
queuedDownloads[downloadID] = DownloadStateWithRequest(
state = download,
request = request
)
store.dispatch(DownloadAction.QueueDownloadAction(download.copy(id = downloadID)))
downloadRequests[downloadID] = request
registerBroadcastReceiver()
return downloadID
}
override fun tryAgain(downloadId: Long) {
val androidDownloadManager: SystemDownloadManager = applicationContext.getSystemService()!!
androidDownloadManager.enqueue(queuedDownloads[downloadId].request)
androidDownloadManager.enqueue(downloadRequests[downloadId])
}
/**
......@@ -98,7 +86,8 @@ class AndroidDownloadManager(
if (isSubscribedReceiver) {
applicationContext.unregisterReceiver(this)
isSubscribedReceiver = false
queuedDownloads.clear()
store.dispatch(DownloadAction.RemoveAllQueuedDownloadsAction)
downloadRequests.clear()
}
}
......@@ -111,17 +100,21 @@ class AndroidDownloadManager(
}
/**
* Invoked when a download is complete. Calls [onDownloadStopped] and unregisters the
* broadcast receiver if there are no more queued downloads.
* Invoked when a download is complete. Notifies [onDownloadStopped] and removes the queued
* download if it's complete.
*/
override fun onReceive(context: Context, intent: Intent) {
val downloadID = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1)
val download = queuedDownloads[downloadID]
val download = store.state.queuedDownloads[downloadID]
val downloadStatus = intent.getSerializableExtra(AbstractFetchDownloadService.EXTRA_DOWNLOAD_STATUS)
as AbstractFetchDownloadService.DownloadJobStatus
if (downloadStatus == AbstractFetchDownloadService.DownloadJobStatus.COMPLETED) {
store.dispatch(DownloadAction.RemoveQueuedDownloadAction(downloadID))
}
if (download != null) {
onDownloadStopped(download.state, downloadID, downloadStatus)
onDownloadStopped(download, downloadID, downloadStatus)
}
}
}
......
......@@ -15,15 +15,13 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.P
import android.util.LongSparseArray
import androidx.core.util.set
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.downloads.AbstractFetchDownloadService
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.EXTRA_DOWNLOAD
import mozilla.components.feature.downloads.AbstractFetchDownloadService.Companion.EXTRA_DOWNLOAD_STATUS
import mozilla.components.feature.downloads.ext.isScheme
import mozilla.components.feature.downloads.ext.putDownloadExtra
import kotlin.reflect.KClass
/**
......@@ -34,12 +32,12 @@ import kotlin.reflect.KClass
*/
class FetchDownloadManager<T : AbstractFetchDownloadService>(
private val applicationContext: Context,
private val store: BrowserStore,
private val service: KClass<T>,
private val broadcastManager: LocalBroadcastManager = LocalBroadcastManager.getInstance(applicationContext),
override var onDownloadStopped: onDownloadStopped = noop
) : BroadcastReceiver(), DownloadManager {
private val queuedDownloads = LongSparseArray<DownloadState>()
private var isSubscribedReceiver = false
override val permissions = if (SDK_INT >= P) {
......@@ -58,25 +56,21 @@ class FetchDownloadManager<T : AbstractFetchDownloadService>(
if (!download.isScheme(listOf("http", "https", "data", "blob"))) {
return null
}
validatePermissionGranted(applicationContext)
queuedDownloads[download.id] = download
val intent = Intent(applicationContext, service.java)
intent.putDownloadExtra(download)
applicationContext.startService(intent)
// The middleware will notify the service to start the download
// once this action is processed.
store.dispatch(DownloadAction.QueueDownloadAction(download))
registerBroadcastReceiver()
return download.id
}
override fun tryAgain(downloadId: Long) {
val download = queuedDownloads[downloadId] ?: return
val download = store.state.queuedDownloads[downloadId] ?: return
val intent = Intent(applicationContext, service.java)
intent.putDownloadExtra(download)
intent.putExtra(EXTRA_DOWNLOAD_ID, download.id)
applicationContext.startService(intent)
registerBroadcastReceiver()
......@@ -89,7 +83,7 @@ class FetchDownloadManager<T : AbstractFetchDownloadService>(
if (isSubscribedReceiver) {
broadcastManager.unregisterReceiver(this)
isSubscribedReceiver = false
queuedDownloads.clear()
store.dispatch(DownloadAction.RemoveAllQueuedDownloadsAction)
}
}
......@@ -102,15 +96,18 @@ class FetchDownloadManager<T : AbstractFetchDownloadService>(
}
/**
* Invoked when a download is complete. Calls [onDownloadStopped] and unregisters the
* broadcast receiver if there are no more queued downloads.
* Invoked when a download is complete. Notifies [onDownloadStopped] and removes the queued
* download if it's complete.
*/
override fun onReceive(context: Context, intent: Intent) {
val download = intent.getParcelableExtra<DownloadState>(EXTRA_DOWNLOAD)
val downloadID = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1)
val download = store.state.queuedDownloads[downloadID]
val downloadStatus = intent.getSerializableExtra(EXTRA_DOWNLOAD_STATUS)
as AbstractFetchDownloadService.DownloadJobStatus
if (downloadStatus == AbstractFetchDownloadService.DownloadJobStatus.COMPLETED) {
store.dispatch(DownloadAction.RemoveQueuedDownloadAction(downloadID))
}
if (download != null) {
onDownloadStopped(download, downloadID, downloadStatus)
}
......
......@@ -4,6 +4,7 @@
package mozilla.components.feature.downloads
import android.app.DownloadManager.EXTRA_DOWNLOAD_ID
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
......@@ -22,7 +23,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import mozilla.components.browser.state.action.DownloadAction
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
......@@ -39,12 +42,12 @@ import mozilla.components.feature.downloads.AbstractFetchDownloadService.Downloa
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.FAILED
import mozilla.components.feature.downloads.AbstractFetchDownloadService.DownloadJobStatus.PAUSED
import mozilla.components.feature.downloads.DownloadNotification.NOTIFICATION_DOWNLOAD_GROUP_ID
import mozilla.components.feature.downloads.ext.putDownloadExtra
import mozilla.components.feature.downloads.facts.DownloadsFacts.Items.NOTIFICATION
import mozilla.components.support.base.facts.Action