Commit 521d6332 authored by MozLando's avatar MozLando
Browse files

Merge #7127



7127: Issue #7021: Integrate the ThumbnailDiskCache with BrowserThumbnails r=jonalmeida a=gabrielluong
Co-authored-by: default avatarGabriel Luong <gabriel.luong@gmail.com>
parents 539d368b 7c650be7
......@@ -35,6 +35,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
dependencies {
implementation project(':browser-state')
implementation project(':concept-engine')
implementation project(':support-images')
implementation project(':support-ktx')
implementation Dependencies.androidx_annotation
......
/* 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.thumbnails
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareStore
/**
* [Middleware] implementation for handling [ContentAction.UpdateThumbnailAction] and storing
* the thumbnail to the disk cache.
*/
class ThumbnailsMiddleware(
private val thumbnailStorage: ThumbnailStorage
) : Middleware<BrowserState, BrowserAction> {
override fun invoke(
store: MiddlewareStore<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction
) {
when (action) {
is ContentAction.UpdateThumbnailAction -> {
// Store the captured tab screenshot from the EngineView when the session's
// thumbnail is updated.
thumbnailStorage.saveThumbnail(action.sessionId, action.thumbnail)
}
}
next(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.thumbnails
import android.graphics.Bitmap
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.support.base.log.logger.Logger
/**
* Contains use cases related to the thumbnails feature.
*/
class ThumbnailsUseCases(
store: BrowserStore,
thumbnailStorage: ThumbnailStorage
) {
/**
* Load thumbnail use case.
*/
class LoadThumbnailUseCase internal constructor(
private val store: BrowserStore,
private val thumbnailStorage: ThumbnailStorage
) {
private val logger = Logger("ThumbnailsUseCases")
/**
* Loads the thumbnail of a tab from its in-memory [ContentState] or from the disk cache
* of [ThumbnailStorage].
*/
suspend operator fun invoke(sessionIdOrUrl: String): Bitmap? {
val tab = store.state.findTab(sessionIdOrUrl)
tab?.content?.thumbnail?.let {
logger.debug(
"Loaded thumbnail from memory (sessionIdOrUrl = $sessionIdOrUrl, " +
"generationId = ${it.generationId})"
)
return@invoke it
}
return thumbnailStorage.loadThumbnail(sessionIdOrUrl).await()
}
}
val loadThumbnail: LoadThumbnailUseCase by lazy {
LoadThumbnailUseCase(
store,
thumbnailStorage
)
}
}
/* 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.thumbnails.storage
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import mozilla.components.browser.thumbnails.R
import mozilla.components.browser.thumbnails.utils.ThumbnailDiskCache
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.images.DesiredSize
import mozilla.components.support.images.decoder.AndroidImageDecoder
import java.util.concurrent.Executors
private const val MAXIMUM_SCALE_FACTOR = 2.0f
// Number of worker threads we are using internally.
private const val THREADS = 3
internal val sharedDiskCache = ThumbnailDiskCache()
/**
* Thumbnail storage layer which handles saving and loading the thumbnail from the disk cache.
*/
class ThumbnailStorage(
private val context: Context,
jobDispatcher: CoroutineDispatcher = Executors.newFixedThreadPool(THREADS)
.asCoroutineDispatcher()
) {
private val decoders = AndroidImageDecoder()
private val logger = Logger("ThumbnailStorage")
private val maximumSize =
context.resources.getDimensionPixelSize(R.dimen.mozac_browser_thumbnails_maximum_size)
private val scope = CoroutineScope(jobDispatcher)
/**
* Asynchronously loads a thumbnail [Bitmap] for the given session ID or url.
*/
fun loadThumbnail(sessionIdOrUrl: String): Deferred<Bitmap?> = scope.async {
loadThumbnailInternal(sessionIdOrUrl).also { loadedThumbnail ->
if (loadedThumbnail != null) {
logger.debug(
"Loaded thumbnail from disk (sessionIdOrUrl = $sessionIdOrUrl, " +
"generationId = ${loadedThumbnail.generationId})"
)
} else {
logger.debug("No thumbnail loaded (sessionIdOrUrl = $sessionIdOrUrl)")
}
}
}
@WorkerThread
private fun loadThumbnailInternal(sessionIdOrUrl: String): Bitmap? {
val desiredSize = DesiredSize(
targetSize = context.resources.getDimensionPixelSize(R.dimen.mozac_browser_thumbnails_size_default),
maxSize = maximumSize,
maxScaleFactor = MAXIMUM_SCALE_FACTOR
)
val data = sharedDiskCache.getThumbnailData(context, sessionIdOrUrl)
if (data != null) {
return decoders.decode(data, desiredSize)
}
return null
}
/**
* Stores the given thumbnail [Bitmap] into the disk cache with the provided session ID or url
* as its key.
*/
fun saveThumbnail(sessionIdOrUrl: String, bitmap: Bitmap) {
logger.debug(
"Saved thumbnail to disk (sessionIdOrUrl = $sessionIdOrUrl, " +
"generationId = ${bitmap.generationId})"
)
sharedDiskCache.putThumbnailBitmap(context, sessionIdOrUrl, bitmap)
}
}
......@@ -6,6 +6,7 @@ package mozilla.components.browser.thumbnails.utils
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.VisibleForTesting
import com.jakewharton.disklrucache.DiskLruCache
import mozilla.components.support.base.log.logger.Logger
import java.io.File
......@@ -23,6 +24,12 @@ class ThumbnailDiskCache {
private var thumbnailCache: DiskLruCache? = null
private val thumbnailCacheWriteLock = Any()
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
internal fun clear(context: Context) {
getThumbnailCache(context).delete()
thumbnailCache = null
}
/**
* Retrieves the thumbnail data from the disk cache for the given session ID or URL.
*
......
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<resources xmlns:tools="http://schemas.android.com/tools">
<dimen name="mozac_browser_thumbnails_size_default">102dp</dimen>
<dimen name="mozac_browser_thumbnails_maximum_size" tools:ignore="PxUsage">2153px</dimen>
</resources>
/* 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.thumbnails
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.mock
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class ThumbnailsMiddlewareTest {
@Test
fun `thumbnail storage stores the provided thumbnail on update thumbnail action`() {
val sessionIdOrUrl = "test-tab1"
val tab = createTab("https://www.mozilla.org", id = "test-tab1")
val thumbnailStorage: ThumbnailStorage = mock()
val store = BrowserStore(
initialState = BrowserState(tabs = listOf(tab)),
middleware = listOf(ThumbnailsMiddleware(thumbnailStorage))
)
val bitmap: Bitmap = mock()
store.dispatch(ContentAction.UpdateThumbnailAction(sessionIdOrUrl, bitmap)).joinBlocking()
verify(thumbnailStorage).saveThumbnail(sessionIdOrUrl, bitmap)
}
}
/* 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.thumbnails
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class ThumbnailsUseCasesTest {
@Test
fun `LoadThumbnailUseCase - loads the thumbnail from the in-memory ContentState if available`() = runBlocking {
val sessionIdOrUrl = "test-tab1"
val bitmap: Bitmap = mock()
val tab = createTab("https://www.mozilla.org", id = "test-tab1", thumbnail = bitmap)
val thumbnailStorage: ThumbnailStorage = mock()
val store = BrowserStore(
initialState = BrowserState(tabs = listOf(tab)),
middleware = listOf(ThumbnailsMiddleware(thumbnailStorage))
)
val useCases = ThumbnailsUseCases(store, thumbnailStorage)
val thumbnail = useCases.loadThumbnail(sessionIdOrUrl)
assertEquals(bitmap, thumbnail)
}
@Test
fun `LoadThumbnailUseCase - loads the thumbnail from the disk cache if in-memory thumbnail is unavailable`() = runBlocking {
val sessionIdOrUrl = "test-tab1"
val bitmap: Bitmap = mock()
val tab = createTab("https://www.mozilla.org", id = "test-tab1")
val thumbnailStorage: ThumbnailStorage = mock()
val store = BrowserStore(
initialState = BrowserState(tabs = listOf(tab)),
middleware = listOf(ThumbnailsMiddleware(thumbnailStorage))
)
val useCases = ThumbnailsUseCases(store, thumbnailStorage)
`when`(thumbnailStorage.loadThumbnail(any())).thenReturn(CompletableDeferred(bitmap))
val thumbnail = useCases.loadThumbnail(sessionIdOrUrl)
verify(thumbnailStorage).loadThumbnail(sessionIdOrUrl)
assertEquals(bitmap, thumbnail)
}
}
/* 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.thumbnails.storage
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.spy
@RunWith(AndroidJUnit4::class)
class ThumbnailStorageTest {
@Before
@After
fun cleanUp() {
sharedDiskCache.clear(testContext)
}
@Test
fun `saveThumbnail`() = runBlocking {
val sessionIdOrUrl = "test-tab1"
val bitmap: Bitmap = mock()
val thumbnailStorage = spy(ThumbnailStorage(testContext))
var thumbnail = thumbnailStorage.loadThumbnail(sessionIdOrUrl).await()
assertNull(thumbnail)
thumbnailStorage.saveThumbnail(sessionIdOrUrl, bitmap)
thumbnail = thumbnailStorage.loadThumbnail(sessionIdOrUrl).await()
assertNotNull(thumbnail)
}
@Test
fun `loadThumbnail`() = runBlocking {
val sessionIdOrUrl = "test-tab1"
val bitmap: Bitmap = mock()
val thumbnailStorage = spy(ThumbnailStorage(testContext))
thumbnailStorage.saveThumbnail(sessionIdOrUrl, bitmap)
`when`(thumbnailStorage.loadThumbnail(sessionIdOrUrl)).thenReturn(CompletableDeferred(bitmap))
val thumbnail = thumbnailStorage.loadThumbnail(sessionIdOrUrl).await()
assertEquals(bitmap, thumbnail)
}
}
......@@ -24,4 +24,17 @@ data class Tab(
val icon: Bitmap? = null,
val thumbnail: Bitmap? = null,
val mediaState: Media.State? = null
)
) {
override fun equals(other: Any?): Boolean {
if (javaClass != other?.javaClass) return false
other as Tab
return id == other.id &&
url == other.url &&
title == other.title &&
icon?.generationId == other.icon?.generationId &&
thumbnail?.generationId == other.thumbnail?.generationId &&
mediaState == other.mediaState
}
}
......@@ -31,6 +31,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
dependencies {
implementation project(':browser-session')
implementation project(':browser-thumbnails')
api project(':feature-session')
implementation project(':concept-engine')
implementation project(':concept-tabstray')
......
......@@ -7,6 +7,7 @@ package mozilla.components.feature.tabs.tabstray
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.ThumbnailsUseCases
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.tabs.ext.toTabs
......@@ -18,10 +19,12 @@ import mozilla.components.support.base.feature.LifecycleAwareFeature
* @param defaultTabsFilter A tab filter that is used for the initial presenting of tabs that will be used by
* [TabsFeature.filterTabs] by default as well.
*/
@Suppress("LongParameterList")
class TabsFeature(
tabsTray: TabsTray,
private val store: BrowserStore,
tabsUseCases: TabsUseCases,
thumbnailsUseCases: ThumbnailsUseCases,
private val defaultTabsFilter: (TabSessionState) -> Boolean = { true },
closeTabsTray: () -> Unit
) : LifecycleAwareFeature {
......@@ -29,6 +32,7 @@ class TabsFeature(
internal var presenter = TabsTrayPresenter(
tabsTray,
store,
thumbnailsUseCases,
defaultTabsFilter,
closeTabsTray
)
......
......@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.ThumbnailsUseCases
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.feature.tabs.ext.toTabs
......@@ -27,6 +28,7 @@ import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
class TabsTrayPresenter(
private val tabsTray: TabsTray,
private val store: BrowserStore,
private val thumbnailsUseCases: ThumbnailsUseCases,
internal var tabsFilter: (TabSessionState) -> Boolean,
private val closeTabsTray: () -> Unit
) {
......@@ -43,6 +45,12 @@ class TabsTrayPresenter(
private suspend fun collect(flow: Flow<BrowserState>) {
flow.map { it.toTabs(tabsFilter) }
.map { tabs ->
// Load the tab thumbnail from the memory or disk caches.
tabs.copy(list = tabs.list.map { tab ->
tab.copy(thumbnail = thumbnailsUseCases.loadThumbnail(tab.id))
})
}
.ifChanged()
.collect { tabs ->
// Do not invoke the callback on start if this is the initial state.
......
......@@ -25,7 +25,7 @@ class TabsFeatureTest {
val presenter: TabsTrayPresenter = mock()
val interactor: TabsTrayInteractor = mock()
val useCases = TabsUseCases(sessionManager)
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock()))
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock(), mock()))
assertNotEquals(tabsFeature.interactor, interactor)
assertNotEquals(tabsFeature.presenter, presenter)
......@@ -44,7 +44,7 @@ class TabsFeatureTest {
val presenter: TabsTrayPresenter = mock()
val interactor: TabsTrayInteractor = mock()
val useCases = TabsUseCases(sessionManager)
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock()))
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock(), mock()))
tabsFeature.presenter = presenter
tabsFeature.interactor = interactor
......@@ -62,7 +62,7 @@ class TabsFeatureTest {
val presenter: TabsTrayPresenter = mock()
val interactor: TabsTrayInteractor = mock()
val useCases = TabsUseCases(sessionManager)
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock()))
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock(), mock()))
tabsFeature.presenter = presenter
tabsFeature.interactor = interactor
......@@ -80,7 +80,7 @@ class TabsFeatureTest {
val presenter: TabsTrayPresenter = mock()
val interactor: TabsTrayInteractor = mock()
val useCases = TabsUseCases(sessionManager)
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock()))
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), mock(), mock()))
tabsFeature.presenter = presenter
tabsFeature.interactor = interactor
......@@ -99,7 +99,7 @@ class TabsFeatureTest {
val filter: (TabSessionState) -> Boolean = { false }
val sessionManager = SessionManager(engine = mock())
val useCases = TabsUseCases(sessionManager)
val tabsFeature = spy(TabsFeature(mock(), store, useCases, filter, mock()))
val tabsFeature = spy(TabsFeature(mock(), store, useCases, mock(), filter, mock()))
val presenter: TabsTrayPresenter = mock()
val interactor: TabsTrayInteractor = mock()
......
......@@ -18,12 +18,15 @@ import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.MediaState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.thumbnails.ThumbnailsUseCases
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.feature.tabs.ext.toTabs
import mozilla.components.support.test.any
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
......@@ -58,15 +61,16 @@ class TabsTrayPresenterTest {
val store = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "a"),
createTab("https://getpocket.com", id = "b")
createTab("https://www.mozilla.org", id = "a", thumbnail = mock()),
createTab("https://getpocket.com", id = "b", thumbnail = mock())
),
selectedTabId = "a"
)
)
val tabsTray: MockedTabsTray = spy(MockedTabsTray())
val presenter = TabsTrayPresenter(tabsTray, store, { true }, mock())
val thumbnailsUseCases = ThumbnailsUseCases(store, ThumbnailStorage(testContext))
val presenter = TabsTrayPresenter(tabsTray, store, thumbnailsUseCases, { true }, mock())
verifyNoMoreInteractions(tabsTray)
......@@ -89,15 +93,16 @@ class TabsTrayPresenterTest {
val store = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "a"),
createTab("https://getpocket.com", id = "b")
createTab("https://www.mozilla.org", id = "a", thumbnail = mock()),
createTab("https://getpocket.com", id = "b", thumbnail = mock())
),
selectedTabId = "a"
)