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_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
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
const val PWA_CACHE_DIR = "pwa_cache"
// Component initialization timeouts
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 androidx.appcompat.app.AppCompatActivity
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.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
@@ -25,7 +24,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.log.logger.Logger
@@ -76,19 +74,17 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
val container = findViewById<FrameLayout>(R.id.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 shortcutId = intent?.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
if (url.isNotEmpty()) {
// Try to show PWA placeholder
loadingScreenManager?.showLoadingForIntent(
Intent().apply {
data = android.net.Uri.parse(url)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder")
}
)
val loadingIntent = Intent().apply {
data = android.net.Uri.parse(url)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder")
shortcutId?.let { putExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID, it) }
}
loadingScreenManager?.showLoadingForIntent(loadingIntent)
} else {
// Show Custom Tab placeholder
loadingScreenManager?.showLoadingForIntent(Intent())
}
}
@@ -98,12 +94,7 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
var elapsedMs = 0L
while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) {
val components = GlobalComponents.components
if (components != null) {
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(components, sessionId)
// Brief delay to show the enhanced loading screen
delay(200)
if (GlobalComponents.components != null) {
showFragment(sessionId)
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) {
val components = GlobalComponents.components ?: run {
logger.error("Components still null after waiting, finishing.")
@@ -241,11 +199,13 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
context: Context,
customTabSessionId: String,
webAppManifestUrl: String? = null,
pwaShortcutId: String? = null,
): Intent {
return Intent(context, ExternalAppBrowserActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId)
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,
customTabSessionId = sessionId,
webAppManifestUrl = url,
pwaShortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID),
)
startActivity(externalIntent)
finish()
@@ -310,33 +311,6 @@ class IntentReceiverActivity : Activity() {
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.
* 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) {
if (GlobalComponents.components != null) {
logger.debug("Components initialized after ${elapsedMs}ms")
pendingIntent?.let { intent ->
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(intent)
// Small delay to show the enhanced loading screen (200ms)
delay(200)
routeIntent(intent)
}
pendingIntent?.let { routeIntent(it) }
pendingIntent = null
return@launch
}
@@ -17,6 +17,7 @@ import android.os.Build
import androidx.core.content.getSystemService
import eu.weblibre.flutter_mozilla_components.GlobalComponents
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.pigeons.ExternalApplicationResource
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
@@ -135,19 +136,26 @@ class GeckoPwaApiImpl(
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 {
action = Intent.ACTION_VIEW
data = Uri.parse(manifest.startUrl)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid)
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 {
setShortLabel(manifest.shortName ?: manifest.name ?: "Web App")
setLongLabel(manifest.name ?: manifest.shortName ?: "Web App")
setShortLabel(appName)
setLongLabel(manifest.name ?: appName)
setIntent(shortcutIntent)
if (iconBitmap != null) {
@@ -8,203 +8,90 @@ package eu.weblibre.flutter_mozilla_components.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.graphics.ColorUtils
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.PwaSplashCache
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.
* Handles loading of icons, theming, and animations.
* Displays a loading screen during PWA and Custom Tab initialization.
*
* - 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(
private val activity: Activity,
private val container: FrameLayout
) {
private val logger = Logger("LoadingScreenManager")
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 {
/**
* Creates a LoadingScreenManager for the given activity.
* 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 forActivity(activity: Activity, container: FrameLayout) =
LoadingScreenManager(activity, container)
/**
* Detects if an intent is for a PWA based on the extras.
*/
fun isPwaIntent(intent: Intent?): Boolean {
return intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
}
fun isPwaIntent(intent: Intent?) =
intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
/**
* Extracts URL from an intent.
*/
fun extractUrl(intent: Intent?): String? {
return intent?.dataString
}
fun hasCachedSplashData(intent: Intent?) =
intent?.hasExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID) == true
}
/**
* Shows the appropriate loading screen immediately based on intent analysis.
* This can be called before components are initialized.
*
* @param intent The intent to analyze for type detection
* Shows the loading screen immediately based on intent analysis.
* Can be called before components are initialized.
*/
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()
val view = LayoutInflater.from(activity).inflate(
R.layout.pwa_loading_screen,
container,
false
val view = LayoutInflater.from(themedContext).inflate(
R.layout.loading_screen, container, false
)
// Extract URL and use domain as temporary name
val url = intent.dataString
val domain = url?.let { extractDomain(it) } ?: "Web App"
val iconView = view.findViewById<ImageView>(R.id.loading_icon)
val nameView = view.findViewById<TextView>(R.id.loading_name)
val statusView = view.findViewById<TextView>(R.id.loading_status)
// Set temporary app name (will be replaced with actual name once manifest loads)
val nameView = view.findViewById<TextView>(R.id.pwa_name)
nameView.text = domain
val shortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
val manifest = shortcutId?.let { PwaSplashCache.loadManifest(activity, it) }
// Show WebLibre logo as placeholder (already set in XML layout)
val iconView = view.findViewById<ImageView>(R.id.pwa_icon)
iconView.alpha = 0.5f
if (manifest != null) {
// PWA with cached data — branded splash
nameView.text = manifest.shortName ?: manifest.name ?: "Web App"
// Start pulsing animation
startPulseAnimation(view)
val cachedIcon = PwaSplashCache.loadIcon(activity, shortcutId)
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)
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) {
currentLoadingView?.let { view ->
if (animate) {
@@ -223,74 +110,26 @@ class LoadingScreenManager private constructor(
}
}
/**
* Cleans up the loading view and animations.
*/
fun cleanup() {
pulseAnimator?.cancel()
pulseAnimator = null
currentLoadingView?.let { view ->
container.removeView(view)
}
currentLoadingView?.let { container.removeView(it) }
currentLoadingView = null
}
private fun startPulseAnimation(view: View) {
val pulseView = view.findViewById<View>(R.id.pwa_icon_pulse)
?: return
private fun applyThemeColor(view: View, themeColor: Int, nameView: TextView, statusView: TextView) {
view.setBackgroundColor(themeColor)
pulseView.alpha = 0.0f
pulseAnimator = ObjectAnimator.ofFloat(pulseView, "alpha", 0.0f, 0.3f, 0.0f).apply {
duration = 1500
repeatCount = ObjectAnimator.INFINITE
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
val isDark = ColorUtils.calculateLuminance(themeColor) < 0.5
val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK
val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3)
private suspend fun loadPwaIcon(
manifest: WebAppManifest,
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
}
nameView.setTextColor(primaryTextColor)
statusView.setTextColor(secondaryTextColor)
}
private fun extractDomain(url: String): String {
return try {
val uri = android.net.Uri.parse(url)
uri.host ?: url
} catch (e: Exception) {
android.net.Uri.parse(url).host ?: url
} catch (_: Exception) {
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:windowAnimationStyle">@android:style/Animation</item>
</style>
<!-- Shape style for rounded icon backgrounds (PWA loading) -->
<style name="RoundedIconShape">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">28%</item>
</style>
</resources>