improve splash screens

This commit is contained in:
Fabian Freund
2026-02-10 18:01:00 +01:00
parent 83673227f3
commit 3e4a9cfc5f
11 changed files with 243 additions and 470 deletions
@@ -11,9 +11,13 @@ object PwaConstants {
const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid" const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid"
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id" const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
// Shortcut ID for linking to cached manifest + icon on disk
const val EXTRA_PWA_SHORTCUT_ID = "pwa_shortcut_id"
// Profile and file paths // Profile and file paths
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile" const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping" const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
const val PWA_CACHE_DIR = "pwa_cache"
// Component initialization timeouts // Component initialization timeouts
const val COMPONENT_INIT_TIMEOUT_MS = 10000L const val COMPONENT_INIT_TIMEOUT_MS = 10000L
@@ -0,0 +1,96 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.concept.engine.manifest.WebAppManifestParser
import java.io.File
import java.io.FileOutputStream
/**
* Simple disk cache for PWA splash screen data (manifest + icon).
* Keyed by shortcut ID so home screen shortcuts can instantly display
* branded splash screens without waiting for components to initialize.
*
* Cache layout:
* <filesDir>/pwa_cache/<shortcutId>/manifest.json
* <filesDir>/pwa_cache/<shortcutId>/icon.png
*/
object PwaSplashCache {
private const val TAG = "PwaSplashCache"
private val parser = WebAppManifestParser()
private const val MANIFEST_FILE = "manifest.json"
private const val ICON_FILE = "icon.png"
/**
* Saves manifest JSON and icon bitmap to disk for the given shortcut ID.
*/
fun save(context: Context, shortcutId: String, manifest: WebAppManifest, icon: Bitmap?) {
try {
val dir = cacheDir(context, shortcutId)
if (!dir.exists()) dir.mkdirs()
val json = parser.serialize(manifest).toString()
File(dir, MANIFEST_FILE).writeText(json)
icon?.let { bitmap ->
FileOutputStream(File(dir, ICON_FILE)).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to cache splash data for $shortcutId", e)
}
}
/**
* Loads cached manifest for the given shortcut ID, or null if not cached.
*/
fun loadManifest(context: Context, shortcutId: String): WebAppManifest? {
return try {
val file = File(cacheDir(context, shortcutId), MANIFEST_FILE)
if (!file.exists()) return null
val json = file.readText()
when (val result = parser.parse(json)) {
is WebAppManifestParser.Result.Success -> result.manifest
is WebAppManifestParser.Result.Failure -> {
Log.e(TAG, "Failed to parse cached manifest for $shortcutId", result.exception)
null
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load cached manifest for $shortcutId", e)
null
}
}
/**
* Loads cached icon bitmap for the given shortcut ID, or null if not cached.
*/
fun loadIcon(context: Context, shortcutId: String): Bitmap? {
return try {
val file = File(cacheDir(context, shortcutId), ICON_FILE)
if (!file.exists()) return null
BitmapFactory.decodeFile(file.absolutePath)
} catch (e: Exception) {
Log.e(TAG, "Failed to load cached icon for $shortcutId", e)
null
}
}
private fun cacheDir(context: Context, shortcutId: String): File {
// Always use applicationContext.filesDir to avoid profile-scoped context paths
return File(context.applicationContext.filesDir, "${PwaConstants.PWA_CACHE_DIR}/$shortcutId")
}
}
@@ -12,7 +12,6 @@ import android.os.Bundle
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants import eu.weblibre.flutter_mozilla_components.PwaConstants
@@ -25,7 +24,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.findCustomTab import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.log.logger.Logger
@@ -76,19 +74,17 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
val container = findViewById<FrameLayout>(R.id.container) val container = findViewById<FrameLayout>(R.id.container)
loadingScreenManager = LoadingScreenManager.forActivity(this, container) loadingScreenManager = LoadingScreenManager.forActivity(this, container)
// Show branded placeholder immediately based on available data
// If we have a manifest URL, it's likely a PWA
val url = webAppManifestUrl ?: "" val url = webAppManifestUrl ?: ""
val shortcutId = intent?.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
if (url.isNotEmpty()) { if (url.isNotEmpty()) {
// Try to show PWA placeholder val loadingIntent = Intent().apply {
loadingScreenManager?.showLoadingForIntent( data = android.net.Uri.parse(url)
Intent().apply { putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder")
data = android.net.Uri.parse(url) shortcutId?.let { putExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID, it) }
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder") }
} loadingScreenManager?.showLoadingForIntent(loadingIntent)
)
} else { } else {
// Show Custom Tab placeholder
loadingScreenManager?.showLoadingForIntent(Intent()) loadingScreenManager?.showLoadingForIntent(Intent())
} }
} }
@@ -98,12 +94,7 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
var elapsedMs = 0L var elapsedMs = 0L
while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) { while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) {
val components = GlobalComponents.components if (GlobalComponents.components != null) {
if (components != null) {
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(components, sessionId)
// Brief delay to show the enhanced loading screen
delay(200)
showFragment(sessionId) showFragment(sessionId)
return@launch return@launch
} }
@@ -120,39 +111,6 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
} }
} }
/**
* Enhances the existing loading screen with actual data once components are ready.
*/
private fun enhanceLoadingScreen(components: Components, sessionId: String) {
val session = components.core.store.state.findCustomTab(sessionId) ?: return
val url = session.content.url
val manifestUrl = webAppManifestUrl
loadingScreenManager?.let { manager ->
when (session.config.externalAppType) {
mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP,
mozilla.components.browser.state.state.ExternalAppType.TRUSTED_WEB_ACTIVITY -> {
// Enhance PWA loading with manifest data
coroutineScope.launch(Dispatchers.IO) {
val manifest = manifestUrl?.let { manifestUrl ->
components.core.webAppManifestStorage.loadManifest(manifestUrl)
}
withContext(Dispatchers.Main) {
manifest?.let {
manager.enhancePwaLoading(it, components.core.icons, coroutineScope)
} ?: manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
else -> {
// Enhance Custom Tab loading with favicon
manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
}
private fun showFragment(sessionId: String) { private fun showFragment(sessionId: String) {
val components = GlobalComponents.components ?: run { val components = GlobalComponents.components ?: run {
logger.error("Components still null after waiting, finishing.") logger.error("Components still null after waiting, finishing.")
@@ -241,11 +199,13 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
context: Context, context: Context,
customTabSessionId: String, customTabSessionId: String,
webAppManifestUrl: String? = null, webAppManifestUrl: String? = null,
pwaShortcutId: String? = null,
): Intent { ): Intent {
return Intent(context, ExternalAppBrowserActivity::class.java).apply { return Intent(context, ExternalAppBrowserActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId) putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId)
webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) } webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) }
pwaShortcutId?.let { putExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID, it) }
} }
} }
} }
@@ -246,6 +246,7 @@ class IntentReceiverActivity : Activity() {
context = this@IntentReceiverActivity, context = this@IntentReceiverActivity,
customTabSessionId = sessionId, customTabSessionId = sessionId,
webAppManifestUrl = url, webAppManifestUrl = url,
pwaShortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID),
) )
startActivity(externalIntent) startActivity(externalIntent)
finish() finish()
@@ -310,33 +311,6 @@ class IntentReceiverActivity : Activity() {
loadingScreenManager?.showLoadingForIntent(intent) loadingScreenManager?.showLoadingForIntent(intent)
} }
/**
* Enhances the existing loading screen with actual data once components are initialized.
* This updates the placeholder with real manifest/icon data.
*/
private fun enhanceLoadingScreen(intent: Intent) {
val components = GlobalComponents.components ?: return
val url = intent.dataString ?: return
loadingScreenManager?.let { manager ->
when {
// PWA intent - enhance with manifest data
LoadingScreenManager.isPwaIntent(intent) -> {
coroutineScope.launch {
val manifest = components.core.webAppManifestStorage.loadManifest(url)
manifest?.let {
manager.enhancePwaLoading(it, components.core.icons, coroutineScope)
}
}
}
// Custom Tab - enhance with favicon
else -> {
manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
}
/** /**
* Waits for GlobalComponents to be initialized with a timeout. * Waits for GlobalComponents to be initialized with a timeout.
* Once components are ready, shows branded loading screen before routing. * Once components are ready, shows branded loading screen before routing.
@@ -349,13 +323,7 @@ class IntentReceiverActivity : Activity() {
while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) { while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) {
if (GlobalComponents.components != null) { if (GlobalComponents.components != null) {
logger.debug("Components initialized after ${elapsedMs}ms") logger.debug("Components initialized after ${elapsedMs}ms")
pendingIntent?.let { intent -> pendingIntent?.let { routeIntent(it) }
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(intent)
// Small delay to show the enhanced loading screen (200ms)
delay(200)
routeIntent(intent)
}
pendingIntent = null pendingIntent = null
return@launch return@launch
} }
@@ -17,6 +17,7 @@ import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.PwaSplashCache
import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
@@ -135,19 +136,26 @@ class GeckoPwaApiImpl(
return@withContext false return@withContext false
} }
val (iconBitmap, isMaskable) = loadPwaIcon(manifest)
val shortcutId = generateShortcutId(manifest.startUrl)
// Cache manifest + icon to disk for instant branded splash at launch
PwaSplashCache.save(context, shortcutId, manifest, iconBitmap)
val appName = manifest.shortName ?: manifest.name ?: "Web App"
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply { val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = Uri.parse(manifest.startUrl) data = Uri.parse(manifest.startUrl)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid) putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid)
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId) putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
putExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID, shortcutId)
} }
val (iconBitmap, isMaskable) = loadPwaIcon(manifest)
val shortcutId = generateShortcutId(manifest.startUrl)
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply { val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
setShortLabel(manifest.shortName ?: manifest.name ?: "Web App") setShortLabel(appName)
setLongLabel(manifest.name ?: manifest.shortName ?: "Web App") setLongLabel(manifest.name ?: appName)
setIntent(shortcutIntent) setIntent(shortcutIntent)
if (iconBitmap != null) { if (iconBitmap != null) {
@@ -8,203 +8,90 @@ package eu.weblibre.flutter_mozilla_components.ui
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.view.ContextThemeWrapper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import eu.weblibre.flutter_mozilla_components.PwaConstants import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.PwaSplashCache
import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.R
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.IconRequest
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.support.base.log.logger.Logger
import kotlinx.coroutines.*
/** /**
* Manager for displaying branded loading screens during PWA and Custom Tab initialization. * Displays a loading screen during PWA and Custom Tab initialization.
* Handles loading of icons, theming, and animations. *
* - PWA with cached data: shows cached icon, app name, and theme color instantly from disk.
* - PWA without cache / Custom Tab: shows WebLibre logo with domain name.
*
* No component dependencies — everything is resolved from intent extras and disk cache.
*/ */
class LoadingScreenManager private constructor( class LoadingScreenManager private constructor(
private val activity: Activity, private val activity: Activity,
private val container: FrameLayout private val container: FrameLayout
) { ) {
private val logger = Logger("LoadingScreenManager")
private var currentLoadingView: View? = null private var currentLoadingView: View? = null
private var pulseAnimator: ObjectAnimator? = null
// Material3 theme wrapper for activities that use non-Material themes (e.g. AppCompat.Translucent)
private val themedContext: Context
get() = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar)
companion object { companion object {
/** fun forActivity(activity: Activity, container: FrameLayout) =
* Creates a LoadingScreenManager for the given activity. LoadingScreenManager(activity, container)
* The container should be the root view where loading screens will be added.
*/
fun forActivity(activity: Activity, container: FrameLayout): LoadingScreenManager {
return LoadingScreenManager(activity, container)
}
/** fun isPwaIntent(intent: Intent?) =
* Detects if an intent is for a PWA based on the extras. intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
*/
fun isPwaIntent(intent: Intent?): Boolean {
return intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
}
/** fun hasCachedSplashData(intent: Intent?) =
* Extracts URL from an intent. intent?.hasExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID) == true
*/
fun extractUrl(intent: Intent?): String? {
return intent?.dataString
}
} }
/** /**
* Shows the appropriate loading screen immediately based on intent analysis. * Shows the loading screen immediately based on intent analysis.
* This can be called before components are initialized. * Can be called before components are initialized.
*
* @param intent The intent to analyze for type detection
*/ */
fun showLoadingForIntent(intent: Intent) { fun showLoadingForIntent(intent: Intent) {
when {
isPwaIntent(intent) -> showPwaPlaceholder(intent)
else -> showCustomTabPlaceholder(intent)
}
}
/**
* Shows a PWA placeholder loading screen immediately (before components are ready).
* Uses URL to extract domain as temporary app name.
*/
private fun showPwaPlaceholder(intent: Intent) {
cleanup() cleanup()
val view = LayoutInflater.from(activity).inflate( val view = LayoutInflater.from(themedContext).inflate(
R.layout.pwa_loading_screen, R.layout.loading_screen, container, false
container,
false
) )
// Extract URL and use domain as temporary name val iconView = view.findViewById<ImageView>(R.id.loading_icon)
val url = intent.dataString val nameView = view.findViewById<TextView>(R.id.loading_name)
val domain = url?.let { extractDomain(it) } ?: "Web App" val statusView = view.findViewById<TextView>(R.id.loading_status)
// Set temporary app name (will be replaced with actual name once manifest loads) val shortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
val nameView = view.findViewById<TextView>(R.id.pwa_name) val manifest = shortcutId?.let { PwaSplashCache.loadManifest(activity, it) }
nameView.text = domain
// Show WebLibre logo as placeholder (already set in XML layout) if (manifest != null) {
val iconView = view.findViewById<ImageView>(R.id.pwa_icon) // PWA with cached data — branded splash
iconView.alpha = 0.5f nameView.text = manifest.shortName ?: manifest.name ?: "Web App"
// Start pulsing animation val cachedIcon = PwaSplashCache.loadIcon(activity, shortcutId)
startPulseAnimation(view) if (cachedIcon != null) {
iconView.setImageBitmap(cachedIcon)
}
manifest.themeColor?.let { themeColor ->
applyThemeColor(view, themeColor, nameView, statusView)
}
} else {
// Custom Tab or uncached PWA — WebLibre logo + domain
val url = intent.dataString
nameView.text = url?.let { extractDomain(it) } ?: "Web App"
}
container.addView(view) container.addView(view)
currentLoadingView = view currentLoadingView = view
} }
/**
* Shows a Custom Tab placeholder loading screen immediately (before components are ready).
*/
private fun showCustomTabPlaceholder(intent: Intent) {
cleanup()
val view = LayoutInflater.from(activity).inflate(
R.layout.custom_tab_loading_screen,
container,
false
)
// Extract and display domain
val url = intent.dataString ?: return
val domainView = view.findViewById<TextView>(R.id.custom_tab_domain)
domainView.text = extractDomain(url)
container.addView(view)
currentLoadingView = view
}
/**
* Enhances the current loading screen with actual PWA data once components are ready.
* This updates the placeholder with real manifest data.
*/
fun enhancePwaLoading(
manifest: WebAppManifest,
browserIcons: BrowserIcons,
coroutineScope: CoroutineScope
) {
currentLoadingView?.let { view ->
// Apply theme colors if available
manifest.themeColor?.let { colorInt ->
view.setBackgroundColor(colorInt)
val isDark = ColorUtils.calculateLuminance(colorInt) < 0.5
val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK
val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3)
view.findViewById<TextView>(R.id.pwa_name)?.setTextColor(primaryTextColor)
view.findViewById<TextView>(R.id.pwa_status)?.setTextColor(secondaryTextColor)
}
// Update app name
val nameView = view.findViewById<TextView>(R.id.pwa_name)
val appName = manifest.shortName ?: manifest.name
if (appName != null && nameView.text != appName) {
nameView.text = appName
}
// Load the PWA icon asynchronously and update
coroutineScope.launch(Dispatchers.IO) {
loadPwaIcon(manifest, browserIcons)?.let { bitmap ->
withContext(Dispatchers.Main) {
val iconView = view.findViewById<ImageView>(R.id.pwa_icon)
iconView.alpha = 1.0f
iconView.setImageBitmap(bitmap)
}
}
}
}
}
/**
* Enhances the Custom Tab loading screen with favicon once components are ready.
*/
fun enhanceCustomTabLoading(
url: String,
browserIcons: BrowserIcons,
coroutineScope: CoroutineScope
) {
currentLoadingView?.let { view ->
coroutineScope.launch(Dispatchers.IO) {
try {
val iconRequest = IconRequest(
url = url,
size = IconRequest.Size.DEFAULT
)
val iconResult = browserIcons.loadIcon(iconRequest).await()
iconResult?.bitmap?.let { bitmap ->
withContext(Dispatchers.Main) {
val iconView = view.findViewById<ImageView>(R.id.custom_tab_icon)
iconView.setImageBitmap(bitmap)
iconView.alpha = 1.0f
}
}
} catch (e: Exception) {
logger.debug("Failed to load favicon for $url")
}
}
}
}
/**
* Hides the loading screen with an optional fade-out animation.
*/
fun hideLoading(animate: Boolean = true) { fun hideLoading(animate: Boolean = true) {
currentLoadingView?.let { view -> currentLoadingView?.let { view ->
if (animate) { if (animate) {
@@ -223,74 +110,26 @@ class LoadingScreenManager private constructor(
} }
} }
/**
* Cleans up the loading view and animations.
*/
fun cleanup() { fun cleanup() {
pulseAnimator?.cancel() currentLoadingView?.let { container.removeView(it) }
pulseAnimator = null
currentLoadingView?.let { view ->
container.removeView(view)
}
currentLoadingView = null currentLoadingView = null
} }
private fun startPulseAnimation(view: View) { private fun applyThemeColor(view: View, themeColor: Int, nameView: TextView, statusView: TextView) {
val pulseView = view.findViewById<View>(R.id.pwa_icon_pulse) view.setBackgroundColor(themeColor)
?: return
pulseView.alpha = 0.0f val isDark = ColorUtils.calculateLuminance(themeColor) < 0.5
pulseAnimator = ObjectAnimator.ofFloat(pulseView, "alpha", 0.0f, 0.3f, 0.0f).apply { val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK
duration = 1500 val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3)
repeatCount = ObjectAnimator.INFINITE
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
private suspend fun loadPwaIcon( nameView.setTextColor(primaryTextColor)
manifest: WebAppManifest, statusView.setTextColor(secondaryTextColor)
browserIcons: BrowserIcons
): Bitmap? {
return try {
val iconResource = manifest.icons
.filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) ||
it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) }
.maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) }
?: manifest.icons.firstOrNull()
iconResource?.let { icon ->
val iconRequest = IconRequest(
url = manifest.startUrl,
size = IconRequest.Size.LAUNCHER,
resources = listOf(
IconRequest.Resource(
url = icon.src,
type = IconRequest.Resource.Type.MANIFEST_ICON,
sizes = icon.sizes?.map { size ->
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
} ?: emptyList(),
mimeType = icon.type,
maskable = icon.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
)
)
)
val result = browserIcons.loadIcon(iconRequest).await()
result?.bitmap
}
} catch (e: Exception) {
logger.error("Failed to load PWA icon", e)
null
}
} }
private fun extractDomain(url: String): String { private fun extractDomain(url: String): String {
return try { return try {
val uri = android.net.Uri.parse(url) android.net.Uri.parse(url).host ?: url
uri.host ?: url } catch (_: Exception) {
} catch (e: Exception) {
url url
} }
} }
@@ -1,10 +0,0 @@
<?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/. -->
<!-- Pulsing ripple effect for PWA loading animation -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?attr/colorPrimary" />
</shape>
@@ -1,54 +0,0 @@
<?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/. -->
<!-- Minimal loading screen for Custom Tabs - keeps focus on content -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/custom_tab_loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<!-- Small domain favicon or generic icon -->
<ImageView
android:id="@+id/custom_tab_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/website_icon_description"
android:alpha="0.6"
android:src="@drawable/ic_launcher_foreground" />
<!-- Domain/URL being loaded -->
<TextView
android:id="@+id/custom_tab_domain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginBottom="16dp"
android:paddingHorizontal="32dp" />
<!-- Simple linear progress indicator -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/custom_tab_progress"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:trackThickness="2dp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,56 @@
<?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/. -->
<!-- Shared loading screen for PWA and Custom Tab launch.
PWA: shows cached icon + app name + theme color.
Custom Tab: shows WebLibre logo + domain name. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:id="@+id/loading_icon"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="24dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/loading_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?android:attr/textColorPrimary" />
<TextView
android:id="@+id/loading_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/pwa_loading"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loading_progress"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginTop="32dp"
android:indeterminate="true"
app:indicatorSize="32dp"
app:trackThickness="3dp" />
</LinearLayout>
@@ -1,88 +0,0 @@
<?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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/pwa_loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<!-- App Icon Container with subtle shadow/elevation -->
<FrameLayout
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="24dp">
<!-- Icon background for maskable icons -->
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/pwa_icon_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
app:shapeAppearanceOverlay="@style/RoundedIconShape" />
<!-- App Icon -->
<ImageView
android:id="@+id/pwa_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:contentDescription="@string/pwa_icon_description"
android:src="@drawable/ic_launcher_foreground" />
<!-- Pulsing animation overlay -->
<View
android:id="@+id/pwa_icon_pulse"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/pulse_ripple"
android:alpha="0.0" />
</FrameLayout>
<!-- App Name -->
<TextView
android:id="@+id/pwa_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?android:attr/textColorPrimary"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:layout_marginBottom="8dp" />
<!-- Subtitle/Status -->
<TextView
android:id="@+id/pwa_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:text="@string/pwa_loading" />
<!-- Progress indicator at bottom -->
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/pwa_progress"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginTop="32dp"
android:indeterminate="true"
app:indicatorSize="32dp"
app:trackThickness="3dp" />
</LinearLayout>
</FrameLayout>
@@ -7,10 +7,4 @@
<item name="android:windowIsTranslucent">true</item> <item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item> <item name="android:windowAnimationStyle">@android:style/Animation</item>
</style> </style>
<!-- Shape style for rounded icon backgrounds (PWA loading) -->
<style name="RoundedIconShape">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">28%</item>
</style>
</resources> </resources>