improved loading time pwa/ct; removed splash screens

This commit is contained in:
Fabian Freund
2026-02-11 06:05:38 +01:00
parent 3e4a9cfc5f
commit 5cdeee6526
13 changed files with 206 additions and 442 deletions
@@ -20,10 +20,10 @@
package eu.weblibre.gecko
import android.content.Context
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterFragmentActivity() {
@@ -41,8 +41,21 @@ class MainActivity: FlutterFragmentActivity() {
channel.invokeMethod("onTrimMemory", level)
}
override fun getCachedEngineId(): String {
return "engine_id"
override fun provideFlutterEngine(context: Context): FlutterEngine {
val cache = FlutterEngineCache.getInstance()
val cachedEngine = cache.get("engine_id")
if (cachedEngine != null) {
return cachedEngine
}
val flutterEngine = FlutterEngine(context.applicationContext)
flutterEngine.navigationChannel.setInitialRoute("/")
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
cache.put("engine_id", flutterEngine)
return flutterEngine
}
override fun shouldDestroyEngineWithHost(): Boolean {
@@ -19,47 +19,10 @@
*/
package eu.weblibre.gecko
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
class MyApplication : Application() {
private fun getProcessNameCompat(): String {
// Try to get process name from ActivityManager
val pid = android.os.Process.myPid()
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
for (processInfo in manager.runningAppProcesses) {
if (processInfo.pid == pid) {
return processInfo.processName
}
}
// Fallback to package name if process name cannot be determined
return packageName
}
override fun onCreate() {
super.onCreate()
val processName = getProcessNameCompat()
// Only initialize Flutter engine in the main process
if (processName == packageName) {
// Initialize the Flutter engine
val flutterEngine = FlutterEngine(this)
// Pre-warm the Flutter engine with your initial route
flutterEngine.navigationChannel.setInitialRoute("/")
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
// Cache the Flutter engine
FlutterEngineCache.getInstance().put("engine_id", flutterEngine)
}
}
}
}
@@ -9,20 +9,26 @@ package eu.weblibre.flutter_mozilla_components
import android.content.Context
import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.concept.engine.selection.SelectionActionDelegate
import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider
import mozilla.components.support.base.facts.Facts
@@ -31,14 +37,21 @@ import mozilla.components.support.base.log.Log
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.base.log.sink.AndroidLogSink
import mozilla.components.support.webextensions.WebExtensionSupport
import java.io.File
import java.util.concurrent.TimeUnit
object GlobalComponents {
private var _components: Components? = null
private var currentMode: ComponentsMode? = null
val components: Components?
get() = _components
enum class ComponentsMode {
FULL,
EXTERNAL,
}
// Pull-to-refresh setting
var pullToRefreshEnabled: Boolean = true
set(value) {
@@ -103,10 +116,19 @@ object GlobalComponents {
extensionEvents: BrowserExtensionEvents,
logLevel: Log.Priority,
contentBlocking: ContentBlocking,
addonCollection: AddonCollection?
addonCollection: AddonCollection?,
mode: ComponentsMode = ComponentsMode.FULL,
) {
Logger.debug("Creating new components")
val previousComponents = _components
val previousMode = currentMode
val previousCustomTabs = if (previousMode == ComponentsMode.EXTERNAL) {
previousComponents?.core?.store?.state?.customTabs.orEmpty()
} else {
emptyList()
}
val newComponents = Components(
applicationContext,
flutterEvents,
@@ -120,6 +142,7 @@ object GlobalComponents {
extensionEvents,
)
_components = newComponents
currentMode = mode
//newComponents.crashReporter.install(applicationContext)
@@ -129,46 +152,123 @@ object GlobalComponents {
newComponents.core.engine.warmUp()
restoreBrowserState(newComponents)
restoreDownloads(newComponents)
try {
GlobalPlacesDependencyProvider.initialize(newComponents.core.historyStorage)
GlobalAddonDependencyProvider.initialize(
newComponents.core.addonManager,
newComponents.core.addonUpdater,
)
WebExtensionSupport.initialize(
newComponents.core.engine,
newComponents.core.store,
onNewTabOverride = { _, engineSession, url ->
newComponents.useCases.tabsUseCases.addTab(
url,
selectTab = true,
engineSession = engineSession
if (previousCustomTabs.isNotEmpty()) {
for (tab in previousCustomTabs) {
val existing = newComponents.core.store.state.findCustomTab(tab.id)
if (existing == null) {
newComponents.core.store.dispatch(
CustomTabListAction.AddCustomTabAction(tab)
)
},
onCloseTabOverride = { _, sessionId ->
newComponents.useCases.tabsUseCases.removeTab(sessionId)
},
onSelectTabOverride = { _, sessionId ->
newComponents.useCases.tabsUseCases.selectTab(sessionId)
},
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
onExtensionsLoaded = { extensions ->
newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
newComponents.core.supportedAddonsChecker.registerForChecks()
},
)
} catch (e: UnsupportedOperationException) {
// Web extension support is only available for engine gecko
Logger.error("Failed to initialize web extension support", e)
}
}
}
GlobalScope.launch(Dispatchers.IO) {
newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory()
if (mode == ComponentsMode.FULL) {
restoreBrowserState(newComponents)
restoreDownloads(newComponents)
try {
GlobalPlacesDependencyProvider.initialize(newComponents.core.historyStorage)
GlobalAddonDependencyProvider.initialize(
newComponents.core.addonManager,
newComponents.core.addonUpdater,
)
WebExtensionSupport.initialize(
newComponents.core.engine,
newComponents.core.store,
onNewTabOverride = { _, engineSession, url ->
newComponents.useCases.tabsUseCases.addTab(
url,
selectTab = true,
engineSession = engineSession
)
},
onCloseTabOverride = { _, sessionId ->
newComponents.useCases.tabsUseCases.removeTab(sessionId)
},
onSelectTabOverride = { _, sessionId ->
newComponents.useCases.tabsUseCases.selectTab(sessionId)
},
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
onExtensionsLoaded = { extensions ->
newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
newComponents.core.supportedAddonsChecker.registerForChecks()
},
)
} catch (e: UnsupportedOperationException) {
// Web extension support is only available for engine gecko
Logger.error("Failed to initialize web extension support", e)
}
GlobalScope.launch(Dispatchers.IO) {
newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory()
}
}
}
}
@Synchronized
fun ensureExternalComponents(
baseContext: Context,
logLevel: Log.Priority = Log.Priority.WARN,
): Boolean {
if (_components != null) {
return true
}
val profileFolder = resolveExternalProfileFolder(baseContext) ?: return false
val profileContext = ProfileContext(baseContext.applicationContext, profileFolder)
val messenger = NoopBinaryMessenger()
val selectionActionEvents = GeckoSelectionActionEvents(messenger)
val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions ->
val processTextAction = "android.intent.action.PROCESS_TEXT"
val withoutProcessText = actions.filter { it != processTextAction }.toTypedArray()
val processTextActions = actions.filter { it == processTextAction }.toTypedArray()
withoutProcessText + processTextActions
}
val contentBlocking = ContentBlocking(
queryParameterStripping = QueryParameterStripping.DISABLED,
queryParameterStrippingAllowList = "",
queryParameterStrippingStripList = "",
bounceTrackingProtectionMode = BounceTrackingProtectionMode.DISABLED,
)
setUp(
applicationContext = profileContext,
flutterEvents = GeckoStateEvents(messenger),
readerViewController = ReaderViewController(messenger),
selectionAction = selectionActionDelegate,
addonEvents = GeckoAddonEvents(messenger),
tabContentEvents = GeckoTabContentEvents(messenger),
extensionEvents = BrowserExtensionEvents(messenger),
logLevel = logLevel,
contentBlocking = contentBlocking,
addonCollection = null,
mode = ComponentsMode.EXTERNAL,
)
engineSettingsApi = GeckoEngineSettingsApiImpl()
return true
}
private fun resolveExternalProfileFolder(baseContext: Context): String? {
return try {
val profileFile = File(baseContext.filesDir, PwaConstants.CURRENT_PROFILE_FILE)
if (!profileFile.exists()) return null
val profileUuid = profileFile.readText().trim().ifEmpty { return null }
val relativePath =
"${PwaConstants.PROFILES_DIR_NAME}/${PwaConstants.PROFILE_DIR_PREFIX}$profileUuid"
val profileDir = File(baseContext.filesDir, relativePath)
if (!profileDir.exists()) return null
relativePath
} catch (e: Exception) {
Logger.error("Failed to resolve external profile folder", e)
null
}
}
}
@@ -0,0 +1,31 @@
/*
* 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 io.flutter.plugin.common.BinaryMessenger
import java.nio.ByteBuffer
class NoopBinaryMessenger : BinaryMessenger {
override fun send(channel: String, message: ByteBuffer?) {
// No-op
}
override fun send(
channel: String,
message: ByteBuffer?,
callback: BinaryMessenger.BinaryReply?
) {
callback?.reply(null)
}
override fun setMessageHandler(
channel: String,
handler: BinaryMessenger.BinaryMessageHandler?
) {
// No-op
}
}
@@ -7,17 +7,16 @@
package eu.weblibre.flutter_mozilla_components
object PwaConstants {
const val PROFILES_DIR_NAME = "weblibre_profiles"
const val PROFILE_DIR_PREFIX = "profile-"
// Intent extras keys for PWA metadata
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
@@ -1,96 +0,0 @@
/*
* 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")
}
}
@@ -9,14 +9,12 @@ package eu.weblibre.flutter_mozilla_components.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -38,7 +36,6 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
private val logger = Logger("ExternalAppBrowserActivity")
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var loadingScreenManager: LoadingScreenManager? = null
private val customTabSessionId: String?
get() = intent?.getStringExtra(EXTRA_CUSTOM_TAB_SESSION_ID)
@@ -61,8 +58,12 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
val components = GlobalComponents.components
if (components == null) {
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
showFragment(sessionId)
return
}
logger.debug("Components not yet initialized, waiting...")
showLoading()
waitForComponents(sessionId)
return
}
@@ -70,25 +71,6 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
showFragment(sessionId)
}
private fun showLoading() {
val container = findViewById<FrameLayout>(R.id.container)
loadingScreenManager = LoadingScreenManager.forActivity(this, container)
val url = webAppManifestUrl ?: ""
val shortcutId = intent?.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
if (url.isNotEmpty()) {
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 {
loadingScreenManager?.showLoadingForIntent(Intent())
}
}
private fun waitForComponents(sessionId: String) {
coroutineScope.launch {
var elapsedMs = 0L
@@ -132,7 +114,6 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.runOnCommit { loadingScreenManager?.hideLoading() }
.commit()
}
@@ -154,10 +135,6 @@ class ExternalAppBrowserActivity : AppCompatActivity() {
// Cancel any pending coroutines
coroutineScope.cancel()
// Clean up loading screen manager
loadingScreenManager?.cleanup()
loadingScreenManager = null
// Only clean up when the activity is actually finishing (user closed it),
// not when the system temporarily destroys it (e.g. switching to main app).
if (isFinishing) {
@@ -199,13 +176,11 @@ 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) }
}
}
}
@@ -10,10 +10,8 @@ import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -43,7 +41,6 @@ class IntentReceiverActivity : Activity() {
private val logger = Logger("IntentReceiverActivity")
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var pendingIntent: Intent? = null
private var loadingScreenManager: LoadingScreenManager? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -62,16 +59,17 @@ class IntentReceiverActivity : Activity() {
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
loadingScreenManager?.cleanup()
loadingScreenManager = null
}
private fun processIntent(intent: Intent) {
val components = GlobalComponents.components
if (components == null) {
if (GlobalComponents.components == null) {
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
routeIntent(intent)
return
}
logger.warn("Components not initialized, waiting for initialization...")
pendingIntent = intent
showLoadingIndicator(intent)
waitForComponentsWithTimeout()
return
}
@@ -246,7 +244,6 @@ class IntentReceiverActivity : Activity() {
context = this@IntentReceiverActivity,
customTabSessionId = sessionId,
webAppManifestUrl = url,
pwaShortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID),
)
startActivity(externalIntent)
finish()
@@ -291,26 +288,6 @@ class IntentReceiverActivity : Activity() {
return tab.id
}
/**
* Shows a branded loading screen immediately based on intent type.
* This avoids showing a minimal spinner and shows proper placeholders right away.
*/
private fun showLoadingIndicator(intent: Intent) {
// Create a container layout
val container = FrameLayout(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
setContentView(container)
// Initialize loading screen manager and show branded screen immediately
loadingScreenManager = LoadingScreenManager.forActivity(this, container)
loadingScreenManager?.showLoadingForIntent(intent)
}
/**
* Waits for GlobalComponents to be initialized with a timeout.
* Once components are ready, shows branded loading screen before routing.
@@ -17,7 +17,6 @@ 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
@@ -140,9 +139,6 @@ class GeckoPwaApiImpl(
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 {
@@ -150,7 +146,6 @@ class GeckoPwaApiImpl(
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 shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
@@ -13,9 +13,12 @@ import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
class CustomTabsService : AbstractCustomTabsService() {
private val components by lazy {
if (GlobalComponents.components == null) {
GlobalComponents.ensureExternalComponents(applicationContext)
}
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
override val customTabsServiceStore: CustomTabsServiceStore by lazy { components.core.customTabsStore }
override val engine: Engine by lazy { components.core.engine }
}
}
@@ -1,136 +0,0 @@
/*
* 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.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
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
/**
* 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 var currentLoadingView: View? = 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 {
fun forActivity(activity: Activity, container: FrameLayout) =
LoadingScreenManager(activity, container)
fun isPwaIntent(intent: Intent?) =
intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
fun hasCachedSplashData(intent: Intent?) =
intent?.hasExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID) == true
}
/**
* Shows the loading screen immediately based on intent analysis.
* Can be called before components are initialized.
*/
fun showLoadingForIntent(intent: Intent) {
cleanup()
val view = LayoutInflater.from(themedContext).inflate(
R.layout.loading_screen, container, false
)
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)
val shortcutId = intent.getStringExtra(PwaConstants.EXTRA_PWA_SHORTCUT_ID)
val manifest = shortcutId?.let { PwaSplashCache.loadManifest(activity, it) }
if (manifest != null) {
// PWA with cached data — branded splash
nameView.text = manifest.shortName ?: manifest.name ?: "Web App"
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
}
fun hideLoading(animate: Boolean = true) {
currentLoadingView?.let { view ->
if (animate) {
view.animate()
.alpha(0f)
.setDuration(200)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
cleanup()
}
})
.start()
} else {
cleanup()
}
}
}
fun cleanup() {
currentLoadingView?.let { container.removeView(it) }
currentLoadingView = null
}
private fun applyThemeColor(view: View, themeColor: Int, nameView: TextView, statusView: TextView) {
view.setBackgroundColor(themeColor)
val isDark = ColorUtils.calculateLuminance(themeColor) < 0.5
val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK
val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3)
nameView.setTextColor(primaryTextColor)
statusView.setTextColor(secondaryTextColor)
}
private fun extractDomain(url: String): String {
return try {
android.net.Uri.parse(url).host ?: url
} catch (_: Exception) {
url
}
}
}
@@ -1,56 +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/. -->
<!-- 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>
@@ -27,8 +27,4 @@
<string name="mozac_feature_customtabs_menu_button">Menu</string>
<string name="mozac_feature_customtabs_security_indicator">Security</string>
<!-- Loading screen strings -->
<string name="pwa_loading">Opening…</string>
<string name="pwa_icon_description">App icon</string>
<string name="website_icon_description">Website icon</string>
</resources>
</resources>