Commit b948ae02 authored by Arturo Mejia's avatar Arturo Mejia
Browse files

Closes issue #7762: Adds support for persisting/restoring downloads.

parent 91f7bcbc
......@@ -207,7 +207,7 @@ sealed class ContentAction : BrowserAction() {
/**
* Removes the [DownloadState] of the [ContentState] with the given [sessionId].
*/
data class ConsumeDownloadAction(val sessionId: String, val downloadId: Long) : ContentAction()
data class ConsumeDownloadAction(val sessionId: String, val downloadId: String) : ContentAction()
/**
* Updates the [HitResult] of the [ContentState] with the given [sessionId].
......@@ -700,7 +700,7 @@ sealed class DownloadAction : BrowserAction() {
/**
* Updates the [BrowserState] to remove the download with the provided [downloadId].
*/
data class RemoveDownloadAction(val downloadId: Long) : DownloadAction()
data class RemoveDownloadAction(val downloadId: String) : DownloadAction()
/**
* Updates the [BrowserState] to remove all downloads.
......@@ -711,6 +711,16 @@ sealed class DownloadAction : BrowserAction() {
* Updates the provided [download] on the [BrowserState].
*/
data class UpdateDownloadAction(val download: DownloadState) : DownloadAction()
/**
* Restores the [BrowserState.downloads] state from the storage.
*/
object RestoreDownloadsStateAction : DownloadAction()
/**
* Restores the given [download] from the storage.
*/
data class RestoreDownloadStateAction(val download: DownloadState) : DownloadAction()
}
/**
......
......@@ -25,6 +25,8 @@ internal object DownloadStateReducer {
is DownloadAction.RemoveAllDownloadsAction -> {
state.copy(downloads = emptyMap())
}
is DownloadAction.RestoreDownloadsStateAction -> state
is DownloadAction.RestoreDownloadStateAction -> updateDownloads(state, action.download)
}
}
......
......@@ -28,6 +28,6 @@ data class BrowserState(
val containers: Map<String, ContainerState> = emptyMap(),
val extensions: Map<String, WebExtensionState> = emptyMap(),
val media: MediaState = MediaState(),
val downloads: Map<Long, DownloadState> = emptyMap(),
val downloads: Map<String, DownloadState> = emptyMap(),
val search: SearchState = SearchState()
) : State
......@@ -7,7 +7,7 @@ package mozilla.components.browser.state.state.content
import android.os.Environment
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import kotlin.random.Random
import java.util.UUID
/**
* Value type that represents a download request.
......@@ -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")
......@@ -40,8 +40,9 @@ data class DownloadState(
val destinationDirectory: String = Environment.DIRECTORY_DOWNLOADS,
val referrerUrl: String? = null,
val skipConfirmation: Boolean = false,
val id: Long = Random.nextLong(),
val sessionId: String? = null
val id: String = UUID.randomUUID().toString(),
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)
}
}
......@@ -349,7 +349,7 @@ class ContentActionTest {
@Test
fun `ConsumeDownloadAction removes download`() {
val download = DownloadState(
id = 1337L,
id = "1337",
url = "https://www.mozilla.org", sessionId = tab.id
)
......@@ -360,7 +360,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)
......@@ -369,7 +369,7 @@ class ContentActionTest {
@Test
fun `ConsumeDownloadAction does not remove download with different id`() {
val download = DownloadState(
id = 1337L,
id = "1337",
url = "https://www.mozilla.org", sessionId = tab.id
)
......@@ -380,7 +380,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)
......
......@@ -11,6 +11,7 @@ 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.Assert.assertSame
import org.junit.Test
class DownloadActionTest {
......@@ -30,6 +31,30 @@ class DownloadActionTest {
assertEquals(2, store.state.downloads.size)
}
@Test
fun `RestoreDownloadStateAction adds download`() {
val store = BrowserStore(BrowserState())
val download1 = DownloadState("https://mozilla.org/download1", destinationDirectory = "")
store.dispatch(DownloadAction.RestoreDownloadStateAction(download1)).joinBlocking()
assertEquals(download1, store.state.downloads[download1.id])
assertEquals(1, store.state.downloads.size)
val download2 = DownloadState("https://mozilla.org/download2", destinationDirectory = "")
store.dispatch(DownloadAction.RestoreDownloadStateAction(download2)).joinBlocking()
assertEquals(download2, store.state.downloads[download2.id])
assertEquals(2, store.state.downloads.size)
}
@Test
fun `RestoreDownloadsStateAction does nothing`() {
val store = BrowserStore(BrowserState())
val state = store.state
store.dispatch(DownloadAction.RestoreDownloadsStateAction).joinBlocking()
assertSame(store.state, state)
}
@Test
fun `RemoveDownloadAction removes download`() {
val store = BrowserStore(BrowserState())
......
......@@ -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'
......
{
"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
/* 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.isSameDownload(download1, downloads.first()))
assertTrue(DownloadStorage.isSameDownload(download2, downloads[1]!!))
assertTrue(DownloadStorage.isSameDownload(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.isSameDownload(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.isSameDownload(download1, downloads.first()))
assertTrue(DownloadStorage.isSameDownload(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<DownloadState> {
val dataSource = storage.getDownloadsPaged().create()
return PagedList.Builder(dataSource, 10)
.setNotifyExecutor(executor)
.setFetchExecutor(executor)
.build()
}
}
/* 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.isSameDownload(download, pagedList[0]!!.toDownloadState()))
}
@Test
fun testRemoveAllDownloads() = runBlocking {
for (index in 1..4) {
insertMockDownload(index.toString(), "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.toString(), "https://www.mozilla.org/file1.txt")
}
var pagedList = getDownloadsPagedList()
assertEquals(2, pagedList.size)
pagedList.forEach {