Skip to content

Commit

Permalink
Closes mozilla-mobile#4282: Migrate feature-downloads to use browser-…
Browse files Browse the repository at this point in the history
…state.
  • Loading branch information
pocmo committed Sep 13, 2019
1 parent 5ed3f6e commit aec2986
Show file tree
Hide file tree
Showing 19 changed files with 390 additions and 285 deletions.
6 changes: 6 additions & 0 deletions components/feature/downloads/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ android {
}
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions.freeCompilerArgs += [
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi"
]
}

dependencies {

implementation project(':browser-session')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import mozilla.components.browser.session.Download
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
Expand Down Expand Up @@ -83,7 +83,7 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
sendDownloadCompleteBroadcast(downloadID)
}

private suspend fun performDownload(download: Download) = withContext(IO) {
private suspend fun performDownload(download: DownloadState) = withContext(IO) {
val headers = listOf(
CONTENT_TYPE to download.contentType,
CONTENT_LENGTH to download.contentLength?.toString(),
Expand Down Expand Up @@ -120,7 +120,7 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
* Encapsulates different behaviour depending on the SDK version.
*/
internal fun useFileStream(
download: Download,
download: DownloadState,
block: (OutputStream) -> Unit
) {
if (SDK_INT >= Build.VERSION_CODES.Q) {
Expand All @@ -131,7 +131,7 @@ abstract class AbstractFetchDownloadService : CoroutineService() {
}

@TargetApi(Build.VERSION_CODES.Q)
private fun useFileStreamScopedStorage(download: Download, block: (OutputStream) -> Unit) {
private fun useFileStreamScopedStorage(download: DownloadState, block: (OutputStream) -> Unit) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, download.fileName)
put(MediaStore.Downloads.MIME_TYPE, download.contentType ?: "*/*")
Expand All @@ -153,7 +153,7 @@ abstract class AbstractFetchDownloadService : CoroutineService() {

@TargetApi(Build.VERSION_CODES.P)
@Suppress("Deprecation")
private fun useFileStreamLegacy(download: Download, block: (OutputStream) -> Unit) {
private fun useFileStreamLegacy(download: DownloadState, block: (OutputStream) -> Unit) {
val dir = Environment.getExternalStoragePublicDirectory(download.destinationDirectory)
val file = File(dir, download.fileName!!)
FileOutputStream(file).use(block)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package mozilla.components.feature.downloads

import android.os.Bundle
import androidx.fragment.app.DialogFragment
import mozilla.components.browser.session.Download
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.support.utils.DownloadUtils

/**
Expand All @@ -24,10 +24,12 @@ abstract class DownloadDialogFragment : DialogFragment() {
*/
var onStartDownload: () -> Unit = {}

var onCancelDownload: () -> Unit = {}

/**
* add the metadata of this download object to the arguments of this fragment.
*/
fun setDownload(download: Download) {
fun setDownload(download: DownloadState) {
val args = arguments ?: Bundle()
args.putString(KEY_FILE_NAME, download.fileName
?: DownloadUtils.guessFileName(null, download.url, download.contentType))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,31 @@

package mozilla.components.feature.downloads

import android.annotation.SuppressLint
import android.content.Context
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.fragment.app.FragmentManager
import mozilla.components.browser.session.Download
import mozilla.components.browser.session.SelectionAwareSessionObserver
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.downloads.DownloadDialogFragment.Companion.FRAGMENT_TAG
import mozilla.components.feature.downloads.manager.AndroidDownloadManager
import mozilla.components.feature.downloads.manager.DownloadManager
import mozilla.components.feature.downloads.manager.OnDownloadCompleted
import mozilla.components.feature.downloads.manager.noop
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.base.feature.OnNeedToRequestPermissions
import mozilla.components.support.base.feature.PermissionsFeature
import mozilla.components.support.base.observer.Consumable
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.android.content.isPermissionGranted
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged

/**
* Feature implementation to provide download functionality for the selected
Expand All @@ -38,23 +42,25 @@ import mozilla.components.support.ktx.android.content.isPermissionGranted
* @property onDownloadCompleted a callback invoked when a download is completed.
* @property downloadManager a reference to the [DownloadManager] which is
* responsible for performing the downloads.
* @property sessionManager a reference to the application's [SessionManager].
* @property store a reference to the application's [BrowserStore].
* @property useCases [DownloadsUseCases] instance for consuming processed downloads.
* @property fragmentManager a reference to a [FragmentManager]. If a fragment
* manager is provided, a dialog will be shown before every download.
* @property dialog a reference to a [DownloadDialogFragment]. If not provided, an
* instance of [SimpleDownloadDialogFragment] will be used.
*/
class DownloadsFeature(
private val applicationContext: Context,
private val store: BrowserStore,
private val useCases: DownloadsUseCases,
override var onNeedToRequestPermissions: OnNeedToRequestPermissions = { },
onDownloadCompleted: OnDownloadCompleted = noop,
private val downloadManager: DownloadManager = AndroidDownloadManager(applicationContext),
sessionManager: SessionManager,
private val sessionId: String? = null,
private val customTabId: String? = null,
private val fragmentManager: FragmentManager? = null,
@VisibleForTesting(otherwise = PRIVATE)
internal var dialog: DownloadDialogFragment = SimpleDownloadDialogFragment.newInstance()
) : SelectionAwareSessionObserver(sessionManager), LifecycleAwareFeature, PermissionsFeature {
) : LifecycleAwareFeature, PermissionsFeature {

var onDownloadCompleted: OnDownloadCompleted
get() = downloadManager.onDownloadCompleted
Expand All @@ -64,34 +70,45 @@ class DownloadsFeature(
this.onDownloadCompleted = onDownloadCompleted
}

private var scope: CoroutineScope? = null

/**
* Starts observing downloads on the selected session and sends them to the [DownloadManager]
* to be processed.
*/
override fun start() {
observeIdOrSelected(sessionId)

findPreviousDialogFragment()?.let {
reAttachOnStartDownloadListener(it)
dialog = it
}

scope = store.flowScoped { flow ->
flow.mapNotNull { state -> state.findCustomTabOrSelectedTab(customTabId) }
.ifChanged { it.content.download }
.collect { state ->
val download = state.content.download
if (download != null) {
processDownload(state, download)
}
}
}
}

/**
* Stops observing downloads on the selected session.
*/
override fun stop() {
super.stop()
scope?.cancel()

downloadManager.unregisterListeners()
}

/**
* Notifies the [DownloadManager] that a new download must be processed.
*/
@SuppressLint("MissingPermission")
override fun onDownload(session: Session, download: Download): Boolean {
private fun processDownload(tab: SessionState, download: DownloadState): Boolean {
return if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) {
if (fragmentManager != null) {
showDialog(download, session)
showDialog(tab, download)
false
} else {
startDownload(download)
Expand All @@ -102,12 +119,12 @@ class DownloadsFeature(
}
}

private fun startDownload(download: Download): Boolean {
private fun startDownload(download: DownloadState): Boolean {
val id = downloadManager.download(download)
return if (id != null) {
true
} else {
showUnSupportFileErrorMessage()
showDownloadNotSupportedError()
false
}
}
Expand All @@ -117,30 +134,44 @@ class DownloadsFeature(
* either trigger or clear the pending download.
*/
override fun onPermissionsResult(permissions: Array<String>, grantResults: IntArray) {
if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) {
activeSession?.let { session ->
session.download.consume { onDownload(session, it) }
if (permissions.isEmpty()) {
// If we are requesting permissions while a permission prompt is already being displayed
// then Android seems to call `onPermissionsResult` immediately with an empty permissions
// list. In this case just ignore it.
return
}

withActiveDownload { (tab, download) ->
if (applicationContext.isPermissionGranted(downloadManager.permissions.asIterable())) {
processDownload(tab, download)
} else {
useCases.consumeDownload(tab.id, download.id)
}
} else {
activeSession?.download = Consumable.empty()
}
}

private fun showUnSupportFileErrorMessage() {
val text = applicationContext.getString(
R.string.mozac_feature_downloads_file_not_supported2,
applicationContext.appName
)

Toast.makeText(applicationContext, text, Toast.LENGTH_LONG).show()
@VisibleForTesting(otherwise = PRIVATE)
internal fun showDownloadNotSupportedError() {
Toast.makeText(
applicationContext,
applicationContext.getString(
R.string.mozac_feature_downloads_file_not_supported2,
applicationContext.appName),
Toast.LENGTH_LONG
).show()
}

@SuppressLint("MissingPermission")
private fun showDialog(download: Download, session: Session) {
private fun showDialog(tab: SessionState, download: DownloadState) {
dialog.setDownload(download)

dialog.onStartDownload = {
session.download.consume(this::startDownload)
startDownload(download)

useCases.consumeDownload.invoke(tab.id, download.id)
}

dialog.onCancelDownload = {
useCases.consumeDownload.invoke(tab.id, download.id)
}

if (!isAlreadyADialogCreated() && fragmentManager != null) {
Expand All @@ -152,16 +183,13 @@ class DownloadsFeature(
return findPreviousDialogFragment() != null
}

private fun reAttachOnStartDownloadListener(previousDialog: DownloadDialogFragment?) {
previousDialog?.let {
dialog = it
activeSession?.let { session ->
session.download.consume { download -> onDownload(session, download) }
}
}
}

private fun findPreviousDialogFragment(): DownloadDialogFragment? {
return fragmentManager?.findFragmentByTag(FRAGMENT_TAG) as? DownloadDialogFragment
}

private fun withActiveDownload(block: (Pair<SessionState, DownloadState>) -> Unit) {
val state = store.state.findCustomTabOrSelectedTab(customTabId) ?: return
val download = state.content.download ?: return
block(Pair(state, download))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* 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 mozilla.components.browser.session.Download
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager

/**
* Contains use cases related to the downloads feature.
*
* @param sessionManager the application's [SessionManager].
*/
class DownloadsUseCases(
sessionManager: SessionManager
) {
class ConsumeDownloadUseCase(
private val sessionManager: SessionManager
) {
/**
* Consumes the [Download] with the given [downloadId] from the [Session] with the given
* [tabId].
*/
operator fun invoke(tabId: String, downloadId: String) {
sessionManager.findSessionById(tabId)?.let { session ->
session.download.consume {
it.id == downloadId
}
}
}
}

val consumeDownload = ConsumeDownloadUseCase(sessionManager)
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ class SimpleDownloadDialogFragment : DownloadDialogFragment() {
onStartDownload()
}
.setNegativeButton(negativeButtonText) { _, _ ->
onCancelDownload()
dismiss()
}
.setOnCancelListener { onCancelDownload() }
.setCancelable(cancelable)
.create()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
package mozilla.components.feature.downloads.ext

import androidx.core.net.toUri
import mozilla.components.browser.session.Download
import mozilla.components.browser.state.state.content.DownloadState
import mozilla.components.concept.fetch.Headers
import mozilla.components.concept.fetch.Headers.Names.CONTENT_DISPOSITION
import mozilla.components.concept.fetch.Headers.Names.CONTENT_LENGTH
Expand All @@ -14,7 +14,7 @@ import mozilla.components.support.utils.DownloadUtils
import java.io.InputStream
import java.net.URLConnection

fun Download.isScheme(protocols: Iterable<String>): Boolean {
internal fun DownloadState.isScheme(protocols: Iterable<String>): Boolean {
val scheme = url.trim().toUri().scheme ?: return false
return protocols.contains(scheme)
}
Expand All @@ -25,7 +25,7 @@ fun Download.isScheme(protocols: Iterable<String>): Boolean {
* @param headers Headers from the response.
* @param stream Stream of the response body.
*/
fun Download.withResponse(headers: Headers, stream: InputStream?): Download {
internal fun DownloadState.withResponse(headers: Headers, stream: InputStream?): DownloadState {
val contentDisposition = headers[CONTENT_DISPOSITION]
var contentType = this.contentType
if (contentType == null && stream != null) {
Expand Down
Loading

0 comments on commit aec2986

Please sign in to comment.