summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/browser/thumbnails
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/browser/thumbnails')
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/README.md81
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/build.gradle63
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/BrowserThumbnails.kt86
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/ThumbnailsMiddleware.kt72
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoader.kt71
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorage.kt133
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCache.kt130
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/res/values/dimens.xml8
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/main/res/values/tags.xml8
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/BrowserThumbnailsTest.kt139
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/ThumbnailsMiddlewareTest.kt199
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoaderTest.kt93
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorageTest.kt142
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCacheTest.kt148
-rw-r--r--mobile/android/android-components/components/browser/thumbnails/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
17 files changed, 1400 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/browser/thumbnails/README.md b/mobile/android/android-components/components/browser/thumbnails/README.md
new file mode 100644
index 0000000000..ba465c867f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/README.md
@@ -0,0 +1,81 @@
+# [Android Components](../../../README.md) > Browser > Thumbnails
+
+A component for loading and storing website thumbnails (screenshot of the website).
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:browser-thumbnails:{latest-version}"
+```
+
+## Requesting thumbnails
+
+To get thumbnail images from the browser, we need to request them from the `EngineView`. This can
+be done easily by using `BrowserThumbnails` which will then notify the `BrowserStore` when a
+thumbnail has been received:
+
+```kotlin
+browserThumbnails.set(
+ feature = BrowserThumbnails(context, layout.engineView, browserStore),
+ owner = this,
+ view = layout
+)
+```
+
+`BrowserThumbnails` tries to make requests as frequent as possible in order to get the most
+up-to-date state of the site in the images.
+
+The various situations when we try to request a thumbnail:
+ - During the Android lifecycle event `onStart`.
+ - When the selected tab's `loading` state changes.
+ - More to be added..
+
+## Storing to disk
+
+When we receive new thumbnails, we may want to persist them to disk as these images can be quite large.
+
+To do this, we need to add the `BrowserMiddleware` to receive the image from the store
+and put it in our storage:
+
+```kotlin
+val thumbnailStorage by lazy { ThumbnailStorage(applicationContext) }
+
+val store by lazy {
+ BrowserStore(middleware = listOf(
+ ThumbnailsMiddleware(thumbnailStorage)
+ ))
+}
+```
+
+## Loading from disk
+
+Now that we have the thumbnails stored to disk, we can access them via the `ThumbnailStorage`
+directly:
+
+```kotlin
+runBlocking {
+ val bitmap = thumbnailStorage.loadThumbnail(
+ request = ImageLoadRequest("thumbnailId", maxPreferredImageDimen)
+ )
+}
+```
+
+A better way, is to use the `ThumbnailLoader`:
+
+```kotlin
+val thumbnailLoader = ThumbnailLoader(components.thumbnailStorage)
+thumbnailLoader.loadIntoView(
+ view = thumbnailView,
+ request = ImageLoadRequest(id = tab.id, size = thumbnailSize)
+)
+```
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/browser/thumbnails/build.gradle b/mobile/android/android-components/components/browser/thumbnails/build.gradle
new file mode 100644
index 0000000000..b803fd23a5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/build.gradle
@@ -0,0 +1,63 @@
+/* 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/. */
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ packagingOptions {
+ resources {
+ excludes += ['META-INF/proguard/androidx-annotations.pro']
+ }
+ }
+
+ namespace 'mozilla.components.browser.thumbnails'
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions.freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+}
+
+dependencies {
+ implementation project(':browser-state')
+ implementation project(':concept-engine')
+ implementation project(':concept-base')
+ implementation project(':support-ktx')
+ implementation project(':support-images')
+
+ implementation ComponentsDependencies.androidx_annotation
+ implementation ComponentsDependencies.androidx_core_ktx
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation ComponentsDependencies.thirdparty_disklrucache
+
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_coroutines
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/browser/thumbnails/proguard-rules.pro b/mobile/android/android-components/components/browser/thumbnails/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/thumbnails/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/BrowserThumbnails.kt b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/BrowserThumbnails.kt
new file mode 100644
index 0000000000..c61ae638fc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/BrowserThumbnails.kt
@@ -0,0 +1,86 @@
+/* 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.content.Context
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.ktx.android.content.isOSOnLowMemory
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
+
+/**
+ * Feature implementation for automatically taking thumbnails of sites.
+ * The feature will take a screenshot when the page finishes loading,
+ * and will add it to the [ContentState.thumbnail] property.
+ *
+ * If the OS is under low memory conditions, the screenshot will be not taken.
+ * Ideally, this should be used in conjunction with `SessionManager.onLowMemory` to allow
+ * free up some [ContentState.thumbnail] from memory.
+ */
+class BrowserThumbnails(
+ private val context: Context,
+ private val engineView: EngineView,
+ private val store: BrowserStore,
+) : LifecycleAwareFeature {
+
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Starts observing the selected session to listen for when a session finishes loading.
+ */
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.map { it.selectedTab }
+ .ifAnyChanged { arrayOf(it?.content?.loading, it?.content?.firstContentfulPaint) }
+ .collect { state ->
+ if (state?.content?.loading == false && state.content.firstContentfulPaint) {
+ requestScreenshot()
+ }
+ }
+ }
+ }
+
+ /**
+ * Requests a screenshot to be taken that can be observed from [BrowserStore] if successful. The request can fail
+ * if the device is low on memory or if there is no tab attached to the [EngineView].
+ */
+ fun requestScreenshot() {
+ if (!isLowOnMemory()) {
+ // Create a local reference to prevent capturing "this" in the lambda
+ // which would leak the context if the view is destroyed before the
+ // callback is invoked. This is a workaround for:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1678364
+ val store = this.store
+ engineView.captureThumbnail {
+ val bitmap = it ?: return@captureThumbnail
+ val tabId = store.state.selectedTabId ?: return@captureThumbnail
+
+ store.dispatch(ContentAction.UpdateThumbnailAction(tabId, bitmap))
+ }
+ }
+ }
+
+ /**
+ * Stops observing the selected session.
+ */
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ @VisibleForTesting
+ internal var testLowMemory = false
+
+ private fun isLowOnMemory() = testLowMemory || context.isOSOnLowMemory()
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/ThumbnailsMiddleware.kt b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/ThumbnailsMiddleware.kt
new file mode 100644
index 0000000000..1d8bdca7bb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/ThumbnailsMiddleware.kt
@@ -0,0 +1,72 @@
+/* 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.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
+import mozilla.components.concept.base.images.ImageSaveRequest
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.MiddlewareContext
+
+/**
+ * [Middleware] implementation for handling [ContentAction.UpdateThumbnailAction] and storing
+ * the thumbnail to the disk cache.
+ */
+class ThumbnailsMiddleware(
+ private val thumbnailStorage: ThumbnailStorage,
+) : Middleware<BrowserState, BrowserAction> {
+ @Suppress("ComplexMethod")
+ override fun invoke(
+ context: MiddlewareContext<BrowserState, BrowserAction>,
+ next: (BrowserAction) -> Unit,
+ action: BrowserAction,
+ ) {
+ when (action) {
+ is TabListAction.RemoveAllNormalTabsAction -> {
+ context.state.tabs.filterNot { it.content.private }.forEach { tab ->
+ thumbnailStorage.deleteThumbnail(tab.id, isPrivate = false)
+ }
+ }
+ is TabListAction.RemoveAllPrivateTabsAction -> {
+ context.state.tabs.filter { it.content.private }.forEach { tab ->
+ thumbnailStorage.deleteThumbnail(tab.id, isPrivate = true)
+ }
+ }
+ is TabListAction.RemoveAllTabsAction -> {
+ thumbnailStorage.clearThumbnails()
+ }
+ is TabListAction.RemoveTabAction -> {
+ // Delete the tab screenshot from the storage when the tab is removed.
+ val isPrivate = context.state.isTabIdPrivate(action.tabId)
+ thumbnailStorage.deleteThumbnail(action.tabId, isPrivate)
+ }
+ is TabListAction.RemoveTabsAction -> {
+ action.tabIds.forEach { id ->
+ val isPrivate = context.state.isTabIdPrivate(id)
+ thumbnailStorage.deleteThumbnail(id, isPrivate)
+ }
+ }
+ is ContentAction.UpdateThumbnailAction -> {
+ // Store the captured tab screenshot from the EngineView when the session's
+ // thumbnail is updated.
+ context.store.state.tabs.find { it.id == action.sessionId }?.let { session ->
+ val request = ImageSaveRequest(session.id, session.content.private)
+ thumbnailStorage.saveThumbnail(request, action.thumbnail)
+ }
+ return // Do not let the thumbnail actions continue through to the reducer.
+ }
+ else -> {
+ // no-op
+ }
+ }
+ next(action)
+ }
+
+ private fun BrowserState.isTabIdPrivate(id: String): Boolean =
+ tabs.any { it.id == id && it.content.private }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoader.kt b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoader.kt
new file mode 100644
index 0000000000..19382b69bf
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoader.kt
@@ -0,0 +1,71 @@
+/* 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.loader
+
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import androidx.annotation.MainThread
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import mozilla.components.browser.thumbnails.R
+import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageLoader
+import mozilla.components.support.images.CancelOnDetach
+import java.lang.ref.WeakReference
+
+/**
+ * An implementation of [ImageLoader] for loading thumbnails into a [ImageView].
+ */
+class ThumbnailLoader(private val storage: ThumbnailStorage) : ImageLoader {
+
+ override fun loadIntoView(
+ view: ImageView,
+ request: ImageLoadRequest,
+ placeholder: Drawable?,
+ error: Drawable?,
+ ) {
+ CoroutineScope(Dispatchers.Main).launch {
+ loadIntoViewInternal(WeakReference(view), request, placeholder, error)
+ }
+ }
+
+ @MainThread
+ private suspend fun loadIntoViewInternal(
+ view: WeakReference<ImageView>,
+ request: ImageLoadRequest,
+ placeholder: Drawable?,
+ error: Drawable?,
+ ) {
+ // If we previously started loading into the view, cancel the job.
+ val existingJob = view.get()?.getTag(R.id.mozac_browser_thumbnails_tag_job) as? Job
+ existingJob?.cancel()
+
+ // Create a loading job
+ val deferredThumbnail = storage.loadThumbnail(request)
+
+ view.get()?.setTag(R.id.mozac_browser_thumbnails_tag_job, deferredThumbnail)
+ val onAttachStateChangeListener = CancelOnDetach(deferredThumbnail).also {
+ view.get()?.addOnAttachStateChangeListener(it)
+ }
+
+ try {
+ val thumbnail = deferredThumbnail.await()
+ if (thumbnail != null) {
+ view.get()?.setImageBitmap(thumbnail)
+ } else {
+ view.get()?.setImageDrawable(placeholder)
+ }
+ } catch (e: CancellationException) {
+ view.get()?.setImageDrawable(error)
+ } finally {
+ view.get()?.removeOnAttachStateChangeListener(onAttachStateChangeListener)
+ view.get()?.setTag(R.id.mozac_browser_thumbnails_tag_job, null)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorage.kt b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorage.kt
new file mode 100644
index 0000000000..a2e7e97e30
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorage.kt
@@ -0,0 +1,133 @@
+/* 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.Job
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import mozilla.components.browser.thumbnails.R
+import mozilla.components.browser.thumbnails.utils.ThumbnailDiskCache
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageSaveRequest
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.base.utils.NamedThreadFactory
+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()
+internal val privateDiskCache = ThumbnailDiskCache(isPrivate = true)
+
+/**
+ * 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,
+ NamedThreadFactory("ThumbnailStorage"),
+ ).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)
+
+ init {
+ privateDiskCache.clear(context)
+ }
+
+ /**
+ * Clears all the stored thumbnails in the disk cache.
+ */
+ fun clearThumbnails(): Job =
+ scope.launch {
+ logger.debug("Cleared all thumbnails from disk")
+ sharedDiskCache.clear(context)
+ privateDiskCache.clear(context)
+ }
+
+ /**
+ * Deletes the given thumbnail [Bitmap] from the disk cache with the provided session ID or url
+ * as its key.
+ */
+ fun deleteThumbnail(sessionIdOrUrl: String, isPrivate: Boolean): Job =
+ scope.launch {
+ logger.debug("Removed thumbnail from disk (sessionIdOrUrl = $sessionIdOrUrl)")
+ if (isPrivate) {
+ privateDiskCache.removeThumbnailData(context, sessionIdOrUrl)
+ } else {
+ sharedDiskCache.removeThumbnailData(context, sessionIdOrUrl)
+ }
+ }
+
+ /**
+ * Asynchronously loads a thumbnail [Bitmap] for the given [ImageLoadRequest].
+ */
+ fun loadThumbnail(request: ImageLoadRequest): Deferred<Bitmap?> = scope.async {
+ loadThumbnailInternal(request).also { loadedThumbnail ->
+ if (loadedThumbnail != null) {
+ logger.debug(
+ "Loaded thumbnail from disk (id = ${request.id}, " +
+ "generationId = ${loadedThumbnail.generationId})",
+ )
+ } else {
+ logger.debug("No thumbnail loaded (id = ${request.id})")
+ }
+ }
+ }
+
+ @WorkerThread
+ private fun loadThumbnailInternal(request: ImageLoadRequest): Bitmap? {
+ val desiredSize = DesiredSize(
+ targetSize = request.size,
+ minSize = request.size,
+ maxSize = maximumSize,
+ maxScaleFactor = MAXIMUM_SCALE_FACTOR,
+ )
+
+ val data = if (request.isPrivate) {
+ privateDiskCache.getThumbnailData(context, request)
+ } else {
+ sharedDiskCache.getThumbnailData(context, request)
+ }
+
+ if (data != null) {
+ return decoders.decode(data, desiredSize)
+ }
+
+ return null
+ }
+
+ /**
+ * Stores the given thumbnail [Bitmap] into the disk cache with the provided [ImageLoadRequest]
+ * as its key.
+ */
+ fun saveThumbnail(request: ImageSaveRequest, bitmap: Bitmap): Job =
+ scope.launch {
+ logger.debug(
+ "Saved thumbnail to disk (id = $request, " +
+ "generationId = ${bitmap.generationId})",
+ )
+ if (request.isPrivate) {
+ privateDiskCache.putThumbnailBitmap(context, request, bitmap)
+ } else {
+ sharedDiskCache.putThumbnailBitmap(context, request, bitmap)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCache.kt b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCache.kt
new file mode 100644
index 0000000000..d3e53ae334
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCache.kt
@@ -0,0 +1,130 @@
+/* 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.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.os.Build
+import androidx.annotation.VisibleForTesting
+import com.jakewharton.disklrucache.DiskLruCache
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageSaveRequest
+import mozilla.components.support.base.log.logger.Logger
+import java.io.File
+import java.io.IOException
+
+private const val MAXIMUM_CACHE_THUMBNAIL_DATA_BYTES: Long = 1024L * 1024L * 100L // 100 MB
+private const val THUMBNAIL_DISK_CACHE_VERSION = 1
+private const val WEBP_QUALITY = 90
+private const val BASE_DIR_NAME = "thumbnails"
+
+/**
+ * Caching thumbnail bitmaps on disk.
+ *
+ * @property isPrivate whether this cache is intended for private browsing thumbnails
+ */
+class ThumbnailDiskCache(private val isPrivate: Boolean = false) {
+ private val logger = Logger("ThumbnailDiskCache")
+
+ @VisibleForTesting
+ internal var thumbnailCache: DiskLruCache? = null
+ private val thumbnailCacheWriteLock = Any()
+
+ internal fun clear(context: Context) {
+ synchronized(thumbnailCacheWriteLock) {
+ try {
+ getThumbnailCache(context).delete()
+ } catch (e: IOException) {
+ logger.warn("Thumbnail cache could not be cleared. Perhaps there are none?")
+ }
+ thumbnailCache = null
+ }
+ }
+
+ /**
+ * Retrieves the thumbnail data from the disk cache for the given session ID or URL.
+ *
+ * @param context the application [Context].
+ * @param request [ImageLoadRequest] providing the session ID or URL of the thumbnail to retrieve.
+ * @return the [ByteArray] of the thumbnail or null if the snapshot of the entry does not exist.
+ */
+ internal fun getThumbnailData(context: Context, request: ImageLoadRequest): ByteArray? {
+ val snapshot = getThumbnailCache(context).get(request.id) ?: return null
+
+ return try {
+ snapshot.getInputStream(0).use {
+ it.buffered().readBytes()
+ }
+ } catch (e: IOException) {
+ logger.info("Failed to read thumbnail bitmap from disk", e)
+ null
+ }
+ }
+
+ /**
+ * Stores the given session ID or URL's thumbnail [Bitmap] into the disk cache.
+ *
+ * @param context the application [Context].
+ * @param request [ImageSaveRequest] providing the session ID or URL of the thumbnail to retrieve.
+ * @param bitmap the thumbnail [Bitmap] to store.
+ */
+ internal fun putThumbnailBitmap(context: Context, request: ImageSaveRequest, bitmap: Bitmap) {
+ val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Bitmap.CompressFormat.WEBP_LOSSY
+ } else {
+ @Suppress("DEPRECATION")
+ Bitmap.CompressFormat.WEBP
+ }
+
+ try {
+ synchronized(thumbnailCacheWriteLock) {
+ val editor = getThumbnailCache(context)
+ .edit(request.id) ?: return
+
+ editor.newOutputStream(0).use { stream ->
+ bitmap.compress(compressFormat, WEBP_QUALITY, stream)
+ }
+
+ editor.commit()
+ }
+ } catch (e: IOException) {
+ logger.info("Failed to save thumbnail bitmap to disk", e)
+ }
+ }
+
+ /**
+ * Removes the given session ID or URL's thumbnail [Bitmap] from the disk cache.
+ *
+ * @param context the application [Context].
+ * @param sessionIdOrUrl the session ID or URL.
+ */
+ internal fun removeThumbnailData(context: Context, sessionIdOrUrl: String) {
+ try {
+ synchronized(thumbnailCacheWriteLock) {
+ getThumbnailCache(context).remove(sessionIdOrUrl)
+ }
+ } catch (e: IOException) {
+ logger.info("Failed to remove thumbnail bitmap from disk", e)
+ }
+ }
+
+ private fun getThumbnailCacheDirectory(context: Context): File {
+ val dirName = if (isPrivate) "private_$BASE_DIR_NAME" else BASE_DIR_NAME
+ val cacheDirectory = File(context.cacheDir, "mozac_browser_thumbnails")
+ return File(cacheDirectory, dirName)
+ }
+
+ @Synchronized
+ private fun getThumbnailCache(context: Context): DiskLruCache {
+ thumbnailCache?.let { return it }
+
+ return DiskLruCache.open(
+ getThumbnailCacheDirectory(context),
+ THUMBNAIL_DISK_CACHE_VERSION,
+ 1,
+ MAXIMUM_CACHE_THUMBNAIL_DATA_BYTES,
+ ).also { thumbnailCache = it }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/dimens.xml b/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..b6c009463a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/dimens.xml
@@ -0,0 +1,8 @@
+<?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>
+ <!-- Maximum size to save thumbnails at. We want full size thumbnails, so we use a large value -->
+ <dimen name="mozac_browser_thumbnails_maximum_size">99999dp</dimen>
+</resources>
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/tags.xml b/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/tags.xml
new file mode 100644
index 0000000000..5152cd5a83
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/main/res/values/tags.xml
@@ -0,0 +1,8 @@
+<?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>
+ <item name="mozac_browser_thumbnails_tag_job" type="id" />
+</resources>
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/BrowserThumbnailsTest.kt b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/BrowserThumbnailsTest.kt
new file mode 100644
index 0000000000..b3bdeb864f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/BrowserThumbnailsTest.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.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.concept.engine.EngineView
+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 mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoInteractions
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class BrowserThumbnailsTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var store: BrowserStore
+ private lateinit var engineView: EngineView
+ private lateinit var thumbnails: BrowserThumbnails
+ private val tabId = "test-tab"
+
+ @Before
+ fun setup() {
+ store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = tabId),
+ ),
+ selectedTabId = tabId,
+ ),
+ ),
+ )
+ engineView = mock()
+ thumbnails = BrowserThumbnails(testContext, engineView, store)
+ }
+
+ @Test
+ fun `do not capture thumbnail when feature is stopped and a site finishes loading`() {
+ thumbnails.start()
+ thumbnails.stop()
+
+ store.dispatch(ContentAction.UpdateThumbnailAction(tabId, mock())).joinBlocking()
+
+ verifyNoMoreInteractions(engineView)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun `feature must capture thumbnail when a site finishes loading and first paint`() {
+ val bitmap: Bitmap? = mock()
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tabId, true)).joinBlocking()
+
+ thumbnails.start()
+
+ `when`(engineView.captureThumbnail(any()))
+ .thenAnswer { // if engineView responds with a bitmap
+ (it.arguments[0] as (Bitmap?) -> Unit).invoke(bitmap)
+ }
+
+ verify(store, never()).dispatch(ContentAction.UpdateThumbnailAction(tabId, bitmap!!))
+
+ store.dispatch(ContentAction.UpdateLoadingStateAction(tabId, false)).joinBlocking()
+ store.dispatch(ContentAction.UpdateFirstContentfulPaintStateAction(tabId, true)).joinBlocking()
+
+ verify(store).dispatch(ContentAction.UpdateThumbnailAction(tabId, bitmap))
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun `feature never updates the store if there is no thumbnail bitmap`() {
+ val store: BrowserStore = mock()
+ val state: BrowserState = mock()
+ val engineView: EngineView = mock()
+ val feature = BrowserThumbnails(testContext, engineView, store)
+
+ `when`(store.state).thenReturn(state)
+ `when`(engineView.captureThumbnail(any()))
+ .thenAnswer { // if engineView responds with a bitmap
+ (it.arguments[0] as (Bitmap?) -> Unit).invoke(null)
+ }
+
+ feature.requestScreenshot()
+
+ verifyNoInteractions(store)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ @Test
+ fun `feature never updates the store if there is no tab ID`() {
+ val store: BrowserStore = mock()
+ val state: BrowserState = mock()
+ val engineView: EngineView = mock()
+ val feature = BrowserThumbnails(testContext, engineView, store)
+ val bitmap: Bitmap = mock()
+
+ `when`(store.state).thenReturn(state)
+ `when`(state.selectedTabId).thenReturn(tabId)
+ `when`(engineView.captureThumbnail(any()))
+ .thenAnswer { // if engineView responds with a bitmap
+ (it.arguments[0] as (Bitmap?) -> Unit).invoke(bitmap)
+ }
+
+ feature.requestScreenshot()
+
+ verify(store).dispatch(ContentAction.UpdateThumbnailAction(tabId, bitmap))
+ }
+
+ @Test
+ fun `when a page is loaded and the os is in low memory condition thumbnail should not be captured`() {
+ store.dispatch(ContentAction.UpdateThumbnailAction(tabId, mock())).joinBlocking()
+
+ thumbnails.testLowMemory = true
+
+ thumbnails.start()
+
+ verify(engineView, never()).captureThumbnail(any())
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/ThumbnailsMiddlewareTest.kt b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/ThumbnailsMiddlewareTest.kt
new file mode 100644
index 0000000000..424e3cabae
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/ThumbnailsMiddlewareTest.kt
@@ -0,0 +1,199 @@
+/* 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.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.TabListAction
+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.concept.base.images.ImageSaveRequest
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+class ThumbnailsMiddlewareTest {
+
+ @Test
+ fun `thumbnail storage stores the provided thumbnail on update thumbnail action`() {
+ val request = ImageSaveRequest("test-tab1", false)
+ 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(request.id, bitmap)).joinBlocking()
+ verify(thumbnailStorage).saveThumbnail(request, bitmap)
+ }
+
+ @Test
+ fun `WHEN update thumbnail action called with private tab THEN storage stores provided thumbnail`() {
+ val request = ImageSaveRequest("test-tab1", true)
+ val tab = createTab("https://www.mozilla.org", id = "test-tab1", private = true)
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(tabs = listOf(tab)),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ val bitmap: Bitmap = mock()
+ store.dispatch(ContentAction.UpdateThumbnailAction(request.id, bitmap)).joinBlocking()
+ verify(thumbnailStorage).saveThumbnail(request, bitmap)
+ }
+
+ @Test
+ fun `thumbnail storage removes the thumbnail on remove all normal tabs action`() {
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ createTab("https://www.wikipedia.com", id = "test-tab3"),
+ createTab("https://www.example.org", private = true, id = "test-tab4"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveAllNormalTabsAction).joinBlocking()
+ verify(thumbnailStorage).deleteThumbnail("test-tab1", false)
+ verify(thumbnailStorage).deleteThumbnail("test-tab2", false)
+ verify(thumbnailStorage).deleteThumbnail("test-tab3", false)
+ verify(thumbnailStorage, never()).deleteThumbnail("test-tab4", true)
+ }
+
+ @Test
+ fun `thumbnail storage removes the thumbnail on remove all private tabs action`() {
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", private = true, id = "test-tab2"),
+ createTab("https://www.wikipedia.com", private = true, id = "test-tab3"),
+ createTab("https://www.example.org", private = true, id = "test-tab4"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
+ verify(thumbnailStorage, never()).deleteThumbnail("test-tab1", false)
+ verify(thumbnailStorage).deleteThumbnail("test-tab2", true)
+ verify(thumbnailStorage).deleteThumbnail("test-tab3", true)
+ verify(thumbnailStorage).deleteThumbnail("test-tab4", true)
+ }
+
+ @Test
+ fun `thumbnail storage removes the thumbnail on remove all tabs action`() {
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveAllTabsAction()).joinBlocking()
+ verify(thumbnailStorage).clearThumbnails()
+ }
+
+ @Test
+ fun `thumbnail storage removes the thumbnail on remove tab action`() {
+ val sessionIdOrUrl = "test-tab1"
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveTabAction(sessionIdOrUrl)).joinBlocking()
+ verify(thumbnailStorage).deleteThumbnail(sessionIdOrUrl, false)
+ }
+
+ @Test
+ fun `WHEN remove tab action with private tab THEN thumbnail storage removes the thumbnail`() {
+ val sessionIdOrUrl = "test-tab1"
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1", private = true),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveTabAction(sessionIdOrUrl)).joinBlocking()
+ verify(thumbnailStorage).deleteThumbnail(sessionIdOrUrl, true)
+ }
+
+ @Test
+ fun `thumbnail storage removes the thumbnail on remove tabs action`() {
+ val sessionIdOrUrl = "test-tab1"
+ val thumbnailStorage: ThumbnailStorage = mock()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ ),
+ ),
+ middleware = listOf(ThumbnailsMiddleware(thumbnailStorage)),
+ )
+
+ store.dispatch(TabListAction.RemoveTabsAction(listOf(sessionIdOrUrl))).joinBlocking()
+ verify(thumbnailStorage).deleteThumbnail(sessionIdOrUrl, false)
+ }
+
+ @Test
+ fun `thumbnail actions are the only ones consumed by the middleware`() {
+ val capture = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+ val store = BrowserStore(
+ initialState = BrowserState(
+ tabs = listOf(
+ createTab("https://www.mozilla.org", id = "test-tab1"),
+ createTab("https://www.firefox.com", id = "test-tab2"),
+ ),
+ ),
+ middleware = listOf(
+ ThumbnailsMiddleware(mock()),
+ capture,
+ ),
+ )
+
+ store.dispatch(ContentAction.UpdateThumbnailAction("test-tab1", mock())).joinBlocking()
+ store.dispatch(TabListAction.RemoveTabAction("test-tab1")).joinBlocking()
+
+ // We shouldn't allow thumbnail actions to continue being processed.
+ capture.assertNotDispatched(ContentAction.UpdateThumbnailAction::class)
+ // TabListActions that we also observe in the middleware _should_ continue being processed.
+ capture.assertLastAction(TabListAction.RemoveTabAction::class) {}
+
+ // All other actions should also continue being processed.
+ store.dispatch(EngineAction.KillEngineSessionAction("test-tab1")).joinBlocking()
+ capture.assertLastAction(EngineAction.KillEngineSessionAction::class) {}
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoaderTest.kt b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoaderTest.kt
new file mode 100644
index 0000000000..397ff63e76
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/loader/ThumbnailLoaderTest.kt
@@ -0,0 +1,93 @@
+/* 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.loader
+
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Job
+import mozilla.components.browser.thumbnails.R
+import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+class ThumbnailLoaderTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `automatically load thumbnails into image view`() {
+ val mockedBitmap: Bitmap = mock()
+ val result = CompletableDeferred<Bitmap>()
+ val view: ImageView = mock()
+ val storage: ThumbnailStorage = mock()
+ val loader = spy(ThumbnailLoader(storage))
+ val request = ImageLoadRequest("123", 100, false)
+
+ doReturn(result).`when`(storage).loadThumbnail(request)
+
+ loader.loadIntoView(view, request)
+
+ verify(view).addOnAttachStateChangeListener(any())
+ verify(view).setTag(eq(R.id.mozac_browser_thumbnails_tag_job), any())
+ verify(view, never()).setImageBitmap(any())
+
+ result.complete(mockedBitmap)
+
+ verify(view).setImageBitmap(mockedBitmap)
+ verify(view).removeOnAttachStateChangeListener(any())
+ verify(view).setTag(R.id.mozac_browser_thumbnails_tag_job, null)
+ }
+
+ @Test
+ fun `loadIntoView sets drawable to error if cancelled`() {
+ val result = CompletableDeferred<Bitmap>()
+ val view: ImageView = mock()
+ val placeholder: Drawable = mock()
+ val error: Drawable = mock()
+ val storage: ThumbnailStorage = mock()
+ val loader = spy(ThumbnailLoader(storage))
+ val request = ImageLoadRequest("123", 100, false)
+
+ doReturn(result).`when`(storage).loadThumbnail(request)
+
+ loader.loadIntoView(view, request, placeholder = placeholder, error = error)
+
+ result.cancel()
+
+ verify(view).setImageDrawable(error)
+ verify(view).removeOnAttachStateChangeListener(any())
+ verify(view).setTag(R.id.mozac_browser_thumbnails_tag_job, null)
+ }
+
+ @Test
+ fun `loadIntoView cancels previous jobs`() {
+ val result = CompletableDeferred<Bitmap>()
+ val view: ImageView = mock()
+ val previousJob: Job = mock()
+ val storage: ThumbnailStorage = mock()
+ val loader = spy(ThumbnailLoader(storage))
+ val request = ImageLoadRequest("123", 100, false)
+
+ doReturn(previousJob).`when`(view).getTag(R.id.mozac_browser_thumbnails_tag_job)
+ doReturn(result).`when`(storage).loadThumbnail(request)
+
+ loader.loadIntoView(view, request)
+
+ verify(previousJob).cancel()
+
+ result.cancel()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorageTest.kt b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorageTest.kt
new file mode 100644
index 0000000000..53bd03208c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/storage/ThumbnailStorageTest.kt
@@ -0,0 +1,142 @@
+/* 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 mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageSaveRequest
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+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.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class ThumbnailStorageTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+
+ @Before
+ @After
+ fun cleanUp() {
+ sharedDiskCache.clear(testContext)
+ privateDiskCache.clear(testContext)
+ }
+
+ @Test
+ fun `clearThumbnails`() = runTestOnMain {
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext, testDispatcher))
+
+ thumbnailStorage.saveThumbnail(ImageSaveRequest("test-tab1", false), bitmap).joinBlocking()
+ thumbnailStorage.saveThumbnail(ImageSaveRequest("test-tab2", false), bitmap).joinBlocking()
+ var thumbnail1 = thumbnailStorage.loadThumbnail(ImageLoadRequest("test-tab1", 100, false)).await()
+ var thumbnail2 = thumbnailStorage.loadThumbnail(ImageLoadRequest("test-tab2", 100, false)).await()
+ assertNotNull(thumbnail1)
+ assertNotNull(thumbnail2)
+
+ thumbnailStorage.clearThumbnails()
+ thumbnail1 = thumbnailStorage.loadThumbnail(ImageLoadRequest("test-tab1", 100, false)).await()
+ thumbnail2 = thumbnailStorage.loadThumbnail(ImageLoadRequest("test-tab2", 100, false)).await()
+ assertNull(thumbnail1)
+ assertNull(thumbnail2)
+ }
+
+ @Test
+ fun `deleteThumbnail`() = runTestOnMain {
+ val request = ImageSaveRequest("test-tab1", false)
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext, testDispatcher))
+
+ thumbnailStorage.saveThumbnail(request, bitmap).joinBlocking()
+ var thumbnail = thumbnailStorage.loadThumbnail(ImageLoadRequest(request.id, 100, request.isPrivate)).await()
+ assertNotNull(thumbnail)
+
+ thumbnailStorage.deleteThumbnail(request.id, request.isPrivate).joinBlocking()
+ thumbnail = thumbnailStorage.loadThumbnail(ImageLoadRequest(request.id, 100, request.isPrivate)).await()
+ assertNull(thumbnail)
+ }
+
+ @Test
+ fun `saveThumbnail`() = runTestOnMain {
+ val request = ImageLoadRequest("test-tab1", 100, false)
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext))
+ var thumbnail = thumbnailStorage.loadThumbnail(request).await()
+
+ assertNull(thumbnail)
+
+ thumbnailStorage.saveThumbnail(ImageSaveRequest(request.id, request.isPrivate), bitmap).joinBlocking()
+ thumbnail = thumbnailStorage.loadThumbnail(request).await()
+ assertNotNull(thumbnail)
+ }
+
+ @Test
+ fun `WHEN private save request THEN placed in private cache`() = runTestOnMain {
+ val request = ImageLoadRequest("test-tab1", 100, true)
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext))
+ var thumbnail = thumbnailStorage.loadThumbnail(request).await()
+
+ assertNull(thumbnail)
+
+ thumbnailStorage.saveThumbnail(ImageSaveRequest(request.id, request.isPrivate), bitmap).joinBlocking()
+ thumbnail = thumbnailStorage.loadThumbnail(request).await()
+ assertNotNull(thumbnail)
+ }
+
+ @Test
+ fun `loadThumbnail`() = runTestOnMain {
+ val request = ImageLoadRequest("test-tab1", 100, false)
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext, testDispatcher))
+
+ thumbnailStorage.saveThumbnail(ImageSaveRequest(request.id, request.isPrivate), bitmap)
+ `when`(thumbnailStorage.loadThumbnail(request)).thenReturn(CompletableDeferred(bitmap))
+
+ val thumbnail = thumbnailStorage.loadThumbnail(request).await()
+ assertEquals(bitmap, thumbnail)
+ }
+
+ @Test
+ fun `WHEN private load request THEN loaded from private cache`() = runTestOnMain {
+ val request = ImageLoadRequest("test-tab1", 100, true)
+ val bitmap: Bitmap = mock()
+ val thumbnailStorage = spy(ThumbnailStorage(testContext, testDispatcher))
+
+ thumbnailStorage.saveThumbnail(ImageSaveRequest(request.id, request.isPrivate), bitmap)
+ `when`(thumbnailStorage.loadThumbnail(request)).thenReturn(CompletableDeferred(bitmap))
+
+ val thumbnail = thumbnailStorage.loadThumbnail(request).await()
+ assertEquals(bitmap, thumbnail)
+ assertNull(thumbnailStorage.loadThumbnail(ImageLoadRequest(request.id, request.size, false)).await())
+ }
+
+ @Test
+ fun `WHEN storage is initialized THEN private cache is cleared`() {
+ val request = ImageLoadRequest("test-tab1", 100, true)
+ val bitmap: Bitmap = mock()
+
+ privateDiskCache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+ assertNotNull(privateDiskCache.getThumbnailData(testContext, request))
+ ThumbnailStorage(testContext, testDispatcher)
+
+ assertNull(privateDiskCache.getThumbnailData(testContext, request))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCacheTest.kt b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCacheTest.kt
new file mode 100644
index 0000000000..d80f18af43
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/java/mozilla/components/browser/thumbnails/utils/ThumbnailDiskCacheTest.kt
@@ -0,0 +1,148 @@
+/* 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.utils
+
+import android.graphics.Bitmap
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.jakewharton.disklrucache.DiskLruCache
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageSaveRequest
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.`when`
+import org.robolectric.annotation.Config
+import java.io.IOException
+import java.io.OutputStream
+
+@RunWith(AndroidJUnit4::class)
+class ThumbnailDiskCacheTest {
+
+ @Test
+ fun `Writing and reading bitmap bytes for sdk higher than 29`() {
+ val cache = ThumbnailDiskCache()
+ val request = ImageLoadRequest("123", 100, false)
+
+ val bitmap: Bitmap = mock()
+ `when`(bitmap.compress(any(), ArgumentMatchers.anyInt(), any())).thenAnswer {
+ Assert.assertEquals(
+ Bitmap.CompressFormat.WEBP_LOSSY,
+ it.arguments[0] as Bitmap.CompressFormat,
+ )
+ Assert.assertEquals(90, it.arguments[1] as Int) // Quality
+
+ val stream = it.arguments[2] as OutputStream
+ stream.write("Hello World".toByteArray())
+ true
+ }
+
+ cache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+
+ val data = cache.getThumbnailData(testContext, request)
+ assertNotNull(data!!)
+ Assert.assertEquals("Hello World", String(data))
+ }
+
+ @Test
+ fun `Writing and reading bitmap bytes for private cache`() {
+ val cache = ThumbnailDiskCache(isPrivate = true)
+ val request = ImageLoadRequest("123", 100, true)
+
+ val bitmap: Bitmap = mock()
+ `when`(bitmap.compress(any(), ArgumentMatchers.anyInt(), any())).thenAnswer {
+ Assert.assertEquals(
+ Bitmap.CompressFormat.WEBP_LOSSY,
+ it.arguments[0] as Bitmap.CompressFormat,
+ )
+ Assert.assertEquals(90, it.arguments[1] as Int) // Quality
+
+ val stream = it.arguments[2] as OutputStream
+ stream.write("Hello World".toByteArray())
+ true
+ }
+
+ cache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+
+ val data = cache.getThumbnailData(testContext, request)
+ assertNotNull(data!!)
+ Assert.assertEquals("Hello World", String(data))
+ }
+
+ @Config(sdk = [29])
+ @Test
+ fun `Writing and reading bitmap bytes for sdk lower or equal to 29`() {
+ val cache = ThumbnailDiskCache()
+ val request = ImageLoadRequest("123", 100, false)
+
+ val bitmap: Bitmap = mock()
+ `when`(bitmap.compress(any(), ArgumentMatchers.anyInt(), any())).thenAnswer {
+ Assert.assertEquals(
+ @Suppress("DEPRECATION") // not deprecated in sdk 29
+ Bitmap.CompressFormat.WEBP,
+ it.arguments[0] as Bitmap.CompressFormat,
+ )
+ Assert.assertEquals(90, it.arguments[1] as Int) // Quality
+
+ val stream = it.arguments[2] as OutputStream
+ stream.write("Hello World".toByteArray())
+ true
+ }
+
+ cache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+
+ val data = cache.getThumbnailData(testContext, request)
+ assertNotNull(data!!)
+ Assert.assertEquals("Hello World", String(data))
+ }
+
+ @Test
+ fun `Removing bitmap from disk cache`() {
+ val cache = ThumbnailDiskCache()
+ val request = ImageLoadRequest("123", 100, false)
+ val bitmap: Bitmap = mock()
+
+ cache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+ var data = cache.getThumbnailData(testContext, request)
+ assertNotNull(data!!)
+
+ cache.removeThumbnailData(testContext, request.id)
+ data = cache.getThumbnailData(testContext, request)
+ assertNull(data)
+ }
+
+ @Test
+ fun `Clearing bitmap from disk cache`() {
+ val cache = ThumbnailDiskCache()
+ val request = ImageLoadRequest("123", 100, false)
+ val bitmap: Bitmap = mock()
+
+ cache.putThumbnailBitmap(testContext, ImageSaveRequest(request.id, request.isPrivate), bitmap)
+ var data = cache.getThumbnailData(testContext, request)
+ assertNotNull(data!!)
+
+ cache.clear(testContext)
+ data = cache.getThumbnailData(testContext, request)
+ assertNull(data)
+ }
+
+ @Test
+ fun `Clearing bitmap from disk catch IOException`() {
+ val cache = ThumbnailDiskCache()
+ val lruCache: DiskLruCache = mock()
+ cache.thumbnailCache = lruCache
+
+ `when`(lruCache.delete()).thenThrow(IOException("test"))
+
+ cache.clear(testContext)
+
+ assertNull(cache.thumbnailCache)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/thumbnails/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/thumbnails/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/thumbnails/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)