Skip to content

Commit

Permalink
Issue mozilla-mobile#7021: Integrate the ThumbnailDiskCache with Brow…
Browse files Browse the repository at this point in the history
…serThumbnails
  • Loading branch information
gabrielluong committed May 27, 2020
1 parent d21bcf7 commit 06efe08
Show file tree
Hide file tree
Showing 16 changed files with 396 additions and 28 deletions.
1 change: 1 addition & 0 deletions components/browser/thumbnails/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* 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 for 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)")
return@invoke it
}

return thumbnailStorage.loadThumbnail(sessionIdOrUrl).await()
}
}

val loadThumbnail: LoadThumbnailUseCase by lazy { LoadThumbnailUseCase(store, thumbnailStorage) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/* 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)")
} 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)")
sharedDiskCache.putThumbnailBitmap(context, sessionIdOrUrl, bitmap)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions components/browser/thumbnails/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?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>
Original file line number Diff line number Diff line change
@@ -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.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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* 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)
}
}
1 change: 1 addition & 0 deletions components/feature/tabs/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,17 +19,20 @@ 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 {
@VisibleForTesting
internal var presenter = TabsTrayPresenter(
tabsTray,
store,
thumbnailsUseCases,
defaultTabsFilter,
closeTabsTray
)
Expand Down
Loading

0 comments on commit 06efe08

Please sign in to comment.