From cbf61da7279837416866e4a1ed5de745f59a9882 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 16 Feb 2026 20:54:22 +0100 Subject: [PATCH] add proper extension session lifecycle --- .../addons/AddonPopupBaseFragment.kt | 199 ++++++++++++++++++ .../addons/WebExtensionActionPopupActivity.kt | 59 +++--- 2 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPopupBaseFragment.kt diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPopupBaseFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPopupBaseFragment.kt new file mode 100644 index 00000000..252948db --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/AddonPopupBaseFragment.kt @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package eu.weblibre.flutter_mozilla_components.addons + +import android.os.Bundle +import android.os.Environment +import android.view.View +import androidx.fragment.app.Fragment +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.services.DownloadService +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.CustomTabListAction +import mozilla.components.browser.state.state.CustomTabSessionState +import mozilla.components.browser.state.state.EngineState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.browser.state.state.content.DownloadState +import mozilla.components.browser.state.state.createCustomTab +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.concept.engine.window.WindowRequest +import mozilla.components.concept.fetch.Response +import mozilla.components.feature.downloads.DownloadsFeature +import mozilla.components.feature.downloads.manager.FetchDownloadManager +import mozilla.components.feature.prompts.PromptFeature +import mozilla.components.support.base.feature.UserInteractionHandler +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper + +/** + * Provides shared functionality to fragments for add-on popups and settings. + * + * Manages the engine session lifecycle by wrapping it in a custom tab registered in the + * BrowserStore, enabling PromptFeature and DownloadsFeature to interact with the session. + */ +abstract class AddonPopupBaseFragment : Fragment(), EngineSession.Observer, UserInteractionHandler { + private val promptsFeature = ViewBoundFeatureWrapper() + private val downloadsFeature = ViewBoundFeatureWrapper() + + protected val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + protected var session: SessionState? = null + protected var engineSession: EngineSession? = null + private var canGoBack: Boolean = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + session?.let { + promptsFeature.set( + feature = PromptFeature( + fragment = this, + store = components.core.store, + customTabId = it.id, + fragmentManager = parentFragmentManager, + fileUploadsDirCleaner = components.core.fileUploadsDirCleaner, + tabsUseCases = components.useCases.tabsUseCases, + onNeedToRequestPermissions = { permissions -> + @Suppress("DEPRECATION") + requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS) + }, + ), + owner = this, + view = view, + ) + + downloadsFeature.set( + feature = DownloadsFeature( + requireContext().applicationContext, + store = components.core.store, + useCases = components.useCases.downloadsUseCases, + fragmentManager = childFragmentManager, + tabId = it.id, + downloadManager = FetchDownloadManager( + requireContext().applicationContext, + components.core.store, + DownloadService::class, + notificationsDelegate = components.notificationsDelegate, + ), + onNeedToRequestPermissions = { permissions -> + @Suppress("DEPRECATION") + requestPermissions(permissions, REQUEST_CODE_DOWNLOAD_PERMISSIONS) + }, + ), + owner = this, + view = view, + ) + } + } + + override fun onStart() { + super.onStart() + engineSession?.register(this) + } + + override fun onStop() { + super.onStop() + engineSession?.unregister(this) + } + + override fun onDestroyView() { + engineSession?.close() + session?.let { + components.core.store.dispatch(CustomTabListAction.RemoveCustomTabAction(it.id)) + } + super.onDestroyView() + } + + override fun onPromptRequest(promptRequest: PromptRequest) { + session?.let { session -> + components.core.store.dispatch( + ContentAction.UpdatePromptRequestAction(session.id, promptRequest), + ) + } + } + + override fun onExternalResource( + url: String, + fileName: String?, + contentLength: Long?, + contentType: String?, + cookie: String?, + userAgent: String?, + isPrivate: Boolean, + skipConfirmation: Boolean, + openInApp: Boolean, + response: Response?, + ) { + session?.let { session -> + val fileSize = if (contentLength != null && contentLength < 0) null else contentLength + val download = DownloadState( + url, + fileName, + contentType, + fileSize, + 0, + DownloadState.Status.INITIATED, + userAgent, + Environment.DIRECTORY_DOWNLOADS, + private = isPrivate, + skipConfirmation = skipConfirmation, + openInApp = openInApp, + response = response, + ) + components.core.store.dispatch( + ContentAction.UpdateDownloadAction(session.id, download), + ) + } + } + + override fun onWindowRequest(windowRequest: WindowRequest) { + if (windowRequest.type == WindowRequest.Type.CLOSE) { + activity?.onBackPressedDispatcher?.onBackPressed() + } else { + engineSession?.loadUrl(windowRequest.url) + } + } + + override fun onNavigationStateChange(canGoBack: Boolean?, canGoForward: Boolean?) { + canGoBack?.let { this.canGoBack = it } + } + + override fun onBackPressed(): Boolean { + return if (canGoBack) { + engineSession?.goBack() + true + } else { + false + } + } + + protected fun initializeSession(fromEngineSession: EngineSession? = null) { + engineSession = fromEngineSession ?: components.core.engine.createSession() + session = createCustomTab( + url = "", + source = SessionState.Source.Internal.CustomTab, + ).copy(engineState = EngineState(engineSession)) + components.core.store.dispatch( + CustomTabListAction.AddCustomTabAction(session as CustomTabSessionState) + ) + } + + @Suppress("OVERRIDE_DEPRECATION") + final override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + when (requestCode) { + REQUEST_CODE_PROMPT_PERMISSIONS -> promptsFeature.get()?.onPermissionsResult(permissions, grantResults) + REQUEST_CODE_DOWNLOAD_PERMISSIONS -> downloadsFeature.get()?.onPermissionsResult(permissions, grantResults) + } + } + + companion object { + private const val REQUEST_CODE_PROMPT_PERMISSIONS = 1 + private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 2 + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt index 5e704f14..bb426c24 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt @@ -11,14 +11,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.R import mozilla.components.browser.state.action.WebExtensionAction import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView -import mozilla.components.concept.engine.window.WindowRequest import mozilla.components.lib.state.ext.consumeFrom -import eu.weblibre.flutter_mozilla_components.R /** * An activity to show the pop up action of a web extension. @@ -53,21 +51,31 @@ class WebExtensionActionPopupActivity : AppCompatActivity() { /** * A fragment to show the web extension action popup with [EngineView]. + * + * Extends [AddonPopupBaseFragment] to get proper session lifecycle management, + * prompt handling, and download support. */ - class WebExtensionActionPopupFragment : Fragment(), EngineSession.Observer { - private val components by lazy { - requireNotNull(GlobalComponents.components) { "Components not initialized" } - } + class WebExtensionActionPopupFragment : AddonPopupBaseFragment(), EngineSession.Observer { - private var engineSession: EngineSession? = null private lateinit var webExtensionId: String + private val safeArguments get() = requireNotNull(arguments) + private var sessionConsumed + get() = safeArguments.getBoolean("isSessionConsumed", false) + set(value) { + safeArguments.putBoolean("isSessionConsumed", value) + } + private val addonSettingsEngineView: EngineView get() = requireView().findViewById(R.id.addonSettingsEngineView) as EngineView override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { webExtensionId = requireNotNull(arguments?.getString("web_extension_id")) - engineSession = components.core.store.state.extensions[webExtensionId]?.popupSession + + // Grab the popup session from the store if available and register it as a custom tab. + components.core.store.state.extensions[webExtensionId]?.popupSession?.let { + initializeSession(it) + } return inflater.inflate(R.layout.fragment_add_on_settings, container, false) } @@ -82,38 +90,31 @@ class WebExtensionActionPopupActivity : AppCompatActivity() { } else { consumeFrom(components.core.store) { state -> state.extensions[webExtensionId]?.let { extState -> - extState.popupSession?.let { - if (engineSession == null) { - addonSettingsEngineView.render(it) - consumePopupSession() - engineSession = it - } + val popupSession = extState.popupSession + if (popupSession != null) { + initializeSession(popupSession) + addonSettingsEngineView.render(popupSession) + popupSession.register(this) + consumePopupSession() + engineSession = popupSession + } else if (sessionConsumed) { + // Session was consumed but lost (e.g. Android recreated the activity). + activity?.finish() } } } } } - override fun onStart() { - super.onStart() - engineSession?.register(this) - } - - override fun onStop() { - super.onStop() - engineSession?.unregister(this) - } - - override fun onWindowRequest(windowRequest: WindowRequest) { - if (windowRequest.type == WindowRequest.Type.CLOSE) { - activity?.onBackPressedDispatcher?.onBackPressed() - } + override fun onDestroyView() { + super.onDestroyView() } private fun consumePopupSession() { components.core.store.dispatch( WebExtensionAction.UpdatePopupSessionAction(webExtensionId, popupSession = null), ) + sessionConsumed = true } companion object {