Revision control
Copy as Markdown
/* 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
package org.mozilla.focus.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.annotation.AnyThread
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.mozilla.focus.ext.arePixelsAllTheSame
import org.mozilla.focus.home.HomeTileScreenshotStore.DIR
import org.mozilla.focus.home.HomeTileScreenshotStore.uuidToFileSystemMutex
import java.io.File
import java.util.UUID
/**
* The format with which to compress on disk. Our goals for storage:
* - Store full resolution, with minimal artifacting: we don't know how we'll display the images in
* future redesigns so we want to preserve them as best as possible.
* - Take up minimal space: we don't have how many tiles the user will store.
*
* WebP was chosen through testing: it produces the smallest file sizes with the least amount of
* visual artifacting but is the most computationally expensive to save (2000ms WebP vs. 200ms JPEG),
* which is irrelevant for our infrequent screenshots.
*
* For file size comparisons, at 1080p, quality 50 on IMDB (a dense page):
* - WebP is ~65 KiB with almost no artifacts
* - JPEG is ~130 KiB with noticeable artifacts up close
* - PNG is ~1.6MiB with no artifacts (it is lossless)
*/
private val COMPRESSION_FORMAT = Bitmap.CompressFormat.WEBP
/**
* The quality argument to [Bitmap.compress]: this value was picked through testing (see
* [COMPRESSION_FORMAT] for goals). Here are comparisons at visual quality:
* - 100: Possibly lossless
* - 50: Visual artifacts essentially unnoticeable
* - 25: Colors are muted and text starts to have noticeable compression
*
* Some approximate data points:
* - IMDB quality 100: 426 KiB
* - IMDB quality 50: 65 KiB
* - IMDB quality 25: 43.6 KiB
* - Google Video quality 50: 16 KiB
* - Google Video quality 25: 13 KiB
*
* Since the data storage difference between 50 and 25 is negligible but there is a noticeable drop
* in visual quality, I chose to use 50. Since these screenshots will be shrunk, we could probably
* use lower quality but I'm concerned downscaling will make visual artifacts more significant and I
* didn't feel it was worth the time to test.
*/
private const val COMPRESSION_QUALITY = 50
private val BITMAP_FACTORY_OPTIONS = BitmapFactory.Options().apply {
// When full resolution in memory, 1080p Bitmap takes up ~7.9MiB, no matter how it's been
// compressed on disk. To save memory and reduce CPU downscale overhead, we downsample.
//
// In 1080p, our max resolution, screenshots are 1920x1080px and the tiles are 280x200px
// (not dp). We divide the screenshots by 4, 480x270px, which fits in the tiles: these
// Bitmaps take up ~0.5MiB in memory.
inSampleSize = 4
}
/**
* Storage for webpage screenshots used for the home tiles.
*
* We use UUIDs as identifiers for screenshots, rather than URLs (a natural choice) because:
* - URLs can exceed the maximum file name length; UUIDs can't
* - URLs can contain illegal file name characters; UUID's can't
* - URLs (as Strings) need to be validated (e.g. is it blank?)
* - The [CustomTilesManager] stores a unique identifier so we rely on it to provide one,
* pushing the complexity there.
*
* This class is thread-safe: see [uuidToFileSystemMutex] javadoc for details.
*/
object HomeTileScreenshotStore {
@VisibleForTesting const val DIR = "home_screenshots"
/**
* A map from UUID to Mutex: we have one lock for each screenshot we try to access (by uuid).
*
* This locking is important to ensure the file is completely written before its first read.
*
* Sometimes file writes can block for a long time (#610) so it's important we don't lock all
* reads and writes on a single write.
*/
private val uuidToFileSystemMutex = mutableMapOf<UUID, Mutex>()
/** @param uuid a unique identifier for this screenshot. */
@AnyThread
fun saveAsync(context: Context, uuid: UUID, screenshot: Bitmap) = GlobalScope.launch {
if (!isScreenshotAcceptableAsHomeTile(screenshot)) {
// We won't save this image, meaning we'll return null when we try to read it.
// At the time of writing, this will fall back to placeholders.
return@launch
}
getMutex(uuid).withLock {
ensureParentDirs(context)
val screenshotFile = getFileForUUID(context, uuid)
screenshotFile.createNewFile()
screenshotFile.outputStream().use {
screenshot.compress(COMPRESSION_FORMAT, COMPRESSION_QUALITY, it)
}
}
}
/** @param a unique identifier for this screenshot. */
@AnyThread
fun removeAsync(context: Context, uuid: UUID) = GlobalScope.launch {
getMutex(uuid).withLock {
getFileForUUID(context, uuid).delete()
}
}
/**
* A blocking function to read a bitmap from the store.
*
* @param uuid unique identifier for this screenshot.
* @return The decoded [Bitmap], or null if the file DNE or the bitmap could not be decoded.
*/
@WorkerThread // file access.
suspend fun read(context: Context, uuid: UUID) = getMutex(uuid).withLock { // TODO: consider timeout: #610
val file = getFileForUUID(context, uuid)
if (!file.exists()) {
null
} else {
file.inputStream().use {
BitmapFactory.decodeStream(it, null, BITMAP_FACTORY_OPTIONS)
}
}
}
@VisibleForTesting internal fun getFileForUUID(context: Context, uuid: UUID) = File(context.filesDir, getPathForUUID(uuid))
private fun getMutex(uuid: UUID) = synchronized(uuidToFileSystemMutex) {
uuidToFileSystemMutex.getOrPut(uuid) { Mutex() }
}
}
private fun ensureParentDirs(context: Context) { File(context.filesDir, DIR).mkdirs() }
private fun getPathForUUID(uuid: UUID) = "$DIR/$uuid"
private fun isScreenshotAcceptableAsHomeTile(screenshot: Bitmap): Boolean {
// Some websites get blank screenshots: vimeo videos are all black and Youtube videos
// are all transparent. We don't want these.
//
// It'd be more accurate to add some delta when comparing pixels, but that adds
// complexity that we we don't need for any screenshots we've already seen - Bitmap screenshots
// don't have compression rounding errors - and may accidentally remove valid images.
return !screenshot.arePixelsAllTheSame()
}