diff --git a/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt b/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt index 8631569cd9e..db772c6ee2e 100644 --- a/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt +++ b/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt @@ -574,7 +574,7 @@ sealed class DownloadAction : BrowserAction() { /** * Updates the [BrowserState] to track the provided [download] as added. */ - data class AddDownloadAction(val download: DownloadState) : DownloadAction() + data class AddDownloadAction(val download: DownloadState, val restored: Boolean = false) : DownloadAction() /** * Updates the [BrowserState] to remove the download with the provided [downloadId]. @@ -590,6 +590,11 @@ sealed class DownloadAction : BrowserAction() { * Updates the provided [download] on the [BrowserState]. */ data class UpdateDownloadAction(val download: DownloadState) : DownloadAction() + + /** + * Restore the [BrowserState.downloads] state from the storage. + */ + object RestoreDownloadsState : DownloadAction() } /** diff --git a/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/DownloadStateReducer.kt b/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/DownloadStateReducer.kt index 7712afe44c1..efaa0740940 100644 --- a/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/DownloadStateReducer.kt +++ b/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/DownloadStateReducer.kt @@ -25,6 +25,7 @@ internal object DownloadStateReducer { is DownloadAction.RemoveAllDownloadsAction -> { state.copy(downloads = emptyMap()) } + DownloadAction.RestoreDownloadsState -> state } } diff --git a/components/browser/state/src/main/java/mozilla/components/browser/state/state/content/DownloadState.kt b/components/browser/state/src/main/java/mozilla/components/browser/state/state/content/DownloadState.kt index 018c16ec726..c4c7e67de98 100644 --- a/components/browser/state/src/main/java/mozilla/components/browser/state/state/content/DownloadState.kt +++ b/components/browser/state/src/main/java/mozilla/components/browser/state/state/content/DownloadState.kt @@ -24,7 +24,7 @@ import kotlin.random.Random * @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. - * @property sessionId Identifier of the session that spawned the download. + * @property createdTime A timestamp when the download was created. * @ */ @Suppress("Deprecation") @@ -41,7 +41,8 @@ data class DownloadState( val referrerUrl: String? = null, val skipConfirmation: Boolean = false, val id: Long = Random.nextLong(), - val sessionId: String? = null + val sessionId: String? = null, + val createdTime: Long = System.currentTimeMillis() ) : Parcelable { val filePath: String get() = Environment.getExternalStoragePublicDirectory(destinationDirectory).path + "/" + fileName @@ -49,31 +50,32 @@ data class DownloadState( /** * Status that represents every state that a download can be in. */ - enum class Status { + @Suppress("MagicNumber") + enum class Status(val id: Int) { /** * Indicates that the download is in the first state after creation but not yet [DOWNLOADING]. */ - INITIATED, + INITIATED(1), /** * Indicates that an [INITIATED] download is now actively being downloaded. */ - DOWNLOADING, + DOWNLOADING(2), /** * Indicates that the download that has been [DOWNLOADING] has been paused. */ - PAUSED, + PAUSED(3), /** * Indicates that the download that has been [DOWNLOADING] has been cancelled. */ - CANCELLED, + CANCELLED(4), /** * Indicates that the download that has been [DOWNLOADING] has moved to failed because * something unexpected has happened. */ - FAILED, + FAILED(5), /** * Indicates that the [DOWNLOADING] download has been completed. */ - COMPLETED + COMPLETED(6) } } diff --git a/components/feature/downloads/build.gradle b/components/feature/downloads/build.gradle index dfab57a8511..1eaf9b09db2 100644 --- a/components/feature/downloads/build.gradle +++ b/components/feature/downloads/build.gradle @@ -5,6 +5,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' android { compileSdkVersion config.compileSdkVersion @@ -12,6 +13,13 @@ android { defaultConfig { minSdkVersion config.minSdkVersion targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } + } } buildTypes { @@ -20,6 +28,10 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { @@ -43,6 +55,11 @@ dependencies { implementation Dependencies.kotlin_stdlib implementation Dependencies.androidx_recyclerview implementation Dependencies.androidx_constraintlayout + implementation Dependencies.androidx_room_runtime + implementation Dependencies.androidx_paging + implementation Dependencies.androidx_lifecycle_livedata + + kapt Dependencies.androidx_room_compiler testImplementation Dependencies.androidx_test_core testImplementation Dependencies.androidx_test_junit @@ -52,6 +69,16 @@ dependencies { testImplementation project(':concept-engine') testImplementation project(':support-test') testImplementation project(':support-test-libstate') + + androidTestImplementation project(':support-android-test') + + androidTestImplementation Dependencies.androidx_room_testing + androidTestImplementation Dependencies.androidx_arch_core_testing + androidTestImplementation Dependencies.androidx_test_core + androidTestImplementation Dependencies.androidx_test_runner + androidTestImplementation Dependencies.androidx_test_rules + androidTestImplementation Dependencies.testing_coroutines + } apply from: '../../../publish.gradle' diff --git a/components/feature/downloads/schemas/mozilla.components.feature.downloads.db.DownloadsDatabase/1.json b/components/feature/downloads/schemas/mozilla.components.feature.downloads.db.DownloadsDatabase/1.json new file mode 100644 index 00000000000..b4e1f72b3b7 --- /dev/null +++ b/components/feature/downloads/schemas/mozilla.components.feature.downloads.db.DownloadsDatabase/1.json @@ -0,0 +1,76 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "342d0e5d0a0fcde72b88ac4585caf842", + "entities": [ + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `url` TEXT NOT NULL, `file_name` TEXT, `content_type` TEXT, `content_length` INTEGER, `status` INTEGER NOT NULL, `destination_directory` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "destinationDirectory", + "columnName": "destination_directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '342d0e5d0a0fcde72b88ac4585caf842')" + ] + } +} \ No newline at end of file diff --git a/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/OnDeviceDownloadStorageTest.kt b/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/OnDeviceDownloadStorageTest.kt new file mode 100644 index 00000000000..ccc9f5b1411 --- /dev/null +++ b/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/OnDeviceDownloadStorageTest.kt @@ -0,0 +1,139 @@ +/* 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.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagedList +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.feature.downloads.db.DownloadsDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@ExperimentalCoroutinesApi +class OnDeviceDownloadStorageTest { + private lateinit var context: Context + private lateinit var storage: DownloadStorage + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + executor = Executors.newSingleThreadExecutor() + + context = ApplicationProvider.getApplicationContext() + val database = Room.inMemoryDatabaseBuilder(context, DownloadsDatabase::class.java).build() + + storage = DownloadStorage(context) + storage.database = lazy { database } + } + + @After + fun tearDown() { + executor.shutdown() + } + + @Test + fun testAddingDownload() = runBlockingTest { + val download1 = createMockDownload("1", "url1") + val download2 = createMockDownload("2", "url2") + val download3 = createMockDownload("3", "url3") + + storage.add(download1) + storage.add(download2) + storage.add(download3) + + val downloads = getDownloadsPagedList() + + assertEquals(3, downloads.size) + + assertTrue(DownloadStorage.areTheSame(download1, downloads.first())) + assertTrue(DownloadStorage.areTheSame(download2, downloads[1]!!)) + assertTrue(DownloadStorage.areTheSame(download3, downloads[2]!!)) + } + + @Test + fun testRemovingDownload() = runBlockingTest { + val download1 = createMockDownload("1", "url1") + val download2 = createMockDownload("2", "url2") + + storage.add(download1) + storage.add(download2) + + assertEquals(2, getDownloadsPagedList().size) + + storage.remove(download1) + + val downloads = getDownloadsPagedList() + val downloadFromDB = downloads.first() + + assertEquals(1, downloads.size) + assertTrue(DownloadStorage.areTheSame(download2, downloadFromDB)) + } + + @Test + fun testGettingDownloads() = runBlockingTest { + val download1 = createMockDownload("1", "url1") + val download2 = createMockDownload("2", "url2") + + storage.add(download1) + storage.add(download2) + + val downloads = getDownloadsPagedList() + + assertEquals(2, downloads.size) + + assertTrue(DownloadStorage.areTheSame(download1, downloads.first())) + assertTrue(DownloadStorage.areTheSame(download2, downloads[1]!!)) + } + + @Test + fun testRemovingDownloads() = runBlocking { + for (index in 1..2) { + storage.add(createMockDownload(index.toString(), "url1")) + } + + var pagedList = getDownloadsPagedList() + + assertEquals(2, pagedList.size) + + pagedList.forEach { + storage.remove(it) + } + + pagedList = getDownloadsPagedList() + + assertTrue(pagedList.isEmpty()) + } + + private fun createMockDownload(id: String, url: String): DownloadState { + return DownloadState( + id = id, + url = url, contentType = "application/zip", contentLength = 5242880, + userAgent = "Mozilla/5.0 (Linux; Android 7.1.1) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/8.0 Chrome/69.0.3497.100 Mobile Safari/537.36" + ) + } + + private fun getDownloadsPagedList(): PagedList { + val dataSource = storage.getDownloadsPaged().create() + return PagedList.Builder(dataSource, 10) + .setNotifyExecutor(executor) + .setFetchExecutor(executor) + .build() + } +} diff --git a/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/db/DownloadDaoTest.kt b/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/db/DownloadDaoTest.kt new file mode 100644 index 00000000000..2ab57e700ed --- /dev/null +++ b/components/feature/downloads/src/androidTest/java/mozilla/components/feature/downloads/db/DownloadDaoTest.kt @@ -0,0 +1,127 @@ +/* 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.db + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import mozilla.components.browser.state.state.content.DownloadState +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.ExecutorService +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagedList +import mozilla.components.feature.downloads.DownloadStorage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import java.util.concurrent.Executors + +class DownloadDaoTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private lateinit var database: DownloadsDatabase + private lateinit var dao: DownloadDao + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(context, DownloadsDatabase::class.java).build() + dao = database.downloadDao() + executor = Executors.newSingleThreadExecutor() + } + + @After + fun tearDown() { + database.close() + executor.shutdown() + } + + @Test + fun testInsertingAndReadingDownloads() = runBlocking { + val download = insertMockDownload(1, "https://www.mozilla.org/file1.txt") + val pagedList = getDownloadsPagedList() + + assertEquals(1, pagedList.size) + assertTrue(DownloadStorage.areTheSame(download, pagedList[0]!!.toDownloadState())) + } + + @Test + fun testRemoveAllDownloads() = runBlocking { + for (index in 1..4) { + insertMockDownload(index.toLong(), "https://www.mozilla.org/file1.txt") + } + + var pagedList = getDownloadsPagedList() + + assertEquals(4, pagedList.size) + dao.deleteAllDownloads() + + pagedList = getDownloadsPagedList() + + assertTrue(pagedList.isEmpty()) + } + + @Test + fun testRemovingDownloads() = runBlocking { + for (index in 1..2) { + insertMockDownload(index.toLong(), "https://www.mozilla.org/file1.txt") + } + + var pagedList = getDownloadsPagedList() + + assertEquals(2, pagedList.size) + + pagedList.forEach { + dao.delete(it) + } + + pagedList = getDownloadsPagedList() + + assertTrue(pagedList.isEmpty()) + } + + @Test + fun testUpdateDownload() = runBlocking { + insertMockDownload(1L, "https://www.mozilla.org/file1.txt") + + var pagedList = getDownloadsPagedList() + + assertEquals(1, pagedList.size) + + val download = pagedList.first() + + val updatedDownload = download.toDownloadState().copy("new_url") + + dao.update(updatedDownload.toDownloadEntity()) + pagedList = getDownloadsPagedList() + + assertEquals("new_url", pagedList.first().url) + } + + private fun getDownloadsPagedList(): PagedList { + val dataSource = dao.getDownloadsPaged().create() + return PagedList.Builder(dataSource, 10) + .setNotifyExecutor(executor) + .setFetchExecutor(executor) + .build() + } + + private suspend fun insertMockDownload(id: Long, url: String): DownloadState { + val download = DownloadState( + id = id, + url = url, contentType = "application/zip", contentLength = 5242880, + userAgent = "Mozilla/5.0 (Linux; Android 7.1.1) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/8.0 Chrome/69.0.3497.100 Mobile Safari/537.36" + ) + dao.insert(download.toDownloadEntity()) + return download + } +} diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/AbstractFetchDownloadService.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/AbstractFetchDownloadService.kt index f67dd4a789b..7d86b4348b0 100644 --- a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/AbstractFetchDownloadService.kt +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/AbstractFetchDownloadService.kt @@ -68,6 +68,7 @@ import mozilla.components.feature.downloads.facts.emitNotificationResumeFact import mozilla.components.feature.downloads.facts.emitNotificationTryAgainFact import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.kotlin.sanitizeURL +import mozilla.components.support.ktx.kotlinx.coroutines.throttle import mozilla.components.support.utils.DownloadUtils import java.io.File import java.io.FileOutputStream @@ -253,17 +254,21 @@ abstract class AbstractFetchDownloadService : Service() { // 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() + val actualStatus = if (download.status == INITIATED) DOWNLOADING else download.status + // Create a new job and add it, with its downloadState to the map val downloadJobState = DownloadJobState( - state = download.copy(status = DOWNLOADING), + state = download.copy(status = actualStatus), foregroundServiceId = foregroundServiceId, - status = DOWNLOADING + status = actualStatus ) store.dispatch(DownloadAction.UpdateDownloadAction(downloadJobState.state)) - downloadJobState.job = CoroutineScope(IO).launch { - startDownloadJob(downloadJobState) + if (actualStatus == DOWNLOADING) { + downloadJobState.job = CoroutineScope(IO).launch { + startDownloadJob(downloadJobState) + } } downloadJobs[download.id] = downloadJobState @@ -624,7 +629,15 @@ abstract class AbstractFetchDownloadService : Service() { private fun copyInChunks(downloadJobState: DownloadJobState, inStream: InputStream, outStream: OutputStream) { val data = ByteArray(CHUNK_SIZE) logger.debug("starting copyInChunks ${downloadJobState.state.url}" + - " currentBytesCopied ${downloadJobState.currentBytesCopied}") + " currentBytesCopied ${downloadJobState.state.currentBytesCopied}") + + val throttleUpdatedDownload = throttle( + PROGRESS_UPDATE_INTERVAL, + coroutineScope = CoroutineScope(IO) + ) { copiedBytes -> + val newState = downloadJobState.state.copy(currentBytesCopied = copiedBytes) + updateDownloadState(newState) + } // To ensure that we copy all files (even ones that don't have fileSize, we must NOT check < fileSize while (getDownloadJobStatus(downloadJobState) == DOWNLOADING) { @@ -632,11 +645,9 @@ abstract class AbstractFetchDownloadService : Service() { // If bytesRead is -1, there's no data left to read from the stream if (bytesRead == -1) { break } + downloadJobState.currentBytesCopied += bytesRead - val newState = downloadJobState.state.copy( - currentBytesCopied = downloadJobState.currentBytesCopied + bytesRead - ) - updateDownloadState(newState) + throttleUpdatedDownload(downloadJobState.currentBytesCopied) outStream.write(data, 0, bytesRead) } diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadMiddleware.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadMiddleware.kt index 3331369ac4b..8d4d884c8ea 100644 --- a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadMiddleware.kt +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadMiddleware.kt @@ -7,34 +7,100 @@ package mozilla.components.feature.downloads import android.app.DownloadManager import android.content.Context import android.content.Intent +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.DownloadAction import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.content.DownloadState.Status.CANCELLED +import mozilla.components.browser.state.state.content.DownloadState.Status.COMPLETED import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareStore +import mozilla.components.support.base.log.logger.Logger +import kotlin.coroutines.CoroutineContext /** * [Middleware] implementation for managing downloads via the provided download service. Its * purpose is to react to global download state changes (e.g. of [BrowserState.downloads]) * and notify the download service, as needed. */ +@Suppress("ComplexMethod") class DownloadMiddleware( private val applicationContext: Context, - private val downloadServiceClass: Class<*> + private val downloadServiceClass: Class<*>, + coroutineContext: CoroutineContext = Dispatchers.IO, + @VisibleForTesting + internal val downloadStorage: DownloadStorage = DownloadStorage(applicationContext) ) : Middleware { + private val logger = Logger("DownloadMiddleware") + private var scope = CoroutineScope(coroutineContext) + + @InternalCoroutinesApi override fun invoke( store: MiddlewareStore, next: (BrowserAction) -> Unit, action: BrowserAction ) { - next(action) when (action) { is DownloadAction.AddDownloadAction -> { - val intent = Intent(applicationContext, downloadServiceClass) - intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, action.download.id) - applicationContext.startService(intent) + next(action) + scope.launch { + if (!action.restored) { + downloadStorage.add(action.download) + logger.debug("Added download ${action.download.fileName} to the storage") + } + } + if (action.download.status !in arrayOf(COMPLETED, CANCELLED)) { + val intent = Intent(applicationContext, downloadServiceClass) + intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, action.download.id) + applicationContext.startService(intent) + logger.debug("Sending download intent ${action.download.fileName}") + } + } + + is DownloadAction.RemoveDownloadAction -> { + scope.launch { + store.state.downloads[action.downloadId]?.let { + downloadStorage.remove(it) + logger.debug("Removed download ${it.fileName} from the storage") + } + } + } + + is DownloadAction.UpdateDownloadAction -> { + val updated = action.download + store.state.downloads[updated.id]?.let { old -> + // To not overwhelm the storage, we only send updates that are relevant, + // we only care about properties, that we are stored on the storage. + if (!DownloadStorage.areTheSame(old, updated)) { + scope.launch { + downloadStorage.update(action.download) + } + logger.debug("Updated download ${action.download.fileName} on the storage") + } + } + } + + is DownloadAction.RestoreDownloadsState -> { + scope.launch { + downloadStorage.getDownloads().collect { downloads -> + downloads.forEach { download -> + if (!store.state.downloads.containsKey(download.id)) { + store.dispatch(DownloadAction.AddDownloadAction(download, true)) + logger.error("Download restarted from db") + } + } + } + } } } + if (action !is DownloadAction.AddDownloadAction) { + next(action) + } } } diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadStorage.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadStorage.kt new file mode 100644 index 00000000000..7223d526408 --- /dev/null +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/DownloadStorage.kt @@ -0,0 +1,89 @@ +/* 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.content.Context +import androidx.annotation.VisibleForTesting +import androidx.paging.DataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.feature.downloads.db.DownloadsDatabase +import mozilla.components.feature.downloads.db.toDownloadEntity + +/** + * A storage implementation for organizing download. + */ +class DownloadStorage(context: Context) { + + @VisibleForTesting + internal var database: Lazy = lazy { DownloadsDatabase.get(context) } + + private val downloadDao by lazy { database.value.downloadDao() } + + /** + * Adds a new [download]. + */ + suspend fun add(download: DownloadState) { + downloadDao.insert(download.toDownloadEntity()) + } + + /** + * Returns a [Flow] list of all the [DownloadState] instances. + */ + fun getDownloads(): Flow> { + return downloadDao.getDownloads().map { list -> + list.map { entity -> entity.toDownloadState() } + } + } + + /** + * Returns all saved [DownloadState] instances as a [DataSource.Factory]. + */ + fun getDownloadsPaged(): DataSource.Factory = downloadDao + .getDownloadsPaged() + .map { entity -> + entity.toDownloadState() + } + + /** + * Removes the given [download]. + */ + suspend fun remove(download: DownloadState) { + downloadDao.delete(download.toDownloadEntity()) + } + + /** + * Update the given [download]. + */ + suspend fun update(download: DownloadState) { + downloadDao.update(download.toDownloadEntity()) + } + + /** + * Removes all the downloads. + */ + suspend fun removeAllDownloads() { + downloadDao.deleteAllDownloads() + } + + companion object { + /** + * Takes two [DownloadState] objects and the determine if they are the same, be aware this + * only takes into considerations fields that are being stored, + * not all the field on [DownloadState] are stored. + */ + fun areTheSame(first: DownloadState, second: DownloadState): Boolean { + return first.id == second.id && + first.fileName == second.fileName && + first.url == second.url && + first.contentType == second.contentType && + first.contentLength == second.contentLength && + first.status == second.status && + first.destinationDirectory == second.destinationDirectory && + first.createdTime == second.createdTime + } + } +} diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadDao.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadDao.kt new file mode 100644 index 00000000000..828d9f96ea3 --- /dev/null +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadDao.kt @@ -0,0 +1,38 @@ +/* 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.db + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +/** + * Internal dao for accessing and modifying sitePermissions in the database. + */ +@Dao +internal interface DownloadDao { + + @Insert + suspend fun insert(entity: DownloadEntity): Long + + @Update + suspend fun update(entity: DownloadEntity) + + @Query("SELECT * FROM downloads ORDER BY created_at DESC") + fun getDownloads(): Flow> + + @Delete + suspend fun delete(entity: DownloadEntity) + + @Query("DELETE FROM downloads") + suspend fun deleteAllDownloads() + + @Query("SELECT * FROM downloads ORDER BY created_at DESC") + fun getDownloadsPaged(): DataSource.Factory +} diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadEntity.kt.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadEntity.kt.kt new file mode 100644 index 00000000000..32f5559fa64 --- /dev/null +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadEntity.kt.kt @@ -0,0 +1,74 @@ +/* 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.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import mozilla.components.browser.state.state.content.DownloadState + +/** + * Internal entity representing a download as it gets saved to the database. + */ +@Entity(tableName = "downloads") +internal data class DownloadEntity( + @PrimaryKey + @ColumnInfo(name = "id") + var id: Long, + + @ColumnInfo(name = "url") + var url: String, + + @ColumnInfo(name = "file_name") + var fileName: String?, + + @ColumnInfo(name = "content_type") + var contentType: String?, + + @ColumnInfo(name = "content_length") + var contentLength: Long?, + + @ColumnInfo(name = "status") + var status: DownloadState.Status, + + @ColumnInfo(name = "destination_directory") + var destinationDirectory: String, + + @ColumnInfo(name = "created_at") + var createdAt: Long + +) { + + internal fun toDownloadState(): DownloadState { + return DownloadState( + url, + fileName, + contentType, + contentLength, + currentBytesCopied = 0, + status = status, + userAgent = null, + destinationDirectory = destinationDirectory, + referrerUrl = null, + skipConfirmation = false, + id = id, + sessionId = null, + createdTime = createdAt + ) + } +} + +internal fun DownloadState.toDownloadEntity(): DownloadEntity { + return DownloadEntity( + id, + url, + fileName, + contentType, + contentLength, + status = status, + destinationDirectory = destinationDirectory, + createdAt = createdTime + ) +} diff --git a/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadsDatabase.kt b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadsDatabase.kt new file mode 100644 index 00000000000..7e605576c79 --- /dev/null +++ b/components/feature/downloads/src/main/java/mozilla/components/feature/downloads/db/DownloadsDatabase.kt @@ -0,0 +1,55 @@ +/* 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.db + +import mozilla.components.browser.state.state.content.DownloadState +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters + +/** + * Internal database for saving downloads. + */ +@Database(entities = [DownloadEntity::class], version = 1) +@TypeConverters(StatusConverter::class) +internal abstract class DownloadsDatabase : RoomDatabase() { + abstract fun downloadDao(): DownloadDao + + companion object { + @Volatile + private var instance: DownloadsDatabase? = null + + @Synchronized + fun get(context: Context): DownloadsDatabase { + instance?.let { return it } + + return Room.databaseBuilder( + context, + DownloadsDatabase::class.java, + "mozac_downloads_database" + ).build().also { + instance = it + } + } + } +} + +@Suppress("unused") +internal class StatusConverter { + private val statusArray = DownloadState.Status.values() + + @TypeConverter + fun toInt(status: DownloadState.Status): Int { + return status.id + } + + @TypeConverter + fun toStatus(index: Int): DownloadState.Status? { + return statusArray.find { it.id == index } + } +} diff --git a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/AbstractFetchDownloadServiceTest.kt b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/AbstractFetchDownloadServiceTest.kt index d487c664510..121b83cafb4 100644 --- a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/AbstractFetchDownloadServiceTest.kt +++ b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/AbstractFetchDownloadServiceTest.kt @@ -29,6 +29,7 @@ import mozilla.components.browser.state.action.DownloadAction import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.browser.state.state.content.DownloadState.Status.DOWNLOADING import mozilla.components.browser.state.state.content.DownloadState.Status.FAILED +import mozilla.components.browser.state.state.content.DownloadState.Status.INITIATED import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.fetch.Client import mozilla.components.concept.fetch.MutableHeaders @@ -75,6 +76,7 @@ import org.mockito.Mockito.doThrow import org.mockito.Mockito.spy import org.mockito.Mockito.times import org.mockito.Mockito.verify +import org.mockito.Mockito.never import org.mockito.MockitoAnnotations.initMocks import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -583,6 +585,37 @@ class AbstractFetchDownloadServiceTest { assertEquals(2, shadowNotificationService.size()) } + @Test + fun `onStartCommand must change status of INITIATED downloads to DOWNLOADING`() = runBlocking { + val download = DownloadState("https://example.com/file.txt", "file.txt", status = INITIATED) + + val downloadIntent = Intent("ACTION_DOWNLOAD") + downloadIntent.putExtra(EXTRA_DOWNLOAD_ID, download.id) + + doNothing().`when`(service).performDownload(any()) + + browserStore.dispatch(DownloadAction.AddDownloadAction(download)).joinBlocking() + service.onStartCommand(downloadIntent, 0, 0) + service.downloadJobs.values.first().job!!.joinBlocking() + + verify(service).startDownloadJob(any()) + assertEquals(DOWNLOADING, service.downloadJobs.values.first().status) + } + + @Test + fun `onStartCommand must change the status only for INITIATED downloads`() = runBlocking { + val download = DownloadState("https://example.com/file.txt", "file.txt", status = FAILED) + + val downloadIntent = Intent("ACTION_DOWNLOAD") + downloadIntent.putExtra(EXTRA_DOWNLOAD_ID, download.id) + + browserStore.dispatch(DownloadAction.AddDownloadAction(download)).joinBlocking() + service.onStartCommand(downloadIntent, 0, 0) + + verify(service, never()).startDownloadJob(any()) + assertEquals(FAILED, service.downloadJobs.values.first().status) + } + @Test fun `onStartCommand sets the notification foreground`() = runBlocking { val download = DownloadState("https://example.com/file.txt", "file.txt") diff --git a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadMiddlewareTest.kt b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadMiddlewareTest.kt index fd8ac69ffef..f341de3bcea 100644 --- a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadMiddlewareTest.kt +++ b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadMiddlewareTest.kt @@ -8,27 +8,39 @@ import android.app.DownloadManager.EXTRA_DOWNLOAD_ID import android.content.Context import android.content.Intent import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runBlockingTest import mozilla.components.browser.state.action.DownloadAction 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.argumentCaptor -import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import mozilla.components.support.test.ext.joinBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.verify +import org.mockito.Mockito.times +import org.mockito.Mockito.never @RunWith(AndroidJUnit4::class) class DownloadMiddlewareTest { @Test - fun `service is started when download is queued`() { + fun `service is started when download is queued`() = runBlockingTest { val applicationContext: Context = mock() + val downloadMiddleware = DownloadMiddleware( + applicationContext, + AbstractFetchDownloadService::class.java, + coroutineContext = coroutineContext, + downloadStorage = mock() + ) val store = BrowserStore( initialState = BrowserState(), - middleware = listOf(DownloadMiddleware(applicationContext, AbstractFetchDownloadService::class.java)) + middleware = listOf(downloadMiddleware) ) val download = DownloadState("https://mozilla.org/download", destinationDirectory = "") @@ -38,4 +50,116 @@ class DownloadMiddlewareTest { verify(applicationContext).startService(intentCaptor.capture()) assertEquals(download.id, intentCaptor.value.getLongExtra(EXTRA_DOWNLOAD_ID, -1)) } + + @Test + fun `only restarted downloads MUST not be passed to the downloadStorage`() = runBlockingTest { + val applicationContext: Context = mock() + val downloadStorage: DownloadStorage = mock() + val downloadMiddleware = DownloadMiddleware( + applicationContext, + AbstractFetchDownloadService::class.java, + downloadStorage = downloadStorage, + coroutineContext = coroutineContext + ) + val store = BrowserStore( + initialState = BrowserState(), + middleware = listOf(downloadMiddleware) + ) + + var download = DownloadState("https://mozilla.org/download", destinationDirectory = "") + store.dispatch(DownloadAction.AddDownloadAction(download, restored = true)).joinBlocking() + + verify(downloadStorage, never()).add(download) + + download = DownloadState("https://mozilla.org/download", destinationDirectory = "") + store.dispatch(DownloadAction.AddDownloadAction(download, restored = false)).joinBlocking() + + verify(downloadStorage).add(download) + } + + @Test + fun `RemoveDownloadAction MUST remove from the storage`() = runBlockingTest { + val applicationContext: Context = mock() + val downloadStorage: DownloadStorage = mock() + val downloadMiddleware = DownloadMiddleware( + applicationContext, + AbstractFetchDownloadService::class.java, + downloadStorage = downloadStorage, + coroutineContext = coroutineContext + ) + val store = BrowserStore( + initialState = BrowserState(), + middleware = listOf(downloadMiddleware) + ) + + val download = DownloadState("https://mozilla.org/download", destinationDirectory = "") + store.dispatch(DownloadAction.AddDownloadAction(download)).joinBlocking() + + store.dispatch(DownloadAction.RemoveDownloadAction(download.id)).joinBlocking() + + verify(downloadStorage).remove(download) + } + + @Test + fun `UpdateDownloadAction MUST update the storage when changes are meaningful`() = runBlockingTest { + val applicationContext: Context = mock() + val downloadStorage: DownloadStorage = mock() + val downloadMiddleware = DownloadMiddleware( + applicationContext, + AbstractFetchDownloadService::class.java, + downloadStorage = downloadStorage, + coroutineContext = coroutineContext + ) + val store = BrowserStore( + initialState = BrowserState(), + middleware = listOf(downloadMiddleware) + ) + + val download = DownloadState("https://mozilla.org/download") + store.dispatch(DownloadAction.AddDownloadAction(download)).joinBlocking() + + val downloadInTheStore = store.state.downloads.getValue(download.id) + + assertEquals(download, downloadInTheStore) + + var updatedDownload = download.copy(url = "updatedURL") + store.dispatch(DownloadAction.UpdateDownloadAction(updatedDownload)).joinBlocking() + + verify(downloadStorage).update(updatedDownload) + + updatedDownload = download.copy(url = "updatedURLAgain") + store.dispatch(DownloadAction.UpdateDownloadAction(updatedDownload)).joinBlocking() + + verify(downloadStorage, times(1)).update(updatedDownload) + } + + @Test + fun `RestoreDownloadsState MUST populate the store with items in the storage`() = runBlockingTest { + val applicationContext: Context = mock() + val downloadStorage: DownloadStorage = mock() + val downloadMiddleware = DownloadMiddleware( + applicationContext, + AbstractFetchDownloadService::class.java, + downloadStorage = downloadStorage, + coroutineContext = coroutineContext + ) + val store = BrowserStore( + initialState = BrowserState(), + middleware = listOf(downloadMiddleware) + ) + + val download = DownloadState("https://mozilla.org/download") + whenever(downloadStorage.getDownloads()).thenReturn( + flow { + emit(listOf(download, download.copy("new_URL"))) + } + ) + + assertTrue(store.state.downloads.isEmpty()) + + store.dispatch(DownloadAction.RestoreDownloadsState).joinBlocking() + + assertEquals(download, store.state.downloads.values.first()) + assertEquals(download.url, store.state.downloads.values.first().url) + } } diff --git a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadStorageTest.kt b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadStorageTest.kt new file mode 100644 index 00000000000..d703150678e --- /dev/null +++ b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/DownloadStorageTest.kt @@ -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 android.os.Environment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.state.state.content.DownloadState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DownloadStorageTest { + @Test + fun areTheSames() { + val download = DownloadState( + id = "1", + url = "url", + contentType = "application/zip", + contentLength = 5242880, + status = DownloadState.Status.DOWNLOADING, + destinationDirectory = Environment.DIRECTORY_MUSIC + ) + + assertTrue(DownloadStorage.areTheSame(download, download)) + assertFalse(DownloadStorage.areTheSame(download, download.copy(id = "2"))) + assertFalse(DownloadStorage.areTheSame(download, download.copy(url = "newUrl"))) + assertFalse(DownloadStorage.areTheSame(download, download.copy(contentType = "contentType"))) + assertFalse(DownloadStorage.areTheSame(download, download.copy(contentLength = 0))) + assertFalse(DownloadStorage.areTheSame(download, download.copy(status = DownloadState.Status.COMPLETED))) + assertFalse(DownloadStorage.areTheSame(download, download.copy(destinationDirectory = Environment.DIRECTORY_DOWNLOADS))) + } +} \ No newline at end of file diff --git a/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/db/DownloadEntityTest.kt b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/db/DownloadEntityTest.kt new file mode 100644 index 00000000000..3c2ddcab05f --- /dev/null +++ b/components/feature/downloads/src/test/java/mozilla/components/feature/downloads/db/DownloadEntityTest.kt @@ -0,0 +1,66 @@ +/* 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.db + +import android.os.Environment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.state.state.content.DownloadState +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DownloadEntityTest { + + @Test + fun `convert a DownloadEntity to a DownloadState`() { + val downloadEntity = DownloadEntity( + id = "1", + url = "url", + fileName = "fileName", + contentType = "application/zip", + contentLength = 5242880, + status = DownloadState.Status.DOWNLOADING, + destinationDirectory = Environment.DIRECTORY_MUSIC, + createdAt = 33 + ) + + val downloadState = downloadEntity.toDownloadState() + + assertEquals(downloadEntity.id, downloadState.id) + assertEquals(downloadEntity.url, downloadState.url) + assertEquals(downloadEntity.fileName, downloadState.fileName) + assertEquals(downloadEntity.contentType, downloadState.contentType) + assertEquals(downloadEntity.contentLength, downloadState.contentLength) + assertEquals(downloadEntity.status, downloadState.status) + assertEquals(downloadEntity.destinationDirectory, downloadState.destinationDirectory) + assertEquals(downloadEntity.createdAt, downloadState.createdTime) + } + + @Test + fun `convert a DownloadState to DownloadEntity`() { + val downloadState = DownloadState( + id = "1", + url = "url", + fileName = "fileName", + contentType = "application/zip", + contentLength = 5242880, + status = DownloadState.Status.DOWNLOADING, + destinationDirectory = Environment.DIRECTORY_MUSIC, + createdTime = 33 + ) + + val downloadEntity = downloadState.toDownloadEntity() + + assertEquals(downloadState.id, downloadEntity.id) + assertEquals(downloadState.url, downloadEntity.url) + assertEquals(downloadState.fileName, downloadEntity.fileName) + assertEquals(downloadState.contentType, downloadEntity.contentType) + assertEquals(downloadState.contentLength, downloadEntity.contentLength) + assertEquals(downloadState.status, downloadEntity.status) + assertEquals(downloadState.destinationDirectory, downloadEntity.destinationDirectory) + assertEquals(downloadState.createdTime, downloadEntity.createdAt) + } +} \ No newline at end of file diff --git a/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/Utils.kt b/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/Utils.kt new file mode 100644 index 00000000000..d478f41c152 --- /dev/null +++ b/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/Utils.kt @@ -0,0 +1,37 @@ +/* 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.support.ktx.kotlinx.coroutines + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * + * Returns a function that limits the executions of the [block] function, until the [skipTime] passes, + * any calls before [skipTime] passes will be ignored. + * + * Credit to Terenfear https://gist.github.com/Terenfear/a84863be501d3399889455f391eeefe5 + * + * @param skipTime the time to wait until the next call to [block] be processed. + * @param coroutineScope the coroutine scope where [block] will executed. + * @param block function to be execute. + */ +fun throttle( + skipTime: Long = 300L, + coroutineScope: CoroutineScope, + block: (T) -> Unit +): (T) -> Unit { + var throttleJob: Job? = null + return { param: T -> + if (throttleJob?.isCompleted != false) { + throttleJob = coroutineScope.launch { + block(param) + delay(skipTime) + } + } + } +} diff --git a/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/UtilsKtTest.kt b/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/UtilsKtTest.kt new file mode 100644 index 00000000000..f86608d734e --- /dev/null +++ b/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/UtilsKtTest.kt @@ -0,0 +1,37 @@ +/* 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.support.ktx.kotlinx.coroutines + +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class UtilsKtTest { + + @Test + fun throttle() = runBlockingTest { + val skipTime = 300L + var value = 0 + val throttleBlock = throttle(skipTime, coroutineScope = this) { + value = it + } + + for (n in 1..300) { + throttleBlock(n) + } + assertNotEquals(300, value) + + value = 0 + + for (n in 1..300) { + delay(skipTime) + throttleBlock(n) + } + + assertEquals(300, value) + } +} diff --git a/docs/changelog.md b/docs/changelog.md index f00881131c5..1cb905cc2e1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -42,6 +42,16 @@ permalink: /changelog/ * **ui-widgets** * Added widget for showing a website in a list, such as in bookmarks or history. The `mozac_primary_text_color` and `mozac_caption_text_color` attributes should be set. +* **feature-downloads** + * ⚠️ **This is a breaking change**: `AndroidDownloadManager.download` returns a `Strings`, `AndroidDownloadManager.tryAgain` requires a `Strings` `id` parameter. + * ⚠️ **This is a breaking change**: `ConsumeDownloadAction` requires a `Strings` `id` parameter. + * ⚠️ **This is a breaking change**: `DownloadManager#onDownloadStopped` requires a `Strings` `(DownloadState, Long, Status) -> Unit`. + * ⚠️ **This is a breaking change**: `DownloadsUseCases.invoke` requires an `Strings` `downloadId` parameter. + * ⚠️ **This is a breaking change**: `DownloadState.id` has changed its type from `Long` to `String`. + * ⚠️ **This is a breaking change**: `BrowserState.downloads` has changed it's type from `Map` to `Map`. + * 🆕 Added support for persisting/restoring downloads see issue [#7762](https://github.com/mozilla-mobile/android-components/issues/7762). + * 🆕 Added `DownloadStorage` for querying stored download metadata. + # 55.0.0 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v54.0.0...v55.0.0) diff --git a/samples/browser/src/main/java/org/mozilla/samples/browser/SampleApplication.kt b/samples/browser/src/main/java/org/mozilla/samples/browser/SampleApplication.kt index a585158e7cf..4e0aebb78a0 100644 --- a/samples/browser/src/main/java/org/mozilla/samples/browser/SampleApplication.kt +++ b/samples/browser/src/main/java/org/mozilla/samples/browser/SampleApplication.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import mozilla.appservices.Megazord import mozilla.components.browser.session.Session +import mozilla.components.browser.state.action.DownloadAction import mozilla.components.concept.fetch.Client import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient @@ -86,6 +87,7 @@ class SampleApplication : Application() { components.supportedAddonsChecker.registerForChecks() } ) + components.store.dispatch(DownloadAction.RestoreDownloadsState) } catch (e: UnsupportedOperationException) { // Web extension support is only available for engine gecko Logger.error("Failed to initialize web extension support", e) diff --git a/taskcluster/ci/test/kind.yml b/taskcluster/ci/test/kind.yml index 36760d7b497..773f4287c6e 100644 --- a/taskcluster/ci/test/kind.yml +++ b/taskcluster/ci/test/kind.yml @@ -126,3 +126,10 @@ jobs: - ['automation/taskcluster/androidTest/ui-test.sh', 'support-ktx', 'arm', '1'] treeherder: symbol: 'unit-support-ktx' + android-feature-downloads: + description: 'Run unit tests on device for feature downloads' + run: + post-gradlew: + - ['automation/taskcluster/androidTest/ui-test.sh', 'support-ktx', 'arm', '1'] + treeherder: + symbol: 'unit-feature-downloads'