diff options
Diffstat (limited to 'mobile/android/android-components/components/browser/thumbnails')
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) |