impelement geckoview intent handling via viewport api; fix lints and ui issues

This commit is contained in:
Fabian Freund
2026-01-13 11:03:26 +01:00
parent 5e446f1b92
commit 849823f5df
18 changed files with 960 additions and 33 deletions
@@ -24,6 +24,7 @@ import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActi
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import eu.weblibre.flutter_mozilla_components.feature.KeyboardVisibilityFeature
import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration
@@ -90,6 +91,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private val webExtensionPopupObserver = ViewBoundFeatureWrapper<WebExtensionPopupObserver>()
private val webExtToolbarFeature = ViewBoundFeatureWrapper<WebExtensionToolbarFeature>()
// Keyboard visibility detection feature
private var keyboardVisibilityFeature: KeyboardVisibilityFeature? = null
// Registers a photo picker activity launcher in single-select mode.
private val singleMediaPicker =
AndroidPhotoPicker.singleMediaPicker(
@@ -220,6 +224,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
components.engineView = engineView
// Apply any pending viewport settings that were set before engineView was ready
GlobalComponents.viewportApi?.applyPendingSettings()
sessionFeature.set(
feature = SessionFeature(
components.core.store,
@@ -468,6 +475,13 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
components.core.historyStorage.registerStorageMaintenanceWorker()
// Start keyboard visibility detection if viewport events are available
GlobalComponents.viewportEvents?.let { viewportEvents ->
keyboardVisibilityFeature = KeyboardVisibilityFeature(viewportEvents).also {
it.start(binding.root)
}
}
} catch (e: Exception) {
Log.e("EngineCreation", "Failed to create engine: ${e.message}", e)
context?.let { restartApp(it) }
@@ -560,6 +574,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
override fun onDestroyView() {
super.onDestroyView()
// Stop keyboard visibility detection
keyboardVisibilityFeature?.stop()
keyboardVisibilityFeature = null
GlobalComponents.onPullToRefreshEnabledChanged = null
components.engineView?.setActivityContext(null)
_binding = null
@@ -14,7 +14,9 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -45,6 +47,12 @@ object GlobalComponents {
var onPullToRefreshEnabledChanged: ((Boolean) -> Unit)? = null
// Viewport events for keyboard visibility notifications
var viewportEvents: GeckoViewportEvents? = null
// Viewport API for applying pending settings when engineView becomes available
var viewportApi: GeckoViewportApiImpl? = null
@DelicateCoroutinesApi
private fun restoreBrowserState(newComponents: Components) =
GlobalScope.launch(Dispatchers.Main) {
@@ -44,6 +44,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.LogLevel
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewEvents
@@ -263,6 +265,17 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GeckoFetchApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFetchApiImpl())
GeckoBookmarksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBookmarksApiImpl())
// Viewport API for dynamic toolbar and keyboard handling
val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger)
val viewportApi = GeckoViewportApiImpl()
GeckoViewportApi.setUp(
_flutterPluginBinding.binaryMessenger,
viewportApi
)
// Store viewport events and API for keyboard feature and pending settings
GlobalComponents.viewportEvents = viewportEvents
GlobalComponents.viewportApi = viewportApi
ReaderViewEvents.setUp(
_flutterPluginBinding.binaryMessenger,
components.events.readerViewEvents
@@ -0,0 +1,93 @@
/*
* 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 eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportApi
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.
*/
class GeckoViewportApiImpl : GeckoViewportApi {
companion object {
private const val TAG = "GeckoViewportApi"
}
private val logger = Logger(TAG)
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
// Store the current dynamic toolbar max height
private var currentDynamicToolbarMaxHeight: Int = 0
/**
* 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).
*/
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
val height = heightPx.toInt()
currentDynamicToolbarMaxHeight = height
val engineView = components.engineView
if (engineView == null) {
logger.warn("$TAG: setDynamicToolbarMaxHeight called but engineView is null")
return
}
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
engineView.setDynamicToolbarMaxHeight(height)
}
/**
* 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).
*/
override fun setVerticalClipping(clippingPx: Long) {
val clipping = clippingPx.toInt()
val engineView = components.engineView
if (engineView == null) {
logger.warn("$TAG: setVerticalClipping called but engineView 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)
}
}
}
@@ -0,0 +1,223 @@
/*
* 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.feature
import android.os.Build
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowInsets
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
/**
* Feature that detects keyboard visibility changes and reports them to Flutter.
*
* Uses WindowInsets API for accurate keyboard detection on Android 11+ (API 30+),
* with fallback to ViewTreeObserver.OnGlobalLayoutListener for older versions.
*/
class KeyboardVisibilityFeature(
private val flutterEvents: GeckoViewportEvents
) {
companion object {
private const val TAG = "KeyboardVisibilityFeature"
}
private val logger = Logger(TAG)
private var rootView: View? = null
private var lastKeyboardHeight: Int = 0
private var lastKeyboardVisible: Boolean = false
private var isAnimating: Boolean = false
// For legacy keyboard detection (pre-API 30)
private var globalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
// For modern keyboard animation tracking (API 30+)
private var insetsAnimationCallback: WindowInsetsAnimationCompat.Callback? = null
/**
* Start observing keyboard visibility changes on the given root view.
*/
fun start(view: View) {
rootView = view
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setupModernKeyboardDetection(view)
} else {
setupLegacyKeyboardDetection(view)
}
// Also set up WindowInsets listener for immediate state
setupInsetsListener(view)
logger.debug("$TAG: Started keyboard visibility detection")
}
/**
* Stop observing keyboard visibility changes.
*/
fun stop() {
rootView?.let { view ->
globalLayoutListener?.let {
view.viewTreeObserver.removeOnGlobalLayoutListener(it)
}
insetsAnimationCallback?.let {
ViewCompat.setWindowInsetsAnimationCallback(view, null)
}
}
globalLayoutListener = null
insetsAnimationCallback = null
rootView = null
logger.debug("$TAG: Stopped keyboard visibility detection")
}
/**
* Modern keyboard detection using WindowInsetsAnimation (API 30+).
* This provides smooth animation callbacks during keyboard show/hide.
*/
private fun setupModernKeyboardDetection(view: View) {
insetsAnimationCallback = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
isAnimating = true
}
}
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>
): WindowInsetsCompat {
// Find IME animation
val imeAnimation = runningAnimations.find {
it.typeMask and WindowInsetsCompat.Type.ime() != 0
}
if (imeAnimation != null) {
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
// Keyboard height is IME height minus navigation bar (which is included)
val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)
// During animation, report height changes
if (keyboardHeight != lastKeyboardHeight) {
notifyKeyboardChange(keyboardHeight, keyboardHeight > 0, isAnimating = true)
}
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
isAnimating = false
// Send final state
notifyKeyboardChange(lastKeyboardHeight, lastKeyboardVisible, isAnimating = false)
}
}
}
ViewCompat.setWindowInsetsAnimationCallback(view, insetsAnimationCallback)
}
/**
* Legacy keyboard detection using ViewTreeObserver (pre-API 30).
*/
private fun setupLegacyKeyboardDetection(view: View) {
globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
val insets = ViewCompat.getRootWindowInsets(view) ?: return@OnGlobalLayoutListener
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)
val isVisible = keyboardHeight > 0
if (keyboardHeight != lastKeyboardHeight || isVisible != lastKeyboardVisible) {
notifyKeyboardChange(keyboardHeight, isVisible, isAnimating = false)
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
}
/**
* Set up WindowInsets listener for immediate keyboard state.
*/
private fun setupInsetsListener(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val keyboardHeight = if (imeVisible) {
(imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)
} else {
0
}
// Only notify if not currently animating (animation callbacks handle that)
if (!isAnimating && (keyboardHeight != lastKeyboardHeight || imeVisible != lastKeyboardVisible)) {
notifyKeyboardChange(keyboardHeight, imeVisible, isAnimating = false)
}
insets
}
}
/**
* Notify Flutter about keyboard visibility change.
*/
private fun notifyKeyboardChange(heightPx: Int, isVisible: Boolean, isAnimating: Boolean) {
lastKeyboardHeight = heightPx
lastKeyboardVisible = isVisible
val timestamp = System.currentTimeMillis()
logger.debug("$TAG: Keyboard change - height=$heightPx, visible=$isVisible, animating=$isAnimating")
runOnUiThread {
flutterEvents.onKeyboardVisibilityChanged(
timestamp,
heightPx.toLong(),
isVisible,
isAnimating
) { /* callback - ignore result */ }
}
}
/**
* Manually trigger a keyboard state check.
* Useful after configuration changes or when resuming.
*/
fun checkKeyboardState() {
rootView?.let { view ->
val insets = ViewCompat.getRootWindowInsets(view) ?: return
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val keyboardHeight = if (imeVisible) {
(imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)
} else {
0
}
notifyKeyboardChange(keyboardHeight, imeVisible, isAnimating = false)
}
}
}
@@ -5995,6 +5995,137 @@ interface GeckoFetchApi {
}
}
}
/**
* Controls GeckoView's viewport behavior for dynamic toolbar and keyboard handling.
*
* This API allows Flutter to control how GeckoView adjusts its internal viewport
* without resizing the platform view itself, avoiding visual flickering.
*
* The dynamic toolbar system works by:
* 1. Setting the maximum toolbar height via [setDynamicToolbarMaxHeight]
* 2. Updating the vertical clipping as toolbar animates via [setVerticalClipping]
* 3. GeckoView internally adjusts viewport and notifies the website
*
* Generated interface from Pigeon that represents a handler of messages from Flutter.
*/
interface GeckoViewportApi {
/**
* 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).
*
* Call this once when toolbar dimensions are known, and again if they change.
*
* [heightPx] Combined height of top and bottom toolbars in pixels.
*/
fun setDynamicToolbarMaxHeight(heightPx: Long)
/**
* 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).
*
* Call this during toolbar animation frames to smoothly adjust the visible area.
*
* [clippingPx] The clipping offset in pixels. Negative = bottom clip.
*/
fun setVerticalClipping(clippingPx: Long)
companion object {
/** The codec used by GeckoViewportApi. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
/** Sets up an instance of `GeckoViewportApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoViewportApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportApi.setDynamicToolbarMaxHeight$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val heightPxArg = args[0] as Long
val wrapped: List<Any?> = try {
api.setDynamicToolbarMaxHeight(heightPxArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportApi.setVerticalClipping$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val clippingPxArg = args[0] as Long
val wrapped: List<Any?> = try {
api.setVerticalClipping(clippingPxArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/**
* Events from native side about viewport and input-related changes.
*
* These events allow Flutter to react to native viewport changes,
* particularly keyboard visibility which is detected natively.
*
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
*/
class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by GeckoViewportEvents. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
}
/**
* Called when keyboard visibility changes.
*
* This is detected natively using WindowInsets API and provides
* accurate keyboard height information.
*
* [timestamp] Event timestamp 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)
{
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)) {
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)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoBookmarksApi {
/**