diff --git a/.gitignore b/.gitignore index cd0e1c137..6eadbe346 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ gen-external-apklibs .sentry_token .mls_token .nimbus +.wallpaper_url # Python Byte-compiled / optimized / DLL files __pycache__/ diff --git a/app/build.gradle b/app/build.gradle index 6a1423caa..56cdf0807 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -376,6 +376,21 @@ android.applicationVariants.all { variant -> } else { buildConfigField 'Boolean', 'MOZILLA_OFFICIAL', 'false' } + +// ------------------------------------------------------------------------------------------------- +// BuildConfig: Set remote wallpaper URL using local file if it exists +// ------------------------------------------------------------------------------------------------- + + print("Wallpaper URL: ") + + try { + def token = new File("${rootDir}/.wallpaper_url").text.trim() + buildConfigField 'String', 'WALLPAPER_URL', '"' + token + '"' + println "(Added from .wallpaper_url file)" + } catch (FileNotFoundException ignored) { + buildConfigField 'String', 'WALLPAPER_URL', '""' + println("--") + } } // Generate Kotlin code for the Fenix Glean metrics. diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index e175dba4d..bf24bb65e 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -127,6 +127,7 @@ open class FenixApplication : LocaleAwareApplication(), Provider { setupInMainProcessOnly() + downloadWallpapers() // DO NOT MOVE ANYTHING BELOW THIS stop CALL. PerfStartup.applicationOnCreate.stopAndAccumulate(completeMethodDurationTimerId) } @@ -781,4 +782,13 @@ open class FenixApplication : LocaleAwareApplication(), Provider { } override fun getWorkManagerConfiguration() = Builder().setMinimumLoggingLevel(INFO).build() + + @OptIn(DelicateCoroutinesApi::class) + private fun downloadWallpapers() { + if (FeatureFlags.showWallpapers) { + GlobalScope.launch { + components.wallpaperManager.downloadAllRemoteWallpapers() + } + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 5451bdc5a..051fe8417 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -36,6 +36,7 @@ import org.mozilla.fenix.perf.StrictModeManager import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.utils.ClipboardHandler import org.mozilla.fenix.utils.Settings +import org.mozilla.fenix.wallpapers.WallpaperDownloader import org.mozilla.fenix.wallpapers.WallpaperManager import org.mozilla.fenix.wallpapers.WallpapersAssetsStorage import org.mozilla.fenix.wifi.WifiConnectionMonitor @@ -145,7 +146,12 @@ class Components(private val context: Context) { } val wallpaperManager by lazyMonitored { - WallpaperManager(settings, WallpapersAssetsStorage(context)) + WallpaperManager( + settings, + WallpapersAssetsStorage(context), + WallpaperDownloader(context, core.client, analytics.crashReporter), + analytics.crashReporter, + ) } val analytics by lazyMonitored { Analytics(context) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettings.kt b/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettings.kt index 48b94fea8..9d711d4d9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettings.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettings.kt @@ -70,7 +70,7 @@ import java.util.Locale fun WallpaperSettings( wallpapers: List, defaultWallpaper: Wallpaper, - loadWallpaperResource: (Wallpaper) -> Bitmap, + loadWallpaperResource: (Wallpaper) -> Bitmap?, selectedWallpaper: Wallpaper, onSelectWallpaper: (Wallpaper) -> Unit, onViewWallpaper: () -> Unit, @@ -163,7 +163,7 @@ private fun WallpaperSnackbar( private fun WallpaperThumbnails( wallpapers: List, defaultWallpaper: Wallpaper, - loadWallpaperResource: (Wallpaper) -> Bitmap, + loadWallpaperResource: (Wallpaper) -> Bitmap?, selectedWallpaper: Wallpaper, numColumns: Int = 3, onSelectWallpaper: (Wallpaper) -> Unit, @@ -199,7 +199,7 @@ private fun WallpaperThumbnails( private fun WallpaperThumbnailItem( wallpaper: Wallpaper, defaultWallpaper: Wallpaper, - loadWallpaperResource: (Wallpaper) -> Bitmap, + loadWallpaperResource: (Wallpaper) -> Bitmap?, isSelected: Boolean, aspectRatio: Float = 1.1f, onSelect: (Wallpaper) -> Unit @@ -214,6 +214,9 @@ private fun WallpaperThumbnailItem( Modifier } + val bitmap = loadWallpaperResource(wallpaper) + // Completely avoid drawing the item if a bitmap cannot be loaded and is required + if (bitmap == null && wallpaper != defaultWallpaper) return Surface( elevation = 4.dp, shape = thumbnailShape, @@ -225,15 +228,14 @@ private fun WallpaperThumbnailItem( .then(border) .clickable { onSelect(wallpaper) } ) { - if (wallpaper != defaultWallpaper) { - val contentDescription = stringResource( - R.string.wallpapers_item_name_content_description, wallpaper.name - ) + if (bitmap != null) { Image( - bitmap = loadWallpaperResource(wallpaper).asImageBitmap(), + bitmap = bitmap.asImageBitmap(), contentScale = ContentScale.FillBounds, - contentDescription = contentDescription, - modifier = Modifier.fillMaxSize() + contentDescription = stringResource( + R.string.wallpapers_item_name_content_description, wallpaper.name + ), + modifier = Modifier.fillMaxSize(), ) } } @@ -288,7 +290,7 @@ private fun WallpaperThumbnailsPreview() { WallpaperSettings( defaultWallpaper = WallpaperManager.defaultWallpaper, loadWallpaperResource = { - wallpaperManager.loadWallpaperFromAssets(it, context) + wallpaperManager.loadSavedWallpaper(context, it) }, wallpapers = wallpaperManager.availableWallpapers, selectedWallpaper = wallpaperManager.currentWallpaper, diff --git a/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettingsFragment.kt index 19e3d2c16..c2f564800 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/wallpaper/WallpaperSettingsFragment.kt @@ -52,7 +52,7 @@ class WallpaperSettingsFragment : Fragment() { wallpapers = wallpaperManager.availableWallpapers, defaultWallpaper = WallpaperManager.defaultWallpaper, loadWallpaperResource = { - wallpaperManager.loadWallpaperFromAssets(it, requireContext()) + wallpaperManager.loadSavedWallpaper(requireContext(), it) }, selectedWallpaper = currentWallpaper, onSelectWallpaper = { selectedWallpaper: Wallpaper -> diff --git a/app/src/main/java/org/mozilla/fenix/wallpapers/Wallpaper.kt b/app/src/main/java/org/mozilla/fenix/wallpapers/Wallpaper.kt index 12bdb7d29..13838550b 100644 --- a/app/src/main/java/org/mozilla/fenix/wallpapers/Wallpaper.kt +++ b/app/src/main/java/org/mozilla/fenix/wallpapers/Wallpaper.kt @@ -4,6 +4,9 @@ package org.mozilla.fenix.wallpapers +import android.content.Context +import android.content.res.Configuration + /** * A class that represents an available wallpaper and its state. * @property name Indicates the name of this wallpaper. @@ -23,7 +26,52 @@ data class Wallpaper( /** * A type hierarchy representing the different theme collections [Wallpaper]s belong to. */ -sealed class WallpaperThemeCollection { - object None : WallpaperThemeCollection() - object Firefox : WallpaperThemeCollection() +enum class WallpaperThemeCollection(val origin: WallpaperOrigin) { + NONE(WallpaperOrigin.LOCAL), + FIREFOX(WallpaperOrigin.LOCAL), + FOCUS(WallpaperOrigin.REMOTE), +} + +/** + * The parent directory name of a wallpaper. Since wallpapers that are [WallpaperOrigin.LOCAL] are + * stored in drawables, this extension is not applicable to them. + */ +val WallpaperThemeCollection.directoryName: String get() = when (this) { + WallpaperThemeCollection.NONE, + WallpaperThemeCollection.FIREFOX -> "" + WallpaperThemeCollection.FOCUS -> "focus" +} + +/** + * Types defining whether a [Wallpaper] is delivered through a remote source or is included locally + * in the APK. + */ +enum class WallpaperOrigin { + LOCAL, + REMOTE, +} + +/** + * Get the expected local path on disk for a wallpaper. This will differ depending + * on orientation and app theme. + */ +fun Wallpaper.getLocalPathFromContext(context: Context): String { + val orientation = if (context.isLandscape()) "landscape" else "portrait" + val theme = if (context.isDark()) "dark" else "light" + return getLocalPath(orientation, theme) +} + +/** + * Get the expected local path on disk for a wallpaper if orientation and app theme are known. + */ +fun Wallpaper.getLocalPath(orientation: String, theme: String): String = + "$orientation/$theme/${themeCollection.directoryName}/$name.png" + +private fun Context.isLandscape(): Boolean { + return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE +} + +private fun Context.isDark(): Boolean { + return resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES } diff --git a/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperDownloader.kt b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperDownloader.kt new file mode 100644 index 000000000..7d0cb9e0e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperDownloader.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 org.mozilla.fenix.wallpapers + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.isSuccess +import mozilla.components.lib.crash.CrashReporter +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.BuildConfig +import java.io.File + +/** + * Can download wallpapers from a remote host. + * + * @param context Required for writing files to local storage. + * @param client Required for fetching files from network. + */ +class WallpaperDownloader( + private val context: Context, + private val client: Client, + private val crashReporter: CrashReporter, +) { + private val logger = Logger("WallpaperDownloader") + private val remoteHost = BuildConfig.WALLPAPER_URL + + /** + * Downloads a wallpaper from the network. Will try to fetch 4 versions of each wallpaper: + * portrait/light - portrait/dark - landscape/light - landscape/dark. These are expected to be + * found at a remote path in the form: + * /////.png + */ + suspend fun downloadWallpaper(wallpaper: Wallpaper) = withContext(Dispatchers.IO) { + for (metadata in wallpaper.toMetadata()) { + val localFile = File(context.filesDir.absolutePath, metadata.localPath) + if (localFile.exists()) continue + val request = Request( + url = "$remoteHost/${metadata.remotePath}", + method = Request.Method.GET + ) + Result.runCatching { + client.fetch(request) + }.onSuccess { + if (!it.isSuccess) { + logger.error("Download response failure code: ${it.status}") + return@withContext + } + File(localFile.path.substringBeforeLast("/")).mkdirs() + it.body.useStream { input -> + input.copyTo(localFile.outputStream()) + } + }.onFailure { + logger.error(it.message ?: "Download failed: no throwable message included.", it) + crashReporter.submitCaughtException(it) + } + } + } + + private data class WallpaperMetadata(val remotePath: String, val localPath: String) + + private fun Wallpaper.toMetadata(): List = when (themeCollection.origin) { + WallpaperOrigin.LOCAL -> listOf() + WallpaperOrigin.REMOTE -> { + listOf("landscape", "portrait").flatMap { orientation -> + listOf("light", "dark").map { theme -> + val basePath = getLocalPath(orientation, theme) + val remotePath = "${context.resolutionSegment()}/$basePath" + WallpaperMetadata(remotePath, basePath) + } + } + } + } + + @Suppress("MagicNumber") + private fun Context.resolutionSegment(): String = when (resources.displayMetrics.densityDpi) { + // targeting hdpi and greater density resolutions https://developer.android.com/training/multiscreen/screendensities + in 0..240 -> "low" + in 240..320 -> "medium" + else -> "high" + } +} diff --git a/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperManager.kt b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperManager.kt index 2bf16a3b9..c15d4f627 100644 --- a/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperManager.kt +++ b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpaperManager.kt @@ -14,10 +14,15 @@ import android.graphics.drawable.BitmapDrawable import android.os.Handler import android.os.Looper import android.view.View +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.lib.crash.CrashReporter import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.android.content.getColorFromAttr import org.mozilla.fenix.R +import org.mozilla.fenix.perf.runBlockingIncrement import org.mozilla.fenix.utils.Settings +import java.io.File /** * Provides access to available wallpapers and manages their states. @@ -26,9 +31,20 @@ import org.mozilla.fenix.utils.Settings class WallpaperManager( private val settings: Settings, private val wallpaperStorage: WallpaperStorage, + private val downloader: WallpaperDownloader, + private val crashReporter: CrashReporter, ) { val logger = Logger("WallpaperManager") - var availableWallpapers: List = loadWallpapers() + private val remoteWallpapers = listOf( + Wallpaper( + "focus", + portraitPath = "", + landscapePath = "", + isDark = false, + themeCollection = WallpaperThemeCollection.FOCUS + ), + ) + var availableWallpapers: List = loadWallpapers() + remoteWallpapers private set var currentWallpaper: Wallpaper = getCurrentWallpaperFromSettings() @@ -46,13 +62,29 @@ class WallpaperManager( wallpaperContainer.setBackgroundColor(context.getColorFromAttr(DEFAULT_RESOURCE)) logger.info("Wallpaper update to default background") } else { - logger.info("Wallpaper update to ${newWallpaper.name}") - val bitmap = loadWallpaperFromAssets(newWallpaper, context) - wallpaperContainer.background = BitmapDrawable(context.resources, bitmap) + val bitmap = loadSavedWallpaper(context, newWallpaper) + if (bitmap == null) { + val message = "Could not load wallpaper bitmap. Resetting to default." + logger.error(message) + crashReporter.submitCaughtException(NullPointerException(message)) + wallpaperContainer.setBackgroundColor(context.getColorFromAttr(DEFAULT_RESOURCE)) + currentWallpaper = defaultWallpaper + } else { + wallpaperContainer.background = BitmapDrawable(context.resources, bitmap) + } } currentWallpaper = newWallpaper } + /** + * Download all known remote wallpapers. + */ + suspend fun downloadAllRemoteWallpapers() { + for (wallpaper in remoteWallpapers) { + downloader.downloadWallpaper(wallpaper) + } + } + /** * Returns the next available [Wallpaper], the [currentWallpaper] is the last one then * the first available [Wallpaper] will be returned. @@ -77,16 +109,36 @@ class WallpaperManager( } } - fun loadWallpaperFromAssets(wallpaper: Wallpaper, context: Context): Bitmap { + /** + * Load a wallpaper that is saved locally. + */ + fun loadSavedWallpaper(context: Context, wallpaper: Wallpaper): Bitmap? = + if (wallpaper.themeCollection.origin == WallpaperOrigin.LOCAL) { + loadWallpaperFromAssets(context, wallpaper) + } else { + loadWallpaperFromDisk(context, wallpaper) + } + + private fun loadWallpaperFromAssets(context: Context, wallpaper: Wallpaper): Bitmap? = Result.runCatching { val path = if (isLandscape(context)) { wallpaper.landscapePath } else { wallpaper.portraitPath } - return context.assets.open(path).use { + context.assets.open(path).use { BitmapFactory.decodeStream(it) } - } + }.getOrNull() + + private fun loadWallpaperFromDisk(context: Context, wallpaper: Wallpaper): Bitmap? = Result.runCatching { + val path = wallpaper.getLocalPathFromContext(context) + runBlockingIncrement { + withContext(Dispatchers.IO) { + val file = File(context.filesDir, path) + BitmapFactory.decodeStream(file.inputStream()) + } + } + }.getOrNull() private fun isLandscape(context: Context): Boolean { return context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -141,7 +193,7 @@ class WallpaperManager( portraitPath = "", landscapePath = "", isDark = false, - themeCollection = WallpaperThemeCollection.None + themeCollection = WallpaperThemeCollection.NONE ) private const val ANIMATION_DELAY_MS = 1500L } diff --git a/app/src/main/java/org/mozilla/fenix/wallpapers/WallpapersAssetsStorage.kt b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpapersAssetsStorage.kt index 535598153..70ca4abbe 100644 --- a/app/src/main/java/org/mozilla/fenix/wallpapers/WallpapersAssetsStorage.kt +++ b/app/src/main/java/org/mozilla/fenix/wallpapers/WallpapersAssetsStorage.kt @@ -42,10 +42,10 @@ class WallpapersAssetsStorage(private val context: Context) : WallpaperStorage { isDark = getBoolean("isDark"), themeCollection = Result.runCatching { when (getString("themeCollection")) { - "firefox" -> WallpaperThemeCollection.Firefox - else -> WallpaperThemeCollection.None + "firefox" -> WallpaperThemeCollection.FIREFOX + else -> WallpaperThemeCollection.NONE } - }.getOrDefault(WallpaperThemeCollection.None) + }.getOrDefault(WallpaperThemeCollection.NONE) ) } catch (e: JSONException) { logger.error("unable to parse json for wallpaper $this", e) diff --git a/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperManagerTest.kt b/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperManagerTest.kt index 94a071579..f950639d1 100644 --- a/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperManagerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/wallpapers/WallpaperManagerTest.kt @@ -27,7 +27,7 @@ class WallpaperManagerTest { every { mockSettings.currentWallpaper = capture(currentCaptureSlot) } just runs val updatedWallpaper = WallpaperManager.defaultWallpaper - val wallpaperManager = WallpaperManager(mockSettings, mockStorage) + val wallpaperManager = WallpaperManager(mockSettings, mockStorage, mockk(), mockk()) wallpaperManager.currentWallpaper = updatedWallpaper assertEquals(updatedWallpaper.name, currentCaptureSlot.captured) diff --git a/taskcluster/fenix_taskgraph/transforms/build.py b/taskcluster/fenix_taskgraph/transforms/build.py index 8ac7c1c0c..20e93476f 100644 --- a/taskcluster/fenix_taskgraph/transforms/build.py +++ b/taskcluster/fenix_taskgraph/transforms/build.py @@ -47,6 +47,7 @@ def add_shippable_secrets(config, tasks): ('sentry_dsn', '.sentry_token'), ('mls', '.mls_token'), ('nimbus_url', '.nimbus'), + ('wallpaper_url', ".wallpaper_url") )]) else: dummy_secrets.extend([{