initial pwa + custom tabs
This commit is contained in:
@@ -127,6 +127,8 @@ dependencies {
|
||||
implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:feature-webauthn:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:feature-pwa:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:feature-intent:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:ui-widgets:$mozillaComponentsVersion"
|
||||
implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion"
|
||||
|
||||
@@ -134,6 +136,8 @@ dependencies {
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
implementation 'com.mikepenz:iconics-core:5.4.0'
|
||||
implementation 'com.mikepenz:community-material-typeface:7.0.96.1-kotlin@aar'
|
||||
//https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo
|
||||
implementation 'androidx.activity:activity-ktx:1.12.3'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:3.4.0'
|
||||
|
||||
+30
-8
@@ -73,7 +73,7 @@ import mozilla.components.support.webextensions.WebExtensionPopupObserver
|
||||
*/
|
||||
@SuppressWarnings("LargeClass")
|
||||
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler {
|
||||
private val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
|
||||
protected val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
|
||||
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
||||
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
||||
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
|
||||
@@ -136,6 +136,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
|
||||
protected abstract fun createEngine(components: Components): EngineView
|
||||
|
||||
// Track this fragment's EngineView instance to reassign singleton when fragment becomes active
|
||||
private var fragmentEngineView: EngineView? = null
|
||||
|
||||
private lateinit var requestDownloadPermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||
private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||
private lateinit var requestPromptsPermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||
@@ -219,6 +222,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
ProfileContext(requireContext(), components.profileApplicationContext.relativePath)
|
||||
|
||||
val engineView = createEngine(components)
|
||||
fragmentEngineView = engineView // Track for lifecycle management
|
||||
val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity())
|
||||
?.let { ProfileContext(it, components.profileApplicationContext.relativePath) }
|
||||
val engineNativeView = engineView.asView()
|
||||
@@ -228,17 +232,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
|
||||
binding.swipeToRefresh.addView(engineNativeView)
|
||||
|
||||
components.engineView = engineView
|
||||
|
||||
// Apply any pending viewport settings that were set before engineView was ready
|
||||
GlobalComponents.viewportApi?.applyPendingSettings()
|
||||
components.activeEngineView = engineView
|
||||
|
||||
sessionFeature.set(
|
||||
feature = SessionFeature(
|
||||
components.core.store,
|
||||
components.useCases.sessionUseCases.goBack,
|
||||
components.useCases.sessionUseCases.goForward,
|
||||
components.engineView!!,
|
||||
engineView,
|
||||
sessionId,
|
||||
),
|
||||
owner = this,
|
||||
@@ -468,7 +469,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
thumbnailsFeature.set(
|
||||
feature = BrowserThumbnails(
|
||||
profileContext,
|
||||
components.engineView!!,
|
||||
engineView,
|
||||
components.core.store
|
||||
),
|
||||
owner = this,
|
||||
@@ -496,12 +497,20 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
}
|
||||
}
|
||||
|
||||
onEngineSetupComplete()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("EngineCreation", "Failed to create engine: ${e.message}", e)
|
||||
context?.let { restartApp(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the engine view is fully set up and added to the view hierarchy.
|
||||
* Subclasses can override to perform additional setup that requires an attached engine view.
|
||||
*/
|
||||
protected open fun onEngineSetupComplete() {}
|
||||
|
||||
private fun openPopup(webExtensionState: WebExtensionState) {
|
||||
val store = components.core.store
|
||||
val popupSession = store.state.extensions[webExtensionState.id]?.popupSession ?: return
|
||||
@@ -587,6 +596,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reassign active engine view to this fragment's EngineView when fragment becomes active
|
||||
fragmentEngineView?.let {
|
||||
components.activeEngineView = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
@@ -595,7 +612,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
keyboardVisibilityFeature = null
|
||||
|
||||
GlobalComponents.onPullToRefreshEnabledChanged = null
|
||||
components.engineView?.setActivityContext(null)
|
||||
val engineView = fragmentEngineView
|
||||
engineView?.setActivityContext(null)
|
||||
if (components.activeEngineView == engineView) {
|
||||
components.activeEngineView = null
|
||||
}
|
||||
_binding = null
|
||||
fragmentEngineView = null
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -21,6 +21,8 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler {
|
||||
//We cannot introduce here our wrapped context since a activity type is required to make features work correctly like context menu
|
||||
return components.core.engine.createView(requireContext()).apply {
|
||||
selectionActionDelegate = components.selectionAction
|
||||
}.also { engineView ->
|
||||
components.mainBrowserEngineView = engineView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +37,18 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onEngineSetupComplete() {
|
||||
GlobalComponents.viewportApi?.applyPendingToolbarHeight()
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean =
|
||||
super.readerViewFeature.onBackPressed() || super.onBackPressed()
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
components.mainBrowserEngineView = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(sessionId: String? = null) = BrowserFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
|
||||
+6
-2
@@ -44,12 +44,16 @@ class Components(val profileApplicationContext: ProfileContext,
|
||||
) {
|
||||
val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) }
|
||||
val events by lazy { Events(flutterEvents) }
|
||||
val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store) }
|
||||
val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store, core.webAppShortcutManager) }
|
||||
val services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) }
|
||||
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
|
||||
val search by lazy { Search(profileApplicationContext, core, useCases) }
|
||||
|
||||
var engineView: EngineView? = null
|
||||
var mainBrowserEngineView: EngineView? = null
|
||||
var externalAppEngineView: EngineView? = null
|
||||
|
||||
var activeEngineView: EngineView? = null
|
||||
|
||||
var engineReportedInitialized = false
|
||||
|
||||
private val notificationManagerCompat = NotificationManagerCompat.from(profileApplicationContext)
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* 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.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupWindow
|
||||
import androidx.annotation.CallSuper
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.IIcon
|
||||
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbar
|
||||
import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbarFeature
|
||||
import mozilla.components.browser.state.selector.findCustomTab
|
||||
import mozilla.components.browser.state.state.ExternalAppType
|
||||
import mozilla.components.concept.engine.EngineView
|
||||
import mozilla.components.feature.customtabs.CustomTabWindowFeature
|
||||
import mozilla.components.feature.pwa.feature.ManifestUpdateFeature
|
||||
import mozilla.components.feature.pwa.feature.WebAppActivityFeature
|
||||
import mozilla.components.feature.pwa.feature.WebAppContentFeature
|
||||
import mozilla.components.feature.pwa.feature.WebAppHideToolbarFeature
|
||||
import mozilla.components.feature.pwa.feature.WebAppSiteControlsFeature
|
||||
import mozilla.components.support.base.feature.UserInteractionHandler
|
||||
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import mozilla.components.support.ktx.android.arch.lifecycle.addObservers
|
||||
|
||||
/**
|
||||
* Fragment used for browsing the web within external apps (Custom Tabs and PWAs).
|
||||
* Extends [BaseBrowserFragment] with Custom Tab toolbar features and PWA support.
|
||||
*/
|
||||
class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
|
||||
|
||||
private val customTabsToolbarFeature = ViewBoundFeatureWrapper<CustomTabToolbarFeature>()
|
||||
private val hideToolbarFeature = ViewBoundFeatureWrapper<WebAppHideToolbarFeature>()
|
||||
private val windowFeature = ViewBoundFeatureWrapper<CustomTabWindowFeature>()
|
||||
|
||||
private var customTabToolbar: CustomTabToolbar? = null
|
||||
private var activePopup: PopupWindow? = null
|
||||
|
||||
private val customTabSessionId: String?
|
||||
get() = arguments?.getString(CUSTOM_TAB_SESSION_ID_KEY)
|
||||
|
||||
private val webAppManifestUrl: String?
|
||||
get() = arguments?.getString(WEB_APP_MANIFEST_URL_KEY)
|
||||
|
||||
override fun createEngine(components: Components): EngineView {
|
||||
return components.core.engine.createView(requireContext()).apply {
|
||||
selectionActionDelegate = components.selectionAction
|
||||
}.also { engineView ->
|
||||
components.externalAppEngineView = engineView
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val sessionId = customTabSessionId ?: return
|
||||
|
||||
view.post {
|
||||
if (GlobalComponents.components == null) return@post
|
||||
initializeCustomTabFeatures(view, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEngineSetupComplete() {
|
||||
val sessionId = customTabSessionId ?: return
|
||||
val store = components.core.store
|
||||
val customTab = store.state.findCustomTab(sessionId) ?: return
|
||||
|
||||
val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP ||
|
||||
customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY
|
||||
|
||||
if (!isPwaOrTwa) {
|
||||
setupCustomTabToolbar(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupCustomTabToolbar(sessionId: String) {
|
||||
val view = requireView()
|
||||
|
||||
val toolbar = CustomTabToolbar(requireContext()).apply {
|
||||
layoutParams = AppBarLayout.LayoutParams(
|
||||
AppBarLayout.LayoutParams.MATCH_PARENT,
|
||||
AppBarLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
customTabToolbar = toolbar
|
||||
|
||||
binding.customTabAppBar.apply {
|
||||
removeAllViews()
|
||||
addView(toolbar)
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
toolbar.onCloseListener = {
|
||||
requireActivity().finishAndRemoveTask()
|
||||
}
|
||||
toolbar.onShareListener = {
|
||||
shareCurrentUrl(sessionId)
|
||||
}
|
||||
toolbar.onOpenInBrowserListener = {
|
||||
openInBrowser(sessionId)
|
||||
}
|
||||
toolbar.onMenuListener = {
|
||||
showCustomTabMenu(sessionId)
|
||||
}
|
||||
|
||||
customTabsToolbarFeature.set(
|
||||
feature = CustomTabToolbarFeature(
|
||||
store = components.core.store,
|
||||
toolbar = toolbar,
|
||||
sessionId = sessionId,
|
||||
window = requireActivity().window
|
||||
),
|
||||
owner = this,
|
||||
view = view
|
||||
)
|
||||
}
|
||||
|
||||
private fun showCustomTabMenu(sessionId: String) {
|
||||
val toolbar = customTabToolbar ?: return
|
||||
val store = components.core.store
|
||||
val customTab = store.state.findCustomTab(sessionId) ?: return
|
||||
val anchorView = toolbar.getMenuButton()
|
||||
|
||||
val menuView = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.custom_tab_menu, null)
|
||||
|
||||
val popup = PopupWindow(
|
||||
menuView,
|
||||
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
true
|
||||
).apply {
|
||||
elevation = 8f
|
||||
isOutsideTouchable = true
|
||||
}
|
||||
activePopup = popup
|
||||
|
||||
val iconColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurface)
|
||||
val disabledColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
|
||||
// Navigation row icons
|
||||
val canGoBack = customTab.content.canGoBack
|
||||
val canGoForward = customTab.content.canGoForward
|
||||
|
||||
val backBtn = menuView.findViewById<ImageButton>(R.id.menuBack)
|
||||
val forwardBtn = menuView.findViewById<ImageButton>(R.id.menuForward)
|
||||
|
||||
backBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_left, 20, if (canGoBack) iconColor else disabledColor))
|
||||
backBtn.isEnabled = canGoBack
|
||||
backBtn.alpha = if (canGoBack) 1.0f else 0.38f
|
||||
backBtn.setOnClickListener {
|
||||
components.useCases.sessionUseCases.goBack(sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
forwardBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_right, 20, if (canGoForward) iconColor else disabledColor))
|
||||
forwardBtn.isEnabled = canGoForward
|
||||
forwardBtn.alpha = if (canGoForward) 1.0f else 0.38f
|
||||
forwardBtn.setOnClickListener {
|
||||
components.useCases.sessionUseCases.goForward(sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
// Menu item icons
|
||||
menuView.findViewById<ImageView>(R.id.menuRefreshIcon)
|
||||
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_refresh, 20, iconColor))
|
||||
menuView.findViewById<ImageView>(R.id.menuShareIcon)
|
||||
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 20, iconColor))
|
||||
menuView.findViewById<ImageView>(R.id.menuDesktopIcon)
|
||||
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_monitor, 20, iconColor))
|
||||
menuView.findViewById<ImageView>(R.id.menuOpenInBrowserIcon)
|
||||
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 20, iconColor))
|
||||
|
||||
// Refresh
|
||||
menuView.findViewById<View>(R.id.menuRefresh).setOnClickListener {
|
||||
components.useCases.sessionUseCases.reload(sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
// Share
|
||||
menuView.findViewById<View>(R.id.menuShare).setOnClickListener {
|
||||
shareCurrentUrl(sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
// Desktop site toggle
|
||||
val isDesktop = customTab.content.desktopMode
|
||||
val desktopSwitch = menuView.findViewById<MaterialSwitch>(R.id.menuDesktopSwitch)
|
||||
desktopSwitch.isChecked = isDesktop
|
||||
val desktopRow = menuView.findViewById<View>(R.id.menuDesktopSite)
|
||||
desktopRow.setOnClickListener {
|
||||
val newState = !desktopSwitch.isChecked
|
||||
components.useCases.sessionUseCases.requestDesktopSite(newState, sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
desktopSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
components.useCases.sessionUseCases.requestDesktopSite(isChecked, sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
// Open in browser
|
||||
menuView.findViewById<View>(R.id.menuOpenInBrowser).setOnClickListener {
|
||||
openInBrowser(sessionId)
|
||||
popup.dismiss()
|
||||
}
|
||||
|
||||
popup.showAsDropDown(anchorView, 0, 0, Gravity.END)
|
||||
}
|
||||
|
||||
private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable {
|
||||
return IconicsDrawable(requireContext(), icon).apply {
|
||||
this.sizeDp = sizeDp
|
||||
this.colorInt = color
|
||||
}
|
||||
}
|
||||
|
||||
private fun openInBrowser(sessionId: String) {
|
||||
val activity = requireActivity()
|
||||
|
||||
sessionFeature?.get()?.release()
|
||||
components.useCases.customTabsUseCases.migrate(sessionId, select = true)
|
||||
|
||||
val mainIntent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
|
||||
mainIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
mainIntent?.let { activity.startActivity(it) }
|
||||
|
||||
activity.finishAndRemoveTask()
|
||||
}
|
||||
|
||||
private fun shareCurrentUrl(sessionId: String) {
|
||||
val store = components.core.store
|
||||
store.state.findCustomTab(sessionId)?.let { tab ->
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, tab.content.url)
|
||||
putExtra(Intent.EXTRA_SUBJECT, tab.content.title)
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeCustomTabFeatures(view: View, sessionId: String) {
|
||||
val activity = requireActivity()
|
||||
val store = components.core.store
|
||||
val customTab = store.state.findCustomTab(sessionId) ?: return
|
||||
|
||||
components.activeEngineView?.setDynamicToolbarMaxHeight(0)
|
||||
|
||||
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
|
||||
components.core.webAppManifestStorage.getManifestCache(url)
|
||||
}
|
||||
|
||||
windowFeature.set(
|
||||
feature = CustomTabWindowFeature(activity, store, sessionId),
|
||||
owner = this,
|
||||
view = view,
|
||||
)
|
||||
|
||||
val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP ||
|
||||
customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY
|
||||
|
||||
if (isPwaOrTwa) {
|
||||
hideToolbarFeature.set(
|
||||
feature = WebAppHideToolbarFeature(
|
||||
store = store,
|
||||
customTabsStore = components.core.customTabsStore,
|
||||
tabId = sessionId,
|
||||
manifest = manifest,
|
||||
) { toolbarVisible ->
|
||||
Logger.debug("Custom tab toolbar visibility: $toolbarVisible")
|
||||
},
|
||||
owner = this,
|
||||
view = view,
|
||||
)
|
||||
}
|
||||
|
||||
if (manifest != null) {
|
||||
activity.lifecycle.addObservers(
|
||||
WebAppActivityFeature(
|
||||
activity,
|
||||
components.core.icons,
|
||||
manifest,
|
||||
),
|
||||
WebAppContentFeature(
|
||||
store = store,
|
||||
tabId = sessionId,
|
||||
manifest,
|
||||
),
|
||||
ManifestUpdateFeature(
|
||||
activity.applicationContext,
|
||||
store,
|
||||
components.core.webAppShortcutManager,
|
||||
components.core.webAppManifestStorage,
|
||||
sessionId,
|
||||
manifest,
|
||||
),
|
||||
)
|
||||
viewLifecycleOwner.lifecycle.addObserver(
|
||||
WebAppSiteControlsFeature(
|
||||
activity.applicationContext,
|
||||
store,
|
||||
components.useCases.sessionUseCases.reload,
|
||||
sessionId,
|
||||
manifest,
|
||||
notificationsDelegate = components.notificationsDelegate,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@CallSuper
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super<BaseBrowserFragment>.onActivityResult(requestCode, data, resultCode)
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
val sessionId = customTabSessionId ?: return super.onBackPressed()
|
||||
|
||||
val tab = components.core.store.state.findCustomTab(sessionId)
|
||||
if (tab?.content?.canGoBack == true) {
|
||||
components.useCases.sessionUseCases.goBack(sessionId)
|
||||
return true
|
||||
}
|
||||
|
||||
requireActivity().finishAndRemoveTask()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
activePopup?.dismiss()
|
||||
activePopup = null
|
||||
customTabToolbar = null
|
||||
components.externalAppEngineView = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CUSTOM_TAB_SESSION_ID_KEY = "custom_tab_session_id"
|
||||
private const val WEB_APP_MANIFEST_URL_KEY = "web_app_manifest_url"
|
||||
|
||||
fun create(
|
||||
customTabSessionId: String,
|
||||
webAppManifestUrl: String? = null,
|
||||
) = ExternalAppBrowserFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putSessionId(customTabSessionId)
|
||||
putString(CUSTOM_TAB_SESSION_ID_KEY, customTabSessionId)
|
||||
putString(WEB_APP_MANIFEST_URL_KEY, webAppManifestUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -2,6 +2,7 @@ package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
@@ -13,6 +14,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
|
||||
private val subfolderRoot =
|
||||
File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default
|
||||
|
||||
private val profilePrefix = File(relativePath).name
|
||||
|
||||
private var customFilesDir: File = File(subfolderRoot, "files")
|
||||
private var customNoBackupFilesDir: File = File(subfolderRoot, "no_backup")
|
||||
private var customObbDir: File = File(subfolderRoot, "obb")
|
||||
@@ -124,4 +127,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
|
||||
parentFile?.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
|
||||
return base.getSharedPreferences("${profilePrefix}_$name", mode)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
object PwaConstants {
|
||||
// Intent extras keys for PWA metadata
|
||||
const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid"
|
||||
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
|
||||
|
||||
// Profile and file paths
|
||||
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
||||
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
|
||||
|
||||
// Component initialization timeouts
|
||||
const val COMPONENT_INIT_TIMEOUT_MS = 10000L
|
||||
const val COMPONENT_INIT_CHECK_INTERVAL_MS = 100L
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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.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.Components
|
||||
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
|
||||
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
|
||||
|
||||
/**
|
||||
* Native activity that hosts [ExternalAppBrowserFragment] for Custom Tab and PWA sessions.
|
||||
* This is a non-Flutter activity — it renders GeckoView directly in a native layout.
|
||||
*
|
||||
* Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app.
|
||||
*/
|
||||
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)
|
||||
|
||||
private val webAppManifestUrl: String?
|
||||
get() = intent?.getStringExtra(EXTRA_WEB_APP_MANIFEST_URL)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val sessionId = customTabSessionId
|
||||
if (sessionId == null) {
|
||||
logger.error("No custom tab session ID provided, finishing.")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContentView(R.layout.activity_external_app_browser)
|
||||
|
||||
val components = GlobalComponents.components
|
||||
if (components == null) {
|
||||
logger.debug("Components not yet initialized, waiting...")
|
||||
showLoading()
|
||||
waitForComponents(sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
showFragment(sessionId)
|
||||
}
|
||||
|
||||
private fun showLoading() {
|
||||
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 ?: ""
|
||||
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")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Show Custom Tab placeholder
|
||||
loadingScreenManager?.showLoadingForIntent(Intent())
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForComponents(sessionId: String) {
|
||||
coroutineScope.launch {
|
||||
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)
|
||||
showFragment(sessionId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS)
|
||||
elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS
|
||||
}
|
||||
|
||||
// Timeout reached
|
||||
if (isActive) {
|
||||
logger.error("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if (components.core.store.state.findCustomTab(sessionId) == null) {
|
||||
logger.error("Custom tab session $sessionId not found in store, finishing.")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val fragment = ExternalAppBrowserFragment.create(
|
||||
customTabSessionId = sessionId,
|
||||
webAppManifestUrl = webAppManifestUrl,
|
||||
)
|
||||
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.runOnCommit { loadingScreenManager?.hideLoading() }
|
||||
.commit()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// If the session was removed while we were in the background, finish
|
||||
val sessionId = customTabSessionId ?: return
|
||||
val components = GlobalComponents.components ?: return
|
||||
if (components.core.store.state.findCustomTab(sessionId) == null) {
|
||||
logger.debug("Custom tab session $sessionId gone, finishing activity.")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
// 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) {
|
||||
val sessionId = customTabSessionId
|
||||
if (sessionId != null) {
|
||||
val components = GlobalComponents.components
|
||||
if (components != null) {
|
||||
val customTab = components.core.store.state.findCustomTab(sessionId)
|
||||
if (customTab != null) {
|
||||
components.useCases.customTabsUseCases.remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.container)
|
||||
if (fragment is UserInteractionHandler && fragment.onBackPressed()) {
|
||||
return
|
||||
}
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.container)
|
||||
if (fragment is ExternalAppBrowserFragment) {
|
||||
fragment.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id"
|
||||
const val EXTRA_WEB_APP_MANIFEST_URL = "web_app_manifest_url"
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
customTabSessionId: String,
|
||||
webAppManifestUrl: 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* 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.activities
|
||||
|
||||
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
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.feature.customtabs.CustomTabIntentProcessor
|
||||
import mozilla.components.feature.intent.ext.getSessionId
|
||||
import mozilla.components.feature.pwa.intent.WebAppIntentProcessor
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Lightweight transparent activity that receives all ACTION_VIEW intents and routes them
|
||||
* to the appropriate activity:
|
||||
* - Custom Tab intents → [ExternalAppBrowserActivity]
|
||||
* - PWA launch intents → [ExternalAppBrowserActivity] (with profile/context tracking)
|
||||
* - Regular VIEW intents → MainActivity (Flutter)
|
||||
*
|
||||
* For PWA intents created by our custom installer, checks profile match and shows dialog
|
||||
* if the current profile differs from the installation profile.
|
||||
*/
|
||||
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)
|
||||
|
||||
val intent = intent?.let { Intent(it) } ?: Intent()
|
||||
|
||||
logger.debug("onCreate: action=${intent.action} data=${intent.dataString}")
|
||||
|
||||
// Strip flags that could interfere with task management
|
||||
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
|
||||
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
|
||||
|
||||
processIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineScope.cancel()
|
||||
loadingScreenManager?.cleanup()
|
||||
loadingScreenManager = null
|
||||
}
|
||||
|
||||
private fun processIntent(intent: Intent) {
|
||||
val components = GlobalComponents.components
|
||||
if (components == null) {
|
||||
logger.warn("Components not initialized, waiting for initialization...")
|
||||
pendingIntent = intent
|
||||
showLoadingIndicator(intent)
|
||||
waitForComponentsWithTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
routeIntent(intent)
|
||||
}
|
||||
|
||||
private fun routeIntent(intent: Intent) {
|
||||
// Check if this is our custom PWA intent with profile metadata
|
||||
val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
|
||||
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID)
|
||||
if (profileUuid != null) {
|
||||
logger.debug("PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId")
|
||||
handlePwaIntent(intent, profileUuid, contextId)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to standard intent processors for Custom Tabs and legacy PWAs
|
||||
val components = GlobalComponents.components
|
||||
?: run {
|
||||
logger.error("Components became null during routing")
|
||||
handleRegularIntent(intent)
|
||||
return
|
||||
}
|
||||
|
||||
val processors = listOf(
|
||||
"CustomTab" to CustomTabIntentProcessor(
|
||||
components.useCases.customTabsUseCases.add,
|
||||
resources,
|
||||
isPrivate = false,
|
||||
),
|
||||
"PWA" to WebAppIntentProcessor(
|
||||
components.core.store,
|
||||
components.useCases.customTabsUseCases.addWebApp,
|
||||
components.useCases.sessionUseCases.loadUrl,
|
||||
components.core.webAppManifestStorage,
|
||||
),
|
||||
)
|
||||
|
||||
for ((name, processor) in processors) {
|
||||
logger.debug("Trying $name processor...")
|
||||
try {
|
||||
val result = processor.process(intent)
|
||||
logger.debug("$name processor result: $result")
|
||||
if (result) {
|
||||
val sessionId = intent.getSessionId()
|
||||
logger.debug("$name session ID from intent: $sessionId")
|
||||
if (sessionId != null) {
|
||||
val externalIntent = ExternalAppBrowserActivity.createIntent(
|
||||
context = this,
|
||||
customTabSessionId = sessionId,
|
||||
webAppManifestUrl = if (name == "PWA") intent.dataString else null,
|
||||
)
|
||||
startActivity(externalIntent)
|
||||
finish()
|
||||
return
|
||||
} else {
|
||||
logger.warn("$name processor succeeded but no session ID in intent!")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error in $name processor", e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("No processor matched, routing to MainActivity")
|
||||
handleRegularIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles PWA intents with profile and context metadata.
|
||||
* Checks if current profile matches and shows dialog if different.
|
||||
*/
|
||||
private fun handlePwaIntent(
|
||||
intent: Intent,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
) {
|
||||
val url = intent.dataString
|
||||
if (url == null) {
|
||||
logger.error("PWA intent has no URL")
|
||||
handleRegularIntent(intent)
|
||||
return
|
||||
}
|
||||
|
||||
val currentProfileUuid = getCurrentProfileUuid()
|
||||
|
||||
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
|
||||
logger.debug("Profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
|
||||
showProfileMismatchDialog(url, contextId)
|
||||
} else {
|
||||
logger.debug("Profile match or indeterminate, launching PWA with contextId=$contextId")
|
||||
launchPwaWithContext(url, contextId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current profile UUID from the filesystem.
|
||||
* The Flutter side persists this as a plain text file at:
|
||||
* <filesDir>/weblibre_profiles/current_profile
|
||||
*/
|
||||
private fun getCurrentProfileUuid(): String? {
|
||||
return try {
|
||||
val startupProfileFile = File(filesDir, PwaConstants.CURRENT_PROFILE_FILE)
|
||||
if (startupProfileFile.exists()) {
|
||||
startupProfileFile.readText().trim().ifEmpty { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to read current profile UUID", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog when the current profile doesn't match the PWA's installation profile.
|
||||
*/
|
||||
private fun showProfileMismatchDialog(
|
||||
url: String,
|
||||
contextId: String?,
|
||||
) {
|
||||
val message = "This PWA was originally installed in a different profile. " +
|
||||
"Opening it here will use your current profile's data and settings, " +
|
||||
"which means you won't see the same content, preferences, or saved data " +
|
||||
"that you had in the original profile.\n\n" +
|
||||
"Do you want to proceed anyway?"
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("PWA Profile Mismatch")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("Open Anyway") { _, _ ->
|
||||
logger.debug("User chose to open PWA despite profile mismatch")
|
||||
launchPwaWithContext(url, contextId)
|
||||
}
|
||||
.setNegativeButton("Cancel") { _, _ ->
|
||||
logger.debug("User cancelled PWA launch due to profile mismatch")
|
||||
finish()
|
||||
}
|
||||
.setOnCancelListener {
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the PWA with the specified context ID for storage isolation.
|
||||
*/
|
||||
private fun launchPwaWithContext(url: String, contextId: String?) {
|
||||
val components = GlobalComponents.components
|
||||
?: run {
|
||||
logger.error("Components not available for PWA launch")
|
||||
handleRegularIntent(intent)
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val manifest = withContext(Dispatchers.IO) {
|
||||
components.core.webAppManifestStorage.loadManifest(url)
|
||||
}
|
||||
|
||||
val sessionId = createPwaSession(
|
||||
url = url,
|
||||
contextId = contextId,
|
||||
manifest = manifest
|
||||
)
|
||||
|
||||
logger.debug("Created PWA session: contextId=$contextId, sessionId=$sessionId")
|
||||
|
||||
val externalIntent = ExternalAppBrowserActivity.createIntent(
|
||||
context = this@IntentReceiverActivity,
|
||||
customTabSessionId = sessionId,
|
||||
webAppManifestUrl = url,
|
||||
)
|
||||
startActivity(externalIntent)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to launch PWA with context", e)
|
||||
handleRegularIntent(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a custom tab session for a PWA with the specified context ID.
|
||||
*/
|
||||
private fun createPwaSession(
|
||||
url: String,
|
||||
contextId: String?,
|
||||
manifest: mozilla.components.concept.engine.manifest.WebAppManifest?
|
||||
): String {
|
||||
val components = GlobalComponents.components
|
||||
?: throw IllegalStateException("Components not initialized")
|
||||
|
||||
val customTabConfig = mozilla.components.browser.state.state.CustomTabConfig(
|
||||
externalAppType = mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP
|
||||
)
|
||||
|
||||
val tab = mozilla.components.browser.state.state.createCustomTab(
|
||||
url = url,
|
||||
contextId = contextId,
|
||||
config = customTabConfig,
|
||||
webAppManifest = manifest,
|
||||
source = mozilla.components.browser.state.state.SessionState.Source.Internal.CustomTab,
|
||||
private = false
|
||||
)
|
||||
|
||||
components.core.store.dispatch(
|
||||
mozilla.components.browser.state.action.CustomTabListAction.AddCustomTabAction(tab)
|
||||
)
|
||||
|
||||
val loadUrlFlags = mozilla.components.concept.engine.EngineSession.LoadUrlFlags.external()
|
||||
components.useCases.sessionUseCases.loadUrl(url, tab.id, loadUrlFlags)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Falls back to MainActivity if timeout is reached (10 seconds).
|
||||
*/
|
||||
private fun waitForComponentsWithTimeout() {
|
||||
coroutineScope.launch {
|
||||
var elapsedMs = 0L
|
||||
|
||||
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 = null
|
||||
return@launch
|
||||
}
|
||||
|
||||
delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS)
|
||||
elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
logger.warn("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms, falling back to MainActivity")
|
||||
pendingIntent?.let { intent ->
|
||||
handleRegularIntent(intent)
|
||||
}
|
||||
pendingIntent = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRegularIntent(intent: Intent) {
|
||||
val mainActivityIntent = Intent(intent).apply {
|
||||
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(mainActivityIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
+4
@@ -40,6 +40,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
||||
@@ -275,6 +276,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl())
|
||||
GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext))
|
||||
|
||||
// PWA API for web app installation and management
|
||||
GeckoPwaApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPwaApiImpl(profileApplicationContext))
|
||||
|
||||
// Viewport API for dynamic toolbar and keyboard handling
|
||||
val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger)
|
||||
val viewportApi = GeckoViewportApiImpl()
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* 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.api
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
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.activities.IntentReceiverActivity
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.browser.icons.IconRequest
|
||||
import mozilla.components.browser.state.selector.findTab
|
||||
import mozilla.components.browser.state.selector.selectedTab
|
||||
import mozilla.components.concept.engine.manifest.WebAppManifest
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Implementation of GeckoPwaApi that provides PWA install and query functionality.
|
||||
*
|
||||
* Creates custom shortcuts with profile and container metadata embedded in intent extras,
|
||||
* ensuring PWAs reopen with the same profile and container context.
|
||||
*/
|
||||
class GeckoPwaApiImpl(
|
||||
private val context: Context
|
||||
) : GeckoPwaApi {
|
||||
companion object {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
}
|
||||
|
||||
private val logger = Logger("GeckoPwaApiImpl")
|
||||
|
||||
private val components by lazy {
|
||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||
}
|
||||
|
||||
override fun installWebApp(
|
||||
tabId: String?,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
callback: (Result<Boolean>) -> Unit
|
||||
) {
|
||||
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val store = components.core.store
|
||||
val tab = if (tabId != null) {
|
||||
store.state.findTab(tabId)
|
||||
} else {
|
||||
store.state.selectedTab
|
||||
}
|
||||
|
||||
if (tab == null) {
|
||||
logger.warn("Tab not found for installWebApp: $tabId")
|
||||
callback(Result.success(false))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val manifest = tab.content.webAppManifest
|
||||
if (manifest == null) {
|
||||
logger.warn("No manifest found for tab ${tab.id}")
|
||||
callback(Result.success(false))
|
||||
return@launch
|
||||
}
|
||||
|
||||
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
|
||||
|
||||
val success = createPwaShortcut(
|
||||
manifest = manifest,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
components.core.webAppManifestStorage.saveManifest(manifest)
|
||||
storeProfileMapping(manifest.startUrl, profileUuid)
|
||||
logger.debug("Web app installation completed for tab ${tab.id}")
|
||||
} else {
|
||||
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
|
||||
}
|
||||
|
||||
callback(Result.success(success))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to install web app", e)
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PWA shortcut with profile and container metadata in intent extras.
|
||||
*/
|
||||
private suspend fun createPwaShortcut(
|
||||
manifest: WebAppManifest,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
): Boolean = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
logger.warn("Pinned shortcuts require Android O or later")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val shortcutManager = context.getSystemService<ShortcutManager>()
|
||||
?: run {
|
||||
logger.error("ShortcutManager not available")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
if (!shortcutManager.isRequestPinShortcutSupported) {
|
||||
logger.warn("Pinning shortcuts is not supported")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
setIntent(shortcutIntent)
|
||||
|
||||
if (iconBitmap != null) {
|
||||
// Only use adaptive bitmap for maskable icons (designed for adaptive shapes)
|
||||
// Regular icons should use createWithBitmap to display as-is
|
||||
if (isMaskable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setIcon(Icon.createWithAdaptiveBitmap(iconBitmap))
|
||||
} else {
|
||||
setIcon(Icon.createWithBitmap(iconBitmap))
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
||||
logger.debug("PWA shortcut creation result: $success")
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to create PWA shortcut", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a collision-resistant shortcut ID from a URL using SHA-256.
|
||||
*/
|
||||
private fun generateShortcutId(url: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(url.toByteArray())
|
||||
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
|
||||
return "pwa_$hex"
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the PWA icon from the manifest using BrowserIcons.
|
||||
* Returns a pair of (bitmap, isMaskable) to determine proper icon format.
|
||||
*/
|
||||
private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair<Bitmap?, Boolean> = withContext(Dispatchers.IO) {
|
||||
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()
|
||||
|
||||
if (iconResource != null) {
|
||||
val isMaskable = iconResource.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
|
||||
val iconRequest = IconRequest(
|
||||
url = manifest.startUrl,
|
||||
size = IconRequest.Size.LAUNCHER_ADAPTIVE,
|
||||
resources = listOf(
|
||||
IconRequest.Resource(
|
||||
url = iconResource.src,
|
||||
type = IconRequest.Resource.Type.MANIFEST_ICON,
|
||||
sizes = iconResource.sizes?.map { size ->
|
||||
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
|
||||
} ?: emptyList(),
|
||||
mimeType = iconResource.type,
|
||||
maskable = isMaskable
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val iconResult = components.core.icons.loadIcon(iconRequest).await()
|
||||
Pair(iconResult?.bitmap, isMaskable)
|
||||
} else {
|
||||
Pair(null, false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to load PWA icon", e)
|
||||
Pair(null, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit) {
|
||||
logger.debug("getInstalledWebApps called")
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val storage = components.core.webAppManifestStorage
|
||||
val manifests = storage.loadShareableManifests(System.currentTimeMillis())
|
||||
val currentProfileUuid = getCurrentProfileUuid()
|
||||
val pwaManifests = manifests.filter { manifest ->
|
||||
val mappedProfile = getProfileMapping(manifest.startUrl)
|
||||
currentProfileUuid == null || mappedProfile == null || mappedProfile == currentProfileUuid
|
||||
}.map { manifest ->
|
||||
manifest.toPwaManifest()
|
||||
}
|
||||
logger.debug("Found ${pwaManifests.size} installed web apps")
|
||||
callback(Result.success(pwaManifests))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to get installed web apps", e)
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeProfileMapping(startUrl: String, profileUuid: String) {
|
||||
context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(startUrl, profileUuid)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getProfileMapping(startUrl: String): String? {
|
||||
return context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE)
|
||||
.getString(startUrl, null)
|
||||
}
|
||||
|
||||
private fun getCurrentProfileUuid(): String? {
|
||||
return try {
|
||||
val startupProfileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE)
|
||||
if (startupProfileFile.exists()) {
|
||||
startupProfileFile.readText().trim().ifEmpty { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to read current profile UUID", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest {
|
||||
return PwaManifest(
|
||||
startUrl = startUrl,
|
||||
currentUrl = currentUrl,
|
||||
name = name,
|
||||
shortName = shortName,
|
||||
display = display?.name?.lowercase(),
|
||||
themeColor = themeColor?.let { String.format("#%06X", 0xFFFFFF and it) },
|
||||
backgroundColor = backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) },
|
||||
scope = scope,
|
||||
description = description,
|
||||
icons = icons.map { icon ->
|
||||
PwaIcon(
|
||||
src = icon.src,
|
||||
sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" },
|
||||
type = icon.type,
|
||||
)
|
||||
},
|
||||
dir = dir?.name?.lowercase(),
|
||||
lang = lang,
|
||||
orientation = orientation?.name?.lowercase(),
|
||||
relatedApplications = relatedApplications.map { app ->
|
||||
ExternalApplicationResource(
|
||||
platform = app.platform,
|
||||
url = app.url,
|
||||
id = app.id,
|
||||
minVersion = app.minVersion,
|
||||
)
|
||||
},
|
||||
preferRelatedApplications = preferRelatedApplications,
|
||||
shareTarget = shareTarget?.let { target ->
|
||||
ShareTarget(
|
||||
action = target.action,
|
||||
method = target.method?.name,
|
||||
encType = target.encType?.type,
|
||||
params = target.params?.let { params ->
|
||||
ShareTargetParams(
|
||||
title = params.title,
|
||||
text = params.text,
|
||||
url = params.url,
|
||||
files = params.files.map { file ->
|
||||
ShareTargetFiles(
|
||||
name = file.name,
|
||||
accept = file.accept,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -254,7 +254,7 @@ class GeckoSessionApiImpl : GeckoSessionApi {
|
||||
return
|
||||
}
|
||||
|
||||
components.engineView?.captureThumbnail { bitmap ->
|
||||
components.mainBrowserEngineView?.captureThumbnail { bitmap ->
|
||||
try {
|
||||
if (bitmap != null) {
|
||||
components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap))
|
||||
|
||||
+29
-34
@@ -14,8 +14,12 @@ import mozilla.components.support.base.log.logger.Logger
|
||||
* Implementation of GeckoViewportApi that controls GeckoView's viewport behavior
|
||||
* for dynamic toolbar and keyboard handling.
|
||||
*
|
||||
* This allows Flutter to control how GeckoView adjusts its internal viewport
|
||||
* without resizing the platform view itself, avoiding visual flickering.
|
||||
* Toolbar height and vertical clipping target the main browser's EngineView specifically,
|
||||
* not the active/foreground EngineView. This prevents toolbar settings from leaking
|
||||
* to PWA/Custom Tab EngineViews.
|
||||
*
|
||||
* If the main browser EngineView is not yet available when setDynamicToolbarMaxHeight
|
||||
* is called, the value is stored and applied when the EngineView becomes available.
|
||||
*/
|
||||
class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
companion object {
|
||||
@@ -28,66 +32,57 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||
}
|
||||
|
||||
// Store the current dynamic toolbar max height
|
||||
private var currentDynamicToolbarMaxHeight: Int = 0
|
||||
private var pendingToolbarHeight: Int? = null
|
||||
|
||||
/**
|
||||
* Sets the maximum height that dynamic toolbars (top + bottom) can occupy.
|
||||
*
|
||||
* GeckoView will adjust its internal viewport calculations to account for
|
||||
* this space. The website will receive proper viewport dimensions through
|
||||
* standard web APIs (CSS viewport units, window.innerHeight).
|
||||
* Targets the main browser EngineView specifically. If the main browser
|
||||
* EngineView is not yet available, the height is stored and applied when
|
||||
* it becomes available via [applyPendingToolbarHeight].
|
||||
*/
|
||||
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
||||
val height = heightPx.toInt()
|
||||
currentDynamicToolbarMaxHeight = height
|
||||
|
||||
val engineView = components.engineView
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
logger.warn("$TAG: setDynamicToolbarMaxHeight called but engineView is null")
|
||||
logger.debug("$TAG: setDynamicToolbarMaxHeight($height) - mainBrowserEngineView not ready, storing as pending")
|
||||
pendingToolbarHeight = height
|
||||
return
|
||||
}
|
||||
|
||||
pendingToolbarHeight = null
|
||||
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
|
||||
engineView.setDynamicToolbarMaxHeight(height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies any pending toolbar height to the main browser EngineView.
|
||||
* Called when mainBrowserEngineView becomes available.
|
||||
*/
|
||||
fun applyPendingToolbarHeight() {
|
||||
val pending = pendingToolbarHeight ?: return
|
||||
val engineView = components.mainBrowserEngineView ?: return
|
||||
pendingToolbarHeight = null
|
||||
logger.debug("$TAG: Applying pending toolbar height: $pending")
|
||||
engineView.setDynamicToolbarMaxHeight(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the vertical clipping offset for the GeckoView content.
|
||||
*
|
||||
* Use this as the toolbar animates to clip content at the bottom.
|
||||
* Negative values clip from the bottom (for bottom toolbar sliding up).
|
||||
* Positive values clip from the top (for top toolbar sliding down).
|
||||
* Targets the main browser EngineView specifically.
|
||||
*/
|
||||
override fun setVerticalClipping(clippingPx: Long) {
|
||||
val clipping = clippingPx.toInt()
|
||||
|
||||
val engineView = components.engineView
|
||||
val engineView = components.mainBrowserEngineView
|
||||
if (engineView == null) {
|
||||
logger.warn("$TAG: setVerticalClipping called but engineView is null")
|
||||
logger.warn("$TAG: setVerticalClipping called but mainBrowserEngineView is null")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("$TAG: setVerticalClipping($clipping)")
|
||||
engineView.setVerticalClipping(clipping)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies any pending viewport settings that were set before engineView was available.
|
||||
*
|
||||
* Call this method after setting components.engineView to ensure that any
|
||||
* setDynamicToolbarMaxHeight calls made during startup are properly applied.
|
||||
*/
|
||||
fun applyPendingSettings() {
|
||||
val engineView = components.engineView
|
||||
if (engineView == null) {
|
||||
logger.warn("$TAG: applyPendingSettings called but engineView is still null")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentDynamicToolbarMaxHeight > 0) {
|
||||
logger.debug("$TAG: Applying pending dynamicToolbarMaxHeight: $currentDynamicToolbarMaxHeight")
|
||||
engineView.setDynamicToolbarMaxHeight(currentDynamicToolbarMaxHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -47,6 +47,8 @@ import mozilla.components.feature.addons.amo.AMOAddonsProvider
|
||||
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
|
||||
import mozilla.components.feature.addons.update.DefaultAddonUpdater
|
||||
import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
|
||||
import mozilla.components.feature.pwa.ManifestStorage
|
||||
import mozilla.components.feature.pwa.WebAppShortcutManager
|
||||
import mozilla.components.feature.downloads.DownloadMiddleware
|
||||
import mozilla.components.feature.media.MediaSessionFeature
|
||||
import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware
|
||||
@@ -230,6 +232,14 @@ class Core(
|
||||
*/
|
||||
val customTabsStore by lazy { CustomTabsServiceStore() }
|
||||
|
||||
// Must use the base application context (not ProfileContext) so the database
|
||||
// matches what WebAppLauncherActivity (from the library) uses when loading manifests.
|
||||
val webAppManifestStorage by lazy { ManifestStorage(context.applicationContext) }
|
||||
|
||||
val webAppShortcutManager by lazy {
|
||||
WebAppShortcutManager(context, client, webAppManifestStorage)
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage component for persisting browser tab sessions.
|
||||
*/
|
||||
|
||||
+109
-3
@@ -10,13 +10,18 @@ import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryItem
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -25,10 +30,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import mozilla.components.browser.state.action.BrowserAction
|
||||
import mozilla.components.browser.state.selector.selectedTab
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.feature.addons.logger
|
||||
import mozilla.components.lib.state.Store
|
||||
import mozilla.components.lib.state.ext.flowScoped
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
|
||||
|
||||
@@ -53,12 +60,27 @@ class Events(
|
||||
}
|
||||
|
||||
stateFlow.flowScoped { flow ->
|
||||
var previousTabs = emptySet<String>()
|
||||
flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } }
|
||||
.distinctUntilChanged()
|
||||
// Make sure this is sent after tabadded action
|
||||
.debounce { 25 }
|
||||
.collect { tabs ->
|
||||
flutterEvents.onTabListChange(System.currentTimeMillis(), tabs) { _ -> }
|
||||
val currentTabs = tabs.toSet()
|
||||
if (previousTabs.isNotEmpty()) {
|
||||
val removedTabs = previousTabs - currentTabs
|
||||
if (removedTabs.isNotEmpty()) {
|
||||
removedTabs.forEach { tabId ->
|
||||
flutterEvents.onManifestUpdate(
|
||||
EventSequence.next(),
|
||||
tabId,
|
||||
null
|
||||
) { _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
previousTabs = currentTabs
|
||||
flutterEvents.onTabListChange(EventSequence.next(), tabs) { _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,5 +209,89 @@ class Events(
|
||||
) { _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
// PWA manifest availability events - following Fenix MenuPresenter pattern
|
||||
stateFlow.flowScoped { flow ->
|
||||
flow.mapNotNull { state -> state.selectedTab }
|
||||
.ifAnyChanged { tab ->
|
||||
arrayOf(
|
||||
tab.content.loading,
|
||||
tab.content.canGoBack,
|
||||
tab.content.canGoForward,
|
||||
tab.content.webAppManifest,
|
||||
)
|
||||
}
|
||||
.collect { tab ->
|
||||
val manifest = tab.content.webAppManifest
|
||||
val currentUrl = tab.content.url
|
||||
|
||||
// If manifest is null, clear PWA state for this tab
|
||||
if (manifest == null) {
|
||||
flutterEvents.onManifestUpdate(
|
||||
EventSequence.next(),
|
||||
tab.id,
|
||||
null
|
||||
) { _ -> }
|
||||
return@collect
|
||||
}
|
||||
|
||||
val pwaManifest = PwaManifest(
|
||||
startUrl = manifest.startUrl,
|
||||
currentUrl = currentUrl,
|
||||
name = manifest.name,
|
||||
shortName = manifest.shortName,
|
||||
display = manifest.display?.name?.lowercase(),
|
||||
themeColor = manifest.themeColor?.let { String.format("#%06X", 0xFFFFFF and it) },
|
||||
backgroundColor = manifest.backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) },
|
||||
scope = manifest.scope,
|
||||
description = manifest.description,
|
||||
icons = manifest.icons.map { icon ->
|
||||
PwaIcon(
|
||||
src = icon.src,
|
||||
sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" },
|
||||
type = icon.type,
|
||||
)
|
||||
},
|
||||
dir = manifest.dir?.name?.lowercase(),
|
||||
lang = manifest.lang,
|
||||
orientation = manifest.orientation?.name?.lowercase(),
|
||||
relatedApplications = manifest.relatedApplications.map { app ->
|
||||
ExternalApplicationResource(
|
||||
platform = app.platform,
|
||||
url = app.url,
|
||||
id = app.id,
|
||||
minVersion = app.minVersion,
|
||||
)
|
||||
},
|
||||
preferRelatedApplications = manifest.preferRelatedApplications,
|
||||
shareTarget = manifest.shareTarget?.let { target ->
|
||||
ShareTarget(
|
||||
action = target.action,
|
||||
method = target.method?.name,
|
||||
encType = target.encType?.type,
|
||||
params = target.params?.let { params ->
|
||||
ShareTargetParams(
|
||||
title = params.title,
|
||||
text = params.text,
|
||||
url = params.url,
|
||||
files = params.files.map { file ->
|
||||
ShareTargetFiles(
|
||||
name = file.name,
|
||||
accept = file.accept,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
flutterEvents.onManifestUpdate(
|
||||
EventSequence.next(),
|
||||
tab.id,
|
||||
pwaManifest
|
||||
) { _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -12,6 +12,8 @@ import mozilla.components.feature.downloads.DownloadsUseCases
|
||||
import mozilla.components.feature.session.SessionUseCases
|
||||
import mozilla.components.feature.session.SettingsUseCases
|
||||
import mozilla.components.feature.session.TrackingProtectionUseCases
|
||||
import mozilla.components.feature.pwa.WebAppShortcutManager
|
||||
import mozilla.components.feature.pwa.WebAppUseCases
|
||||
import mozilla.components.feature.tabs.CustomTabsUseCases
|
||||
import mozilla.components.feature.tabs.TabsUseCases
|
||||
|
||||
@@ -23,6 +25,7 @@ class UseCases(
|
||||
private val context: Context,
|
||||
private val engine: Engine,
|
||||
private val store: BrowserStore,
|
||||
private val shortcutManager: WebAppShortcutManager? = null,
|
||||
) {
|
||||
/**
|
||||
* Use cases that provide engine interactions for a given browser session.
|
||||
@@ -52,4 +55,8 @@ class UseCases(
|
||||
val appLinksUseCases by lazy { AppLinksUseCases(context) }
|
||||
|
||||
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
|
||||
|
||||
val webAppUseCases by lazy {
|
||||
WebAppUseCases(context, store, shortcutManager ?: throw IllegalStateException("WebAppShortcutManager not provided"))
|
||||
}
|
||||
}
|
||||
|
||||
+479
-51
@@ -2972,6 +2972,286 @@ data class TrackingProtectionException (
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an icon from a PWA manifest.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class PwaIcon (
|
||||
val src: String,
|
||||
val sizes: String? = null,
|
||||
val type: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): PwaIcon {
|
||||
val src = pigeonVar_list[0] as String
|
||||
val sizes = pigeonVar_list[1] as String?
|
||||
val type = pigeonVar_list[2] as String?
|
||||
return PwaIcon(src, sizes, type)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
src,
|
||||
sizes,
|
||||
type,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is PwaIcon) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file entry in share target params.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class ShareTargetFiles (
|
||||
val name: String,
|
||||
val accept: List<String?>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): ShareTargetFiles {
|
||||
val name = pigeonVar_list[0] as String
|
||||
val accept = pigeonVar_list[1] as List<String?>
|
||||
return ShareTargetFiles(name, accept)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
name,
|
||||
accept,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ShareTargetFiles) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents share target params.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class ShareTargetParams (
|
||||
val title: String? = null,
|
||||
val text: String? = null,
|
||||
val url: String? = null,
|
||||
val files: List<ShareTargetFiles?>
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): ShareTargetParams {
|
||||
val title = pigeonVar_list[0] as String?
|
||||
val text = pigeonVar_list[1] as String?
|
||||
val url = pigeonVar_list[2] as String?
|
||||
val files = pigeonVar_list[3] as List<ShareTargetFiles?>
|
||||
return ShareTargetParams(title, text, url, files)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
title,
|
||||
text,
|
||||
url,
|
||||
files,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ShareTargetParams) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a share target for PWA.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class ShareTarget (
|
||||
val action: String,
|
||||
val method: String? = null,
|
||||
val encType: String? = null,
|
||||
val params: ShareTargetParams? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): ShareTarget {
|
||||
val action = pigeonVar_list[0] as String
|
||||
val method = pigeonVar_list[1] as String?
|
||||
val encType = pigeonVar_list[2] as String?
|
||||
val params = pigeonVar_list[3] as ShareTargetParams?
|
||||
return ShareTarget(action, method, encType, params)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
action,
|
||||
method,
|
||||
encType,
|
||||
params,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ShareTarget) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an external application resource.
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class ExternalApplicationResource (
|
||||
val platform: String,
|
||||
val url: String? = null,
|
||||
val id: String? = null,
|
||||
val minVersion: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): ExternalApplicationResource {
|
||||
val platform = pigeonVar_list[0] as String
|
||||
val url = pigeonVar_list[1] as String?
|
||||
val id = pigeonVar_list[2] as String?
|
||||
val minVersion = pigeonVar_list[3] as String?
|
||||
return ExternalApplicationResource(platform, url, id, minVersion)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
platform,
|
||||
url,
|
||||
id,
|
||||
minVersion,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ExternalApplicationResource) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a PWA web app manifest.
|
||||
*
|
||||
* Mirrors Mozilla Android Components' WebAppManifest structure.
|
||||
* https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html
|
||||
*
|
||||
* Generated class from Pigeon that represents data sent in messages.
|
||||
*/
|
||||
data class PwaManifest (
|
||||
val startUrl: String,
|
||||
val name: String? = null,
|
||||
val shortName: String? = null,
|
||||
val display: String? = null,
|
||||
val themeColor: String? = null,
|
||||
val backgroundColor: String? = null,
|
||||
val scope: String? = null,
|
||||
val description: String? = null,
|
||||
val icons: List<PwaIcon?>,
|
||||
val dir: String? = null,
|
||||
val lang: String? = null,
|
||||
val orientation: String? = null,
|
||||
val relatedApplications: List<ExternalApplicationResource?>,
|
||||
val preferRelatedApplications: Boolean,
|
||||
val shareTarget: ShareTarget? = null,
|
||||
/**
|
||||
* The URL of the page when the manifest was detected.
|
||||
* Used for HTTPS/installability checks.
|
||||
*/
|
||||
val currentUrl: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): PwaManifest {
|
||||
val startUrl = pigeonVar_list[0] as String
|
||||
val name = pigeonVar_list[1] as String?
|
||||
val shortName = pigeonVar_list[2] as String?
|
||||
val display = pigeonVar_list[3] as String?
|
||||
val themeColor = pigeonVar_list[4] as String?
|
||||
val backgroundColor = pigeonVar_list[5] as String?
|
||||
val scope = pigeonVar_list[6] as String?
|
||||
val description = pigeonVar_list[7] as String?
|
||||
val icons = pigeonVar_list[8] as List<PwaIcon?>
|
||||
val dir = pigeonVar_list[9] as String?
|
||||
val lang = pigeonVar_list[10] as String?
|
||||
val orientation = pigeonVar_list[11] as String?
|
||||
val relatedApplications = pigeonVar_list[12] as List<ExternalApplicationResource?>
|
||||
val preferRelatedApplications = pigeonVar_list[13] as Boolean
|
||||
val shareTarget = pigeonVar_list[14] as ShareTarget?
|
||||
val currentUrl = pigeonVar_list[15] as String
|
||||
return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
startUrl,
|
||||
name,
|
||||
shortName,
|
||||
display,
|
||||
themeColor,
|
||||
backgroundColor,
|
||||
scope,
|
||||
description,
|
||||
icons,
|
||||
dir,
|
||||
lang,
|
||||
orientation,
|
||||
relatedApplications,
|
||||
preferRelatedApplications,
|
||||
shareTarget,
|
||||
currentUrl,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is PwaManifest) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -3400,6 +3680,36 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
TrackingProtectionException.fromList(it)
|
||||
}
|
||||
}
|
||||
214.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PwaIcon.fromList(it)
|
||||
}
|
||||
}
|
||||
215.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
ShareTargetFiles.fromList(it)
|
||||
}
|
||||
}
|
||||
216.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
ShareTargetParams.fromList(it)
|
||||
}
|
||||
}
|
||||
217.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
ShareTarget.fromList(it)
|
||||
}
|
||||
}
|
||||
218.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
ExternalApplicationResource.fromList(it)
|
||||
}
|
||||
}
|
||||
219.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PwaManifest.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -3745,6 +4055,30 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(213)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is PwaIcon -> {
|
||||
stream.write(214)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is ShareTargetFiles -> {
|
||||
stream.write(215)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is ShareTargetParams -> {
|
||||
stream.write(216)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is ShareTarget -> {
|
||||
stream.write(217)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is ExternalApplicationResource -> {
|
||||
stream.write(218)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is PwaManifest -> {
|
||||
stream.write(219)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
@@ -5320,12 +5654,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onViewReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
fun onViewReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5337,12 +5671,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onEngineReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5354,12 +5688,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onIconUpdate(timestampArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result<Unit>) -> Unit)
|
||||
fun onIconUpdate(sequenceArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, urlArg, bytesArg)) {
|
||||
channel.send(listOf(sequenceArg, urlArg, bytesArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5371,12 +5705,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onTabAdded(timestampArg: Long, tabIdArg: String, callback: (Result<Unit>) -> Unit)
|
||||
fun onTabAdded(sequenceArg: Long, tabIdArg: String, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, tabIdArg)) {
|
||||
channel.send(listOf(sequenceArg, tabIdArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5388,12 +5722,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onTabListChange(timestampArg: Long, tabIdsArg: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
fun onTabListChange(sequenceArg: Long, tabIdsArg: List<String>, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, tabIdsArg)) {
|
||||
channel.send(listOf(sequenceArg, tabIdsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5405,12 +5739,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onSelectedTabChange(timestampArg: Long, idArg: String?, callback: (Result<Unit>) -> Unit)
|
||||
fun onSelectedTabChange(sequenceArg: Long, idArg: String?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5422,12 +5756,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onTabContentStateChange(timestampArg: Long, stateArg: TabContentState, callback: (Result<Unit>) -> Unit)
|
||||
fun onTabContentStateChange(sequenceArg: Long, stateArg: TabContentState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5439,12 +5773,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onHistoryStateChange(timestampArg: Long, idArg: String, stateArg: HistoryState, callback: (Result<Unit>) -> Unit)
|
||||
fun onHistoryStateChange(sequenceArg: Long, idArg: String, stateArg: HistoryState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5456,12 +5790,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onReaderableStateChange(timestampArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result<Unit>) -> Unit)
|
||||
fun onReaderableStateChange(sequenceArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5473,12 +5807,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onSecurityInfoStateChange(timestampArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result<Unit>) -> Unit)
|
||||
fun onSecurityInfoStateChange(sequenceArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, stateArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, stateArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5490,12 +5824,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onIconChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
|
||||
fun onIconChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, bytesArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, bytesArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5507,12 +5841,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onThumbnailChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
|
||||
fun onThumbnailChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, bytesArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, bytesArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5524,12 +5858,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onFindResults(timestampArg: Long, idArg: String, resultsArg: List<FindResultState>, callback: (Result<Unit>) -> Unit)
|
||||
fun onFindResults(sequenceArg: Long, idArg: String, resultsArg: List<FindResultState>, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, resultsArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, resultsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5541,12 +5875,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onLongPress(timestampArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result<Unit>) -> Unit)
|
||||
fun onLongPress(sequenceArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, idArg, hitResultArg)) {
|
||||
channel.send(listOf(sequenceArg, idArg, hitResultArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5558,12 +5892,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onPreferenceChange(timestampArg: Long, valueArg: GeckoPref, callback: (Result<Unit>) -> Unit)
|
||||
fun onPreferenceChange(sequenceArg: Long, valueArg: GeckoPref, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onPreferenceChange$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, valueArg)) {
|
||||
channel.send(listOf(sequenceArg, valueArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5575,12 +5909,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onContainerSiteAssignment(timestampArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result<Unit>) -> Unit)
|
||||
fun onContainerSiteAssignment(sequenceArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onContainerSiteAssignment$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, detailsArg)) {
|
||||
channel.send(listOf(sequenceArg, detailsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5592,12 +5926,29 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onMlProgress(timestampArg: Long, progressArg: MlProgressData, callback: (Result<Unit>) -> Unit)
|
||||
fun onMlProgress(sequenceArg: Long, progressArg: MlProgressData, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onMlProgress$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, progressArg)) {
|
||||
channel.send(listOf(sequenceArg, progressArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
} else {
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onManifestUpdate(sequenceArg: Long, tabIdArg: String, manifestArg: PwaManifest?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onManifestUpdate$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(sequenceArg, tabIdArg, manifestArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5695,12 +6046,12 @@ class ReaderViewController(private val binaryMessenger: BinaryMessenger, private
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun appearanceButtonVisibility(timestampArg: Long, visibleArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
fun appearanceButtonVisibility(sequenceArg: Long, visibleArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, visibleArg)) {
|
||||
channel.send(listOf(sequenceArg, visibleArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5853,12 +6204,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onUpsertWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result<Unit>) -> Unit)
|
||||
fun onUpsertWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, extensionDataArg)) {
|
||||
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, extensionDataArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5870,12 +6221,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onRemoveWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result<Unit>) -> Unit)
|
||||
fun onRemoveWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg)) {
|
||||
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5887,12 +6238,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onUpdateWebExtensionIcon(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result<Unit>) -> Unit)
|
||||
fun onUpdateWebExtensionIcon(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, iconArg)) {
|
||||
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, iconArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5969,12 +6320,12 @@ class GeckoSuggestionEvents(private val binaryMessenger: BinaryMessenger, privat
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onSuggestionResult(timestampArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List<GeckoSuggestion>, callback: (Result<Unit>) -> Unit)
|
||||
fun onSuggestionResult(sequenceArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List<GeckoSuggestion>, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, suggestionTypeArg, suggestionsArg)) {
|
||||
channel.send(listOf(sequenceArg, suggestionTypeArg, suggestionsArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -5995,12 +6346,12 @@ class GeckoTabContentEvents(private val binaryMessenger: BinaryMessenger, privat
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onContentUpdate(timestampArg: Long, contentArg: TabContent, callback: (Result<Unit>) -> Unit)
|
||||
fun onContentUpdate(sequenceArg: Long, contentArg: TabContent, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, contentArg)) {
|
||||
channel.send(listOf(sequenceArg, contentArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -6384,12 +6735,12 @@ class BrowserExtensionEvents(private val binaryMessenger: BinaryMessenger, priva
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result<Unit>) -> Unit)
|
||||
fun onFeedRequested(sequenceArg: Long, urlArg: String, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, urlArg)) {
|
||||
channel.send(listOf(sequenceArg, urlArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -6546,17 +6897,17 @@ class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private
|
||||
* This is detected natively using WindowInsets API and provides
|
||||
* accurate keyboard height information.
|
||||
*
|
||||
* [timestamp] Event timestamp for ordering.
|
||||
* [sequence] Event sequence number for ordering.
|
||||
* [heightPx] Keyboard height in pixels (0 when hidden).
|
||||
* [isVisible] Whether the keyboard is currently visible.
|
||||
* [isAnimating] Whether the keyboard is currently animating.
|
||||
*/
|
||||
fun onKeyboardVisibilityChanged(timestampArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
fun onKeyboardVisibilityChanged(sequenceArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onKeyboardVisibilityChanged$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(timestampArg, heightPxArg, isVisibleArg, isAnimatingArg)) {
|
||||
channel.send(listOf(sequenceArg, heightPxArg, isVisibleArg, isAnimatingArg)) {
|
||||
if (it is List<*>) {
|
||||
if (it.size > 1) {
|
||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||
@@ -7237,3 +7588,80 @@ interface GeckoAppLinksApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* API for PWA (Progressive Web App) installation and management.
|
||||
*
|
||||
* Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage
|
||||
* to provide PWA install and query functionality to Flutter.
|
||||
*
|
||||
* Generated interface from Pigeon that represents a handler of messages from Flutter.
|
||||
*/
|
||||
interface GeckoPwaApi {
|
||||
/**
|
||||
* Installs the current page as a PWA (adds to home screen).
|
||||
*
|
||||
* Creates an Android shortcut with profile and container metadata embedded
|
||||
* in the intent extras. This ensures the PWA opens with the same profile
|
||||
* and container context that was active during installation.
|
||||
*
|
||||
* The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||
* The [profileUuid] is the UUID of the current user profile.
|
||||
* The [contextId] is the container's contextual identity (optional, null for default container).
|
||||
* Returns true if installation was successful.
|
||||
*/
|
||||
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
|
||||
/** Returns a list of all installed PWA manifests. */
|
||||
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by GeckoPwaApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `GeckoPwaApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoPwaApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val tabIdArg = args[0] as String?
|
||||
val profileUuidArg = args[1] as String
|
||||
val contextIdArg = args[2] as String?
|
||||
api.installWebApp(tabIdArg, profileUuidArg, contextIdArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(GeckoPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(GeckoPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.getInstalledWebApps$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.getInstalledWebApps{ result: Result<List<PwaManifest>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(GeckoPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(GeckoPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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.animation.ObjectAnimator
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
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.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.
|
||||
*/
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts URL from an intent.
|
||||
*/
|
||||
fun extractUrl(intent: Intent?): String? {
|
||||
return intent?.dataString
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
)
|
||||
|
||||
// Extract URL and use domain as temporary name
|
||||
val url = intent.dataString
|
||||
val domain = url?.let { extractDomain(it) } ?: "Web App"
|
||||
|
||||
// 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
|
||||
|
||||
// Show WebLibre logo as placeholder (already set in XML layout)
|
||||
val iconView = view.findViewById<ImageView>(R.id.pwa_icon)
|
||||
iconView.alpha = 0.5f
|
||||
|
||||
// Start pulsing animation
|
||||
startPulseAnimation(view)
|
||||
|
||||
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) {
|
||||
view.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(200)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
.start()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the loading view and animations.
|
||||
*/
|
||||
fun cleanup() {
|
||||
pulseAnimator?.cancel()
|
||||
pulseAnimator = null
|
||||
|
||||
currentLoadingView?.let { view ->
|
||||
container.removeView(view)
|
||||
}
|
||||
currentLoadingView = null
|
||||
}
|
||||
|
||||
private fun startPulseAnimation(view: View) {
|
||||
val pulseView = view.findViewById<View>(R.id.pwa_icon_pulse)
|
||||
?: return
|
||||
|
||||
pulseView.alpha = 0.0f
|
||||
pulseAnimator = ObjectAnimator.ofFloat(pulseView, "alpha", 0.0f, 0.3f, 0.0f).apply {
|
||||
duration = 1500
|
||||
repeatCount = ObjectAnimator.INFINITE
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractDomain(url: String): String {
|
||||
return try {
|
||||
val uri = android.net.Uri.parse(url)
|
||||
uri.host ?: url
|
||||
} catch (e: Exception) {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.IIcon
|
||||
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import eu.weblibre.flutter_mozilla_components.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import mozilla.components.browser.state.selector.findCustomTab
|
||||
import mozilla.components.browser.state.state.CustomTabSessionState
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.lib.state.ext.flowScoped
|
||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
|
||||
import mozilla.components.support.ktx.util.URLStringUtils
|
||||
|
||||
/**
|
||||
* Custom tab toolbar with pill-shaped Material 3 design.
|
||||
* Uses MDI (Pictogrammers) icons via the Iconics library.
|
||||
*/
|
||||
class CustomTabToolbar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val toolbarCard: MaterialCardView
|
||||
private val closeButton: ImageButton
|
||||
private val securityIcon: ImageView
|
||||
private val urlText: TextView
|
||||
private val shareButton: ImageButton
|
||||
private val openInBrowserButton: ImageButton
|
||||
private val menuButton: ImageButton
|
||||
|
||||
private var sessionId: String? = null
|
||||
private var store: BrowserStore? = null
|
||||
private var urlScope: CoroutineScope? = null
|
||||
private var securityScope: CoroutineScope? = null
|
||||
|
||||
var onCloseListener: (() -> Unit)? = null
|
||||
var onShareListener: (() -> Unit)? = null
|
||||
var onOpenInBrowserListener: (() -> Unit)? = null
|
||||
var onMenuListener: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.custom_tab_toolbar, this)
|
||||
|
||||
toolbarCard = findViewById(R.id.toolbarCard)
|
||||
closeButton = findViewById(R.id.closeButton)
|
||||
securityIcon = findViewById(R.id.securityIcon)
|
||||
urlText = findViewById(R.id.urlText)
|
||||
shareButton = findViewById(R.id.shareButton)
|
||||
openInBrowserButton = findViewById(R.id.openInBrowserButton)
|
||||
menuButton = findViewById(R.id.menuButton)
|
||||
|
||||
closeButton.setOnClickListener { onCloseListener?.invoke() }
|
||||
shareButton.setOnClickListener { onShareListener?.invoke() }
|
||||
openInBrowserButton.setOnClickListener { onOpenInBrowserListener?.invoke() }
|
||||
menuButton.setOnClickListener { onMenuListener?.invoke() }
|
||||
|
||||
applyMaterial3Colors()
|
||||
applyIcons()
|
||||
}
|
||||
|
||||
fun bind(sessionId: String, store: BrowserStore, toolbarColor: Int? = null) {
|
||||
this.sessionId = sessionId
|
||||
this.store = store
|
||||
|
||||
toolbarColor?.let { applyCustomColors(it) }
|
||||
|
||||
store.state.findCustomTab(sessionId)?.let { tab ->
|
||||
updateUrl(tab)
|
||||
updateSecurityIcon(tab)
|
||||
}
|
||||
|
||||
observeUrlChanges()
|
||||
observeSecurityChanges()
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
urlScope?.cancel()
|
||||
urlScope = null
|
||||
securityScope?.cancel()
|
||||
securityScope = null
|
||||
}
|
||||
|
||||
fun getMenuButton(): View = menuButton
|
||||
|
||||
fun getUrl(): String? = urlText.text?.toString()
|
||||
|
||||
private fun applyMaterial3Colors() {
|
||||
val surfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface)
|
||||
val onSurfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface)
|
||||
toolbarCard.setCardBackgroundColor(surfaceColor)
|
||||
urlText.setTextColor(onSurfaceColor)
|
||||
}
|
||||
|
||||
private fun applyIcons() {
|
||||
val iconColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
|
||||
closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, iconColor))
|
||||
shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, iconColor))
|
||||
openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, iconColor))
|
||||
menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, iconColor))
|
||||
}
|
||||
|
||||
private fun applyCustomColors(color: Int) {
|
||||
toolbarCard.setCardBackgroundColor(color)
|
||||
val textColor = if (isDarkColor(color)) {
|
||||
android.graphics.Color.WHITE
|
||||
} else {
|
||||
android.graphics.Color.BLACK
|
||||
}
|
||||
urlText.setTextColor(textColor)
|
||||
|
||||
closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, textColor))
|
||||
shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, textColor))
|
||||
openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, textColor))
|
||||
menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, textColor))
|
||||
}
|
||||
|
||||
private fun isDarkColor(color: Int): Boolean {
|
||||
val darkness = 1 - (0.299 * android.graphics.Color.red(color) +
|
||||
0.587 * android.graphics.Color.green(color) +
|
||||
0.114 * android.graphics.Color.blue(color)) / 255
|
||||
return darkness >= 0.5
|
||||
}
|
||||
|
||||
private fun observeUrlChanges() {
|
||||
val sessionId = this.sessionId ?: return
|
||||
val store = this.store ?: return
|
||||
|
||||
urlScope = store.flowScoped { flow ->
|
||||
flow
|
||||
.mapNotNull { state -> state.findCustomTab(sessionId) }
|
||||
.ifAnyChanged { tab -> arrayOf(tab.content.url) }
|
||||
.collect { tab -> updateUrl(tab) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSecurityChanges() {
|
||||
val sessionId = this.sessionId ?: return
|
||||
val store = this.store ?: return
|
||||
|
||||
securityScope = store.flowScoped { flow ->
|
||||
flow
|
||||
.mapNotNull { state -> state.findCustomTab(sessionId) }
|
||||
.ifAnyChanged { tab -> arrayOf(tab.content.securityInfo.isSecure) }
|
||||
.collect { tab -> updateSecurityIcon(tab) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUrl(tab: CustomTabSessionState) {
|
||||
urlText.text = URLStringUtils.toDisplayUrl(tab.content.url)
|
||||
}
|
||||
|
||||
private fun updateSecurityIcon(tab: CustomTabSessionState) {
|
||||
if (tab.content.securityInfo.isSecure) {
|
||||
val color = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon2.cmd_lock, 16, color))
|
||||
} else {
|
||||
val color = MaterialColors.getColor(this, android.R.attr.colorError)
|
||||
securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_web, 16, color))
|
||||
}
|
||||
}
|
||||
|
||||
private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable {
|
||||
return IconicsDrawable(context, icon).apply {
|
||||
this.sizeDp = sizeDp
|
||||
this.colorInt = color
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.widget
|
||||
|
||||
import android.view.Window
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import mozilla.components.browser.state.selector.findCustomTab
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import mozilla.components.support.base.feature.UserInteractionHandler
|
||||
|
||||
/**
|
||||
* Feature that connects [CustomTabToolbar] to browser state.
|
||||
* Handles lifecycle, state observation, and window color updates.
|
||||
*/
|
||||
class CustomTabToolbarFeature(
|
||||
private val store: BrowserStore,
|
||||
private val toolbar: CustomTabToolbar,
|
||||
private val sessionId: String,
|
||||
private val window: Window
|
||||
) : LifecycleAwareFeature, DefaultLifecycleObserver, UserInteractionHandler {
|
||||
|
||||
override fun start() {
|
||||
val tab = store.state.findCustomTab(sessionId) ?: return
|
||||
|
||||
val toolbarColor = tab.config.colorSchemes?.defaultColorSchemeParams?.toolbarColor
|
||||
|
||||
toolbar.bind(sessionId, store, toolbarColor)
|
||||
|
||||
toolbarColor?.let { color ->
|
||||
window.statusBarColor = color
|
||||
window.navigationBarColor = color
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
toolbar.unbind()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
stop()
|
||||
super<DefaultLifecycleObserver>.onDestroy(owner)
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean = false
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?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/. -->
|
||||
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="?attr/colorSurfaceContainer" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?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"
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true" />
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?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,157 @@
|
||||
<?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/. -->
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/custom_tab_menu_bg"
|
||||
android:paddingVertical="4dp"
|
||||
android:minWidth="192dp">
|
||||
|
||||
<!-- Navigation row: Back / Forward -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="12dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menuBack"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/custom_tab_navigate_back" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menuForward"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/custom_tab_navigate_forward" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Divider -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginVertical="4dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<!-- Refresh -->
|
||||
<LinearLayout
|
||||
android:id="@+id/menuRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/menuRefreshIcon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/custom_tab_refresh"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Share -->
|
||||
<LinearLayout
|
||||
android:id="@+id/menuShare"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/menuShareIcon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/custom_tab_share"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Desktop site -->
|
||||
<LinearLayout
|
||||
android:id="@+id/menuDesktopSite"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/menuDesktopIcon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/custom_tab_desktop_site"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/menuDesktopSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Open in browser -->
|
||||
<LinearLayout
|
||||
android:id="@+id/menuOpenInBrowser"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/menuOpenInBrowserIcon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/custom_tab_open_in_browser"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<?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:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/toolbarCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
app:cardCornerRadius="28dp"
|
||||
app:cardElevation="3dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:cardBackgroundColor="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<!-- Close Button -->
|
||||
<ImageButton
|
||||
android:id="@+id/closeButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/mozac_feature_customtabs_exit_button" />
|
||||
|
||||
<!-- URL Section with Security Indicator -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/securityIcon"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:contentDescription="@string/mozac_feature_customtabs_security_indicator" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/urlText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ImageButton
|
||||
android:id="@+id/shareButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/custom_tab_share" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/openInBrowserButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/custom_tab_open_in_browser" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menuButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:scaleType="center"
|
||||
android:contentDescription="@string/mozac_feature_customtabs_menu_button" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</FrameLayout>
|
||||
+11
-1
@@ -3,12 +3,22 @@
|
||||
- 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/. -->
|
||||
|
||||
<!-- Main browser fragment layout -->
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/customTabAppBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Placeholder for custom tab toolbar - ExternalAppBrowserFragment provides its own -->
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
@@ -32,4 +42,4 @@
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?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>
|
||||
@@ -16,4 +16,19 @@
|
||||
<string name="mozac_feature_addons_failed_to_uninstall">Failed to uninstall %1$s</string>
|
||||
<!-- Error shown when something unexpected happened while trying to get the extension list from the server -->
|
||||
<string name="mozac_feature_addons_failed_to_query_extensions">Failed to query extensions!</string>
|
||||
<!-- Custom tab menu items -->
|
||||
<string name="custom_tab_share">Share</string>
|
||||
<string name="custom_tab_desktop_site">Desktop site</string>
|
||||
<string name="custom_tab_open_in_browser">Open in browser</string>
|
||||
<string name="custom_tab_navigate_back">Back</string>
|
||||
<string name="custom_tab_navigate_forward">Forward</string>
|
||||
<string name="custom_tab_refresh">Refresh</string>
|
||||
<string name="mozac_feature_customtabs_exit_button">Close</string>
|
||||
<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>
|
||||
@@ -7,4 +7,10 @@
|
||||
<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>
|
||||
@@ -58,6 +58,7 @@ export 'src/pigeons/gecko.g.dart'
|
||||
GeckoFetchResponse,
|
||||
GeckoPref,
|
||||
GeckoPublicSuffixListApi,
|
||||
GeckoPwaApi,
|
||||
GeckoSitePermissionsApi,
|
||||
GeckoSuggestion,
|
||||
GeckoSuggestionType,
|
||||
@@ -75,6 +76,8 @@ export 'src/pigeons/gecko.g.dart'
|
||||
MlProgressStatus,
|
||||
MlProgressType,
|
||||
PhoneHitResult,
|
||||
PwaIcon,
|
||||
PwaManifest,
|
||||
QueryParameterStripping,
|
||||
Resource,
|
||||
ResourceSize,
|
||||
|
||||
@@ -202,8 +202,12 @@ class GeckoEventService extends GeckoStateEvents {
|
||||
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
|
||||
}
|
||||
|
||||
void onMlProgress(int timestamp, MlProgressData progress) {
|
||||
_mlProgressSubject.addWhenMoreRecent(timestamp, null, progress);
|
||||
@override
|
||||
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest) {
|
||||
_manifestUpdateSubject.addWhenMoreRecent(sequence, tabId, (
|
||||
tabId: tabId,
|
||||
manifest: manifest,
|
||||
));
|
||||
}
|
||||
|
||||
GeckoEventService.setUp({
|
||||
|
||||
@@ -1422,7 +1422,7 @@ abstract class GeckoStateEvents {
|
||||
|
||||
void onMlProgress(int sequence, MlProgressData progress);
|
||||
|
||||
void onMlProgress(int timestamp, MlProgressData progress);
|
||||
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest);
|
||||
}
|
||||
|
||||
@FlutterApi()
|
||||
@@ -2017,3 +2017,138 @@ abstract class GeckoAppLinksApi {
|
||||
@async
|
||||
bool openAppLink(String url);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PWA API
|
||||
// =============================================================================
|
||||
|
||||
/// Represents an icon from a PWA manifest.
|
||||
class PwaIcon {
|
||||
final String src;
|
||||
final String? sizes;
|
||||
final String? type;
|
||||
|
||||
const PwaIcon({required this.src, this.sizes, this.type});
|
||||
}
|
||||
|
||||
/// Represents a file entry in share target params.
|
||||
class ShareTargetFiles {
|
||||
final String name;
|
||||
final List<String?> accept;
|
||||
|
||||
const ShareTargetFiles({required this.name, required this.accept});
|
||||
}
|
||||
|
||||
/// Represents share target params.
|
||||
class ShareTargetParams {
|
||||
final String? title;
|
||||
final String? text;
|
||||
final String? url;
|
||||
final List<ShareTargetFiles?> files;
|
||||
|
||||
const ShareTargetParams({
|
||||
this.title,
|
||||
this.text,
|
||||
this.url,
|
||||
this.files = const [],
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents a share target for PWA.
|
||||
class ShareTarget {
|
||||
final String action;
|
||||
final String? method;
|
||||
final String? encType;
|
||||
final ShareTargetParams? params;
|
||||
|
||||
const ShareTarget({
|
||||
required this.action,
|
||||
this.method,
|
||||
this.encType,
|
||||
this.params,
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents an external application resource.
|
||||
class ExternalApplicationResource {
|
||||
final String platform;
|
||||
final String? url;
|
||||
final String? id;
|
||||
final String? minVersion;
|
||||
|
||||
const ExternalApplicationResource({
|
||||
required this.platform,
|
||||
this.url,
|
||||
this.id,
|
||||
this.minVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents a PWA web app manifest.
|
||||
///
|
||||
/// Mirrors Mozilla Android Components' WebAppManifest structure.
|
||||
/// https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html
|
||||
class PwaManifest {
|
||||
final String startUrl;
|
||||
final String? name;
|
||||
final String? shortName;
|
||||
final String? display;
|
||||
final String? themeColor;
|
||||
final String? backgroundColor;
|
||||
final String? scope;
|
||||
final String? description;
|
||||
final List<PwaIcon?> icons;
|
||||
final String? dir;
|
||||
final String? lang;
|
||||
final String? orientation;
|
||||
final List<ExternalApplicationResource?> relatedApplications;
|
||||
final bool preferRelatedApplications;
|
||||
final ShareTarget? shareTarget;
|
||||
|
||||
/// The URL of the page when the manifest was detected.
|
||||
/// Used for HTTPS/installability checks.
|
||||
final String currentUrl;
|
||||
|
||||
const PwaManifest({
|
||||
required this.startUrl,
|
||||
required this.currentUrl,
|
||||
this.name,
|
||||
this.shortName,
|
||||
this.display,
|
||||
this.themeColor,
|
||||
this.backgroundColor,
|
||||
this.scope,
|
||||
this.description,
|
||||
this.icons = const [],
|
||||
this.dir,
|
||||
this.lang,
|
||||
this.orientation,
|
||||
this.relatedApplications = const [],
|
||||
this.preferRelatedApplications = false,
|
||||
this.shareTarget,
|
||||
});
|
||||
}
|
||||
|
||||
/// API for PWA (Progressive Web App) installation and management.
|
||||
///
|
||||
/// Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage
|
||||
/// to provide PWA install and query functionality to Flutter.
|
||||
@HostApi()
|
||||
abstract class GeckoPwaApi {
|
||||
/// Installs the current page as a PWA (adds to home screen).
|
||||
///
|
||||
/// Creates an Android shortcut with profile and container metadata embedded
|
||||
/// in the intent extras. This ensures the PWA opens with the same profile
|
||||
/// and container context that was active during installation.
|
||||
///
|
||||
/// The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||
/// The [profileUuid] is the UUID of the current user profile.
|
||||
/// The [contextId] is the container's contextual identity (optional, null for default container).
|
||||
/// Returns true if installation was successful.
|
||||
@async
|
||||
bool installWebApp(String? tabId, String profileUuid, String? contextId);
|
||||
|
||||
/// Returns a list of all installed PWA manifests.
|
||||
@async
|
||||
List<PwaManifest> getInstalledWebApps();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user