gesture feature initial

This commit is contained in:
Fabian Freund
2026-05-31 11:40:57 +02:00
parent 4aecb8966c
commit 4d4c8a8786
45 changed files with 4729 additions and 5 deletions
@@ -55,7 +55,7 @@ import mozilla.components.feature.prompts.file.AndroidPhotoPicker
import mozilla.components.feature.session.FullScreenFeature
import mozilla.components.feature.session.PictureInPictureFeature
import mozilla.components.feature.session.SessionFeature
import mozilla.components.feature.session.SwipeRefreshFeature
import eu.weblibre.flutter_mozilla_components.feature.GestureAwareSwipeRefreshFeature
import mozilla.components.feature.sitepermissions.SitePermissionsFeature
import mozilla.components.feature.sitepermissions.SitePermissionsRules
import mozilla.components.feature.sitepermissions.SitePermissionsRules.AutoplayAction
@@ -87,7 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
private val swipeRefreshFeature = ViewBoundFeatureWrapper<SwipeRefreshFeature>()
private val swipeRefreshFeature = ViewBoundFeatureWrapper<GestureAwareSwipeRefreshFeature>()
private val secureWindowFeature = ViewBoundFeatureWrapper<SecureWindowFeature>()
private val fullScreenFeature = ViewBoundFeatureWrapper<FullScreenFeature>()
private val mediaSessionFullscreenFeature =
@@ -272,7 +272,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
)
swipeRefreshFeature.set(
feature = SwipeRefreshFeature(
feature = GestureAwareSwipeRefreshFeature(
components.core.store,
components.useCases.sessionUseCases.reload,
binding.swipeToRefresh,
@@ -14,12 +14,14 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService
@@ -97,6 +99,38 @@ object GlobalComponents {
// Engine settings API for managing engine-specific settings
var engineSettingsApi: GeckoEngineSettingsApiImpl? = null
// Touch-gesture recognition: event sink (Kotlin → Dart) and the current
// configuration pushed from Dart. Read by the browser container's
// GestureRecognizer on the UI thread.
var gestureEvents: GeckoGestureEvents? = null
@Volatile
var gestureConfig: GestureConfig? = null
/**
* Set true when the in-flight touch sequence was recognized as a configured
* gesture, so pull-to-refresh ([GestureAwareSwipeRefreshFeature]) can
* suppress the otherwise-redundant reload for down-leading gestures started
* at the top of the page. Reset on each ACTION_DOWN by the gesture
* container. Read and written on the UI thread.
*/
@Volatile
var touchConsumedByGesture: Boolean = false
// Current dynamic-toolbar viewport insets (physical px), tracked from the
// viewport API so gesture edge-detection can exclude the bottom toolbar
// area the engine view never receives touches in.
@Volatile
var dynamicToolbarMaxHeightPx: Int = 0
@Volatile
var verticalClippingPx: Int = 0
/** Currently visible bottom inset: full toolbar height when shown, 0 when
* auto-hidden (clipping cancels it out). */
val bottomViewportInsetPx: Int
get() = (dynamicToolbarMaxHeightPx + verticalClippingPx).coerceAtLeast(0)
// External download manager setting
var useExternalDownloadManager: Boolean = false
@@ -41,6 +41,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPublicSuffixListApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSitePermissionsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
@@ -337,6 +339,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GlobalComponents.viewportEvents = viewportEvents
GlobalComponents.viewportApi = viewportApi
// Touch-gesture recognition: event sink (Kotlin → Dart) + config API
GlobalComponents.gestureEvents =
GeckoGestureEvents(_flutterPluginBinding.binaryMessenger)
GeckoGestureApi.setUp(
_flutterPluginBinding.binaryMessenger,
GeckoGestureApiImpl()
)
ReaderViewEvents.setUp(
_flutterPluginBinding.binaryMessenger,
components.events.readerViewEvents
@@ -0,0 +1,22 @@
/*
* 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.GeckoGestureApi
import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
/**
* Stores the gesture-recognition configuration pushed from Dart. The browser
* container's [eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer]
* reads it on the UI thread for every touch event.
*/
class GeckoGestureApiImpl : GeckoGestureApi {
override fun setGestureConfig(config: GestureConfig) {
GlobalComponents.gestureConfig = config
}
}
@@ -36,6 +36,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
*/
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
val height = heightPx.toInt()
GlobalComponents.dynamicToolbarMaxHeightPx = height
val engineView = components.mainBrowserEngineView
if (engineView == null) {
@@ -65,6 +66,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
*/
override fun setVerticalClipping(clippingPx: Long) {
val clipping = clippingPx.toInt()
GlobalComponents.verticalClippingPx = clipping
val engineView = components.mainBrowserEngineView
if (engineView == null) {
@@ -0,0 +1,111 @@
/* 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.feature
import android.os.Build
import android.view.HapticFeedbackConstants
import android.view.View
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.action.ContentAction.UpdateRefreshCanceledStateAction
import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
/**
* A gesture-aware variant of Mozilla's `SwipeRefreshFeature`.
*
* Behaves exactly like the upstream feature (coordinates a [SwipeRefreshLayout]
* with the session's loading state and reloads on a pull-down at the top of the
* page), with one addition: if the same touch sequence was recognized as a
* configured touch gesture, the reload is suppressed.
*
* Why: the gesture recognizer in `BackGestureFilterFrameLayout` is purely
* observational (it never consumes events), so a down-leading gesture — e.g.
* `D-R` (back) or `D-R-U` (reload) — that starts at the top of the page also
* drives the pull-to-refresh throbber and would otherwise fire a redundant
* reload on release. This mirrors the reference add-on's pull-to-refresh
* `continue()`/`end()` guards, which let pull-to-refresh act only as the
* fallback for a plain straight-down pull that matches no gesture.
*
* The recognizer flags [GlobalComponents.touchConsumedByGesture] on the
* terminating ACTION_UP (which dispatches to the ancestor container before this
* layout's own up-handling runs [onRefresh]), so the flag is reliably set by the
* time we consult it here.
*
* Derived from android-components `SwipeRefreshFeature` (MPL-2.0).
*/
class GestureAwareSwipeRefreshFeature(
private val store: BrowserStore,
private val reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase,
private val swipeRefreshLayout: SwipeRefreshLayout,
private val tabId: String? = null,
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : LifecycleAwareFeature,
SwipeRefreshLayout.OnChildScrollUpCallback,
SwipeRefreshLayout.OnRefreshListener {
private var scope: CoroutineScope? = null
init {
swipeRefreshLayout.setOnRefreshListener(this)
swipeRefreshLayout.setOnChildScrollUpCallback(this)
}
override fun start() {
scope = store.flowScoped(dispatcher = mainDispatcher) { flow ->
flow.map { state -> state.findTabOrCustomTabOrSelectedTab(tabId) }
.ifAnyChanged {
arrayOf(it?.content?.loading, it?.content?.refreshCanceled)
}
.collect { tab ->
tab?.let {
if (!tab.content.loading || tab.content.refreshCanceled) {
swipeRefreshLayout.isRefreshing = false
if (tab.content.refreshCanceled) {
store.dispatch(UpdateRefreshCanceledStateAction(tab.id, false))
}
}
}
}
}
}
override fun stop() {
scope?.cancel()
}
@Suppress("Deprecation")
override fun canChildScrollUp(parent: SwipeRefreshLayout, child: View?) =
if (child is EngineView) {
!child.getInputResultDetail().canOverscrollTop()
} else {
true
}
override fun onRefresh() {
// A configured touch gesture already handled this stroke; don't also
// reload. Retract the throbber the layout showed during the pull.
if (GlobalComponents.touchConsumedByGesture) {
swipeRefreshLayout.isRefreshing = false
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
swipeRefreshLayout.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
}
store.state.findTabOrCustomTabOrSelectedTab(tabId)?.let { tab ->
reloadUrlUseCase(tab.id)
}
}
}
@@ -0,0 +1,234 @@
/*
* 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.view.MotionEvent
import eu.weblibre.flutter_mozilla_components.pigeons.GestureConfig
import kotlin.math.abs
import kotlin.math.min
/**
* Pure, view-agnostic touch-gesture recognizer.
*
* Ported from the Simple Gesture add-on
* (https://github.com/utubo/firefox-simple_gesture, MPL-2.0, Copyright 2017
* utubo): the same stroke grammar is used so user configuration carries over.
* A gesture is encoded as a canonical key built from three parts:
*
* - an optional **start-position** prefix describing where the touch began
* (`L:`/`R:`/`T:`/`B:` for the four edges, `W:`/`E:` for the left/right
* half otherwise),
* - an optional **finger-count** prefix (`2:`, `3:` …; omitted for a single
* finger), and
* - the dash-joined sequence of dominant **directions** (`U`/`D`/`L`/`R`),
*
* e.g. `R:2:D-L` (two fingers, started at the right edge, moved down then
* left) or simply `D-R`.
*
* Recognition is **observational**: callers feed every [MotionEvent] via [feed]
* without consuming it, and [feed] returns the matched key on the terminating
* `ACTION_UP` only when the assembled stroke matches an entry in
* [GestureConfig.activeGestureKeys]. Pages therefore scroll and tap normally;
* multi-stroke gestures simply fire their action on release.
*/
class GestureRecognizer {
/**
* Current configuration. Assigned by the owner (the browser container)
* before each event so updates pushed from Dart take effect immediately.
*/
var config: GestureConfig? = null
/**
* Invoked on the UI thread whenever a new direction arrow is appended to the
* in-progress stroke, with the current partial canonical key (e.g. `R:D`).
* The owner uses this both to restart the idle-timeout (matching the
* reference add-on, which restarts only on a new arrow) and to drive the
* live gesture-feedback overlay.
*/
var onProgress: ((String) -> Unit)? = null
private val arrows = ArrayList<Char>(MAX_ARROWS + 1)
private var startPosition = ""
private var fingers = ""
private var fingersNum = 1
private var lastX = 0f
private var lastY = 0f
private var lastArrow = ' '
private var strokeSize = 0f
private var edgeWidth = 0f
private var contentBottom = 0f
private var aborted = false
private var active = false
/**
* Feeds one motion event. Returns the recognized gesture key on the
* terminating `ACTION_UP`, or null otherwise. Never consumes the event.
*
* [bottomInsetPx] is the height (physical px) of the dynamic bottom toolbar
* currently overlaying the engine view. It is subtracted from the view
* height so the bottom edge zone tracks the visible content edge rather than
* the toolbar area (which never receives touches).
*/
fun feed(
event: MotionEvent,
viewWidth: Int,
viewHeight: Int,
bottomInsetPx: Int,
): String? {
val cfg = config
if (cfg == null || !cfg.enabled || cfg.activeGestureKeys.isEmpty()) {
active = false
return null
}
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
onDown(event, cfg, viewWidth, viewHeight, bottomInsetPx)
null
}
MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_MOVE -> {
onMove(event, cfg)
null
}
MotionEvent.ACTION_UP -> onUp(cfg)
MotionEvent.ACTION_CANCEL -> {
reset()
null
}
else -> null
}
}
/** Discards any in-progress gesture (e.g. on idle timeout or back-gesture
* interception). */
fun cancel() = reset()
private fun onDown(
event: MotionEvent,
cfg: GestureConfig,
w: Int,
h: Int,
bottomInsetPx: Int,
) {
reset()
val minSide = min(w, h).toFloat()
if (minSide <= 0f) {
aborted = true
return
}
active = true
// Scale the configured base stroke length to the screen, matching the
// reference add-on's `strokeSize * min(w, h) / 320`.
strokeSize = cfg.strokeSize * minSide / REFERENCE_SHORT_SIDE
edgeWidth = minSide / EDGE_DIVISOR
// The visible content ends above the dynamic bottom toolbar.
contentBottom = (h - bottomInsetPx).toFloat()
lastX = event.getX(0)
lastY = event.getY(0)
startPosition = computeStartPosition(lastX, lastY, w)
}
private fun onMove(event: MotionEvent, cfg: GestureConfig) {
if (!active || aborted) return
if (!setupFingers(event, cfg)) return
if (arrows.size > MAX_ARROWS) return
val x = event.getX(0)
val y = event.getY(0)
val dx = x - lastX
val dy = y - lastY
val absX = abs(dx)
val absY = abs(dy)
if (absX < strokeSize && absY < strokeSize) return
lastX = x
lastY = y
val arrow = if (absX < absY) {
if (dy < 0) 'U' else 'D'
} else {
if (dx < 0) 'L' else 'R'
}
// Collapse consecutive identical directions into a single stroke.
if (arrow == lastArrow) return
lastArrow = arrow
arrows.add(arrow)
onProgress?.invoke(currentKey())
}
private fun onUp(cfg: GestureConfig): String? {
val result = if (active && !aborted && arrows.isNotEmpty()) matchKey(cfg) else null
reset()
return result
}
private fun setupFingers(event: MotionEvent, cfg: GestureConfig): Boolean {
val count = event.pointerCount
if (count > cfg.maxFingers) {
aborted = true
return false
}
if (fingersNum < count) {
fingersNum = count
fingers = "$count:"
}
return true
}
/**
* The current in-progress canonical key including the start-position and
* finger prefixes, e.g. `R:2:D-L`. Used for live feedback, where the
* consumer matches it against configured bindings to suggest completions.
*/
private fun currentKey(): String = startPosition + fingers + arrows.joinToString("-")
private fun matchKey(cfg: GestureConfig): String? {
val input = fingers + arrows.joinToString("-")
val withStart = startPosition + input
return when {
cfg.activeGestureKeys.contains(withStart) -> withStart
cfg.activeGestureKeys.contains(input) -> input
else -> null
}
}
private fun computeStartPosition(x: Float, y: Float, w: Int): String {
return when {
x < edgeWidth -> "L:"
x > w - edgeWidth -> "R:"
y < edgeWidth -> "T:"
y > contentBottom - edgeWidth -> "B:"
x < w / 2f -> "W:"
else -> "E:"
}
}
private fun reset() {
arrows.clear()
startPosition = ""
fingers = ""
fingersNum = 1
lastArrow = ' '
aborted = false
active = false
}
companion object {
/** Maximum number of direction strokes per gesture. */
private const val MAX_ARROWS = 9
/** Reference short-side length the base stroke size is calibrated for. */
private const val REFERENCE_SHORT_SIDE = 320f
/** Edge-zone width is `min(width, height) / EDGE_DIVISOR`. */
private const val EDGE_DIVISOR = 10f
}
}
@@ -5572,6 +5572,81 @@ data class SandboxCaptureEntry (
return result
}
}
/**
* Configuration for native touch-gesture recognition.
*
* Pushed from Dart whenever the user's gesture settings change. Native
* recognition is purely observational: it assembles a canonical stroke key
* (start-position prefix + finger-count prefix + dash-joined directions, e.g.
* `R:2:D-L`) and only emits when that key matches an entry in
* [activeGestureKeys]. Strokes that do not match are ignored, so normal
* scrolling, tapping and pinch-zoom are never affected.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class GestureConfig (
val enabled: Boolean,
/**
* Base stroke length in logical pixels, scaled at runtime by
* `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on.
*/
val strokeSize: Long,
/**
* Milliseconds of inactivity after which an in-progress gesture is
* discarded.
*/
val timeoutMs: Long,
/** Maximum number of simultaneous pointers a gesture may use. */
val maxFingers: Long,
/**
* Canonical keys that currently have an action bound, e.g. `D-R`,
* `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized]
* when an assembled stroke matches one of these.
*/
val activeGestureKeys: List<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): GestureConfig {
val enabled = pigeonVar_list[0] as Boolean
val strokeSize = pigeonVar_list[1] as Long
val timeoutMs = pigeonVar_list[2] as Long
val maxFingers = pigeonVar_list[3] as Long
val activeGestureKeys = pigeonVar_list[4] as List<String>
return GestureConfig(enabled, strokeSize, timeoutMs, maxFingers, activeGestureKeys)
}
}
fun toList(): List<Any?> {
return listOf(
enabled,
strokeSize,
timeoutMs,
maxFingers,
activeGestureKeys,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as GestureConfig
return GeckoPigeonUtils.deepEquals(this.enabled, other.enabled) && GeckoPigeonUtils.deepEquals(this.strokeSize, other.strokeSize) && GeckoPigeonUtils.deepEquals(this.timeoutMs, other.timeoutMs) && GeckoPigeonUtils.deepEquals(this.maxFingers, other.maxFingers) && GeckoPigeonUtils.deepEquals(this.activeGestureKeys, other.activeGestureKeys)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + GeckoPigeonUtils.deepHash(this.enabled)
result = 31 * result + GeckoPigeonUtils.deepHash(this.strokeSize)
result = 31 * result + GeckoPigeonUtils.deepHash(this.timeoutMs)
result = 31 * result + GeckoPigeonUtils.deepHash(this.maxFingers)
result = 31 * result + GeckoPigeonUtils.deepHash(this.activeGestureKeys)
return result
}
}
private open class GeckoPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -6190,6 +6265,11 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
SandboxCaptureEntry.fromList(it)
}
}
252.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
GestureConfig.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -6687,6 +6767,10 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
stream.write(251)
writeValue(stream, value.toList())
}
is GestureConfig -> {
stream.write(252)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -11829,3 +11913,124 @@ class SandboxCaptureHostEvents(private val binaryMessenger: BinaryMessenger, pri
}
}
}
/**
* Dart Kotlin. Pushes the current gesture-recognition configuration.
*
* Generated interface from Pigeon that represents a handler of messages from Flutter.
*/
interface GeckoGestureApi {
fun setGestureConfig(config: GestureConfig)
companion object {
/** The codec used by GeckoGestureApi. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
/** Sets up an instance of `GeckoGestureApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoGestureApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val configArg = args[0] as GestureConfig
val wrapped: List<Any?> = try {
api.setGestureConfig(configArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/**
* Kotlin Dart. Emitted when an assembled touch stroke matches a configured
* gesture key.
*
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
*/
class GeckoGestureEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by GeckoGestureEvents. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
}
/**
* [sequence] Event sequence number for ordering.
* [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`.
*/
fun onGestureRecognized(sequenceArg: Long, gestureKeyArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, gestureKeyArg)) {
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)))
}
}
}
/**
* Emitted while a stroke is being drawn, each time a new direction arrow is
* appended. Drives the live feedback overlay.
*
* [sequence] Event sequence number for ordering.
* [partialKey] Current partial canonical key including start/finger
* prefixes, e.g. `R:D`.
*/
fun onGestureProgress(sequenceArg: Long, partialKeyArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, partialKeyArg)) {
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)))
}
}
}
/**
* Emitted when an in-progress stroke ends (release, cancel or idle timeout)
* so the live feedback overlay can be hidden.
*
* [sequence] Event sequence number for ordering.
*/
fun onGestureReset(sequenceArg: Long, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg)) {
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)))
}
}
}
}
@@ -12,6 +12,9 @@ import android.content.Context
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.core.view.WindowInsetsCompat
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.feature.GestureRecognizer
import kotlin.math.abs
/**
@@ -34,6 +37,12 @@ import kotlin.math.abs
*
* Taps and vertical drags that originate in the inset still reach the engine
* view, so links and vertical scrolling continue to work at the edges.
*
* This container is also the single observation point for configurable touch
* gestures: every event is fed to a [GestureRecognizer] before the back-gesture
* filter runs. Recognition is purely observational (events are never consumed
* for gestures), so pages scroll and tap normally; a recognized multi-stroke
* gesture is reported to Dart on touch-up via [GlobalComponents.gestureEvents].
*/
class BackGestureFilterFrameLayout(
context: Context,
@@ -44,7 +53,35 @@ class BackGestureFilterFrameLayout(
private var startedInEdgeZone = false
private var hasIntercepted = false
private val gestureRecognizer = GestureRecognizer()
private val gestureTimeoutRunnable = Runnable { cancelGesture() }
/**
* Whether the live feedback overlay is currently being shown for an
* in-progress stroke. Tracked so a reset is reported to Dart only after a
* progress event, keeping plain taps and scrolls off the platform channel.
*/
private var gestureFeedbackActive = false
init {
// A new direction arrow was registered: restart the idle-timeout (the
// reference add-on restarts only on a new arrow, not on every move) and
// forward the partial stroke to the live feedback overlay.
gestureRecognizer.onProgress = { partialKey ->
val config = GlobalComponents.gestureConfig
if (config != null && config.enabled) {
removeCallbacks(gestureTimeoutRunnable)
postDelayed(gestureTimeoutRunnable, config.timeoutMs)
gestureFeedbackActive = true
GlobalComponents.gestureEvents
?.onGestureProgress(EventSequence.next(), partialKey) { }
}
}
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
feedGestureRecognizer(ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = ev.x
@@ -68,6 +105,9 @@ class BackGestureFilterFrameLayout(
// already scrolled the page. Cancel APZ now and swallow the rest.
dx > dy + 1 -> {
hasIntercepted = true
// The stream now belongs to the system back gesture;
// discard any partial touch gesture so it can't fire.
cancelGesture()
// Log.d(TAG, "INTERCEPT dx=$dx dy=$dy")
dispatchSyntheticCancel(ev)
return true
@@ -93,6 +133,69 @@ class BackGestureFilterFrameLayout(
return super.dispatchTouchEvent(ev)
}
/**
* Observes every touch event for configurable gestures. Reads the latest
* config pushed from Dart, drives the idle-timeout, and reports a matched
* gesture key on touch-up. Runs on the UI thread (dispatchTouchEvent), so
* the event sink can be invoked directly.
*/
private fun feedGestureRecognizer(ev: MotionEvent) {
val config = GlobalComponents.gestureConfig
gestureRecognizer.config = config
val key = gestureRecognizer.feed(
ev,
width,
height,
GlobalComponents.bottomViewportInsetPx,
)
when (ev.actionMasked) {
// Start the idle window on touch-down; subsequent restarts happen
// only when the recognizer reports a new arrow (see onProgress).
MotionEvent.ACTION_DOWN -> {
// New touch: clear any gesture claim so pull-to-refresh is only
// suppressed when this stroke actually matches a gesture.
GlobalComponents.touchConsumedByGesture = false
if (config != null && config.enabled) {
removeCallbacks(gestureTimeoutRunnable)
postDelayed(gestureTimeoutRunnable, config.timeoutMs)
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
removeCallbacks(gestureTimeoutRunnable)
emitGestureReset()
}
}
if (key != null) {
// Mark this touch as gesture-handled before the event propagates to
// the swipe-refresh layout's own ACTION_UP handling, so a redundant
// pull-to-refresh reload is suppressed (see
// GestureAwareSwipeRefreshFeature).
GlobalComponents.touchConsumedByGesture = true
GlobalComponents.gestureEvents
?.onGestureRecognized(EventSequence.next(), key) { }
}
}
/** Discards any in-progress gesture and clears its feedback overlay. */
private fun cancelGesture() {
removeCallbacks(gestureTimeoutRunnable)
gestureRecognizer.cancel()
emitGestureReset()
}
/** Tells Dart to hide the live feedback overlay, if one is showing. */
private fun emitGestureReset() {
if (!gestureFeedbackActive) return
gestureFeedbackActive = false
GlobalComponents.gestureEvents
?.onGestureReset(EventSequence.next()) { }
}
private fun dispatchSyntheticCancel(source: MotionEvent) {
val cancel = MotionEvent.obtain(source).apply {
action = MotionEvent.ACTION_CANCEL
@@ -20,6 +20,7 @@ export 'src/domain/services/gecko_engine_settings.dart';
export 'src/domain/services/gecko_event.dart';
export 'src/domain/services/gecko_fetch_service.dart';
export 'src/domain/services/gecko_find_in_page.dart';
export 'src/domain/services/gecko_gesture.dart';
export 'src/domain/services/gecko_history.dart';
export 'src/domain/services/gecko_icon.dart';
export 'src/domain/services/gecko_logging.dart';
@@ -80,6 +81,7 @@ export 'src/pigeons/gecko.g.dart'
GeckoSuggestionType,
GeckoTrackingProtectionApi,
GeoHitResult,
GestureConfig,
HistoryHighlight,
HistoryHighlightWeights,
HistoryMetadata,
@@ -0,0 +1,98 @@
/*
* 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/.
*/
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/src/extensions/subject.dart';
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart';
import 'package:rxdart/rxdart.dart';
/// Service for native touch-gesture recognition.
///
/// Pushes the user's gesture configuration to the native recognizer via
/// [setGestureConfig] and exposes a stream of recognized gesture keys. The
/// native side is purely observational (it never consumes touch input for
/// gestures), so recognized events fire on touch release for multi-stroke
/// gestures only.
///
/// A gesture key is the canonical encoding shared with native: an optional
/// start-position prefix (`L:`/`R:`/`T:`/`B:`/`W:`/`E:`), an optional
/// finger-count prefix (`2:` …), and the dash-joined directions (`U`/`D`/`L`/
/// `R`), e.g. `R:2:D-L` or `D-R`.
class GeckoGestureService extends GeckoGestureEvents {
final GeckoGestureApi _api;
final _recognizedGestureSubject = PublishSubject<String>();
final _gestureProgressSubject = BehaviorSubject<String?>.seeded(null);
/// Stream of recognized gesture keys.
///
/// Emits the canonical key (e.g. `D-R`) each time the native recognizer
/// matches a configured gesture.
Stream<String> get recognizedGestures => _recognizedGestureSubject.stream;
/// Stream of the in-progress stroke for the live feedback overlay.
///
/// Emits the current partial canonical key (e.g. `R:D`) each time a new
/// arrow is drawn, and `null` when the stroke ends (release, cancel or idle
/// timeout). Consumers render the overlay while non-null.
Stream<String?> get gestureProgress => _gestureProgressSubject.stream;
/// Creates a new gesture service.
///
/// Call [setUp] to register the event handlers after construction.
GeckoGestureService({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : _api = GeckoGestureApi(
binaryMessenger: binaryMessenger,
messageChannelSuffix: messageChannelSuffix,
);
/// Sets up the service to receive events from native.
///
/// Must be called before events will be received.
void setUp({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
GeckoGestureEvents.setUp(
this,
binaryMessenger: binaryMessenger,
messageChannelSuffix: messageChannelSuffix,
);
}
/// Pushes the gesture-recognition configuration to native.
///
/// Call whenever the user's gesture settings change.
Future<void> setGestureConfig(GestureConfig config) async {
await _api.setGestureConfig(config);
}
// GeckoGestureEvents implementation
@override
void onGestureRecognized(int sequence, String gestureKey) {
_recognizedGestureSubject.addWhenMoreRecent(sequence, null, gestureKey);
}
@override
void onGestureProgress(int sequence, String partialKey) {
_gestureProgressSubject.addWhenMoreRecent(sequence, null, partialKey);
}
@override
void onGestureReset(int sequence) {
_gestureProgressSubject.addWhenMoreRecent(sequence, null, null);
}
/// Disposes the service and closes all streams.
Future<void> dispose() async {
await _recognizedGestureSubject.close();
await _gestureProgressSubject.close();
}
}
@@ -6098,6 +6098,87 @@ class SandboxCaptureEntry {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
/// Configuration for native touch-gesture recognition.
///
/// Pushed from Dart whenever the user's gesture settings change. Native
/// recognition is purely observational: it assembles a canonical stroke key
/// (start-position prefix + finger-count prefix + dash-joined directions, e.g.
/// `R:2:D-L`) and only emits when that key matches an entry in
/// [activeGestureKeys]. Strokes that do not match are ignored, so normal
/// scrolling, tapping and pinch-zoom are never affected.
class GestureConfig {
GestureConfig({
required this.enabled,
required this.strokeSize,
required this.timeoutMs,
required this.maxFingers,
required this.activeGestureKeys,
});
bool enabled;
/// Base stroke length in logical pixels, scaled at runtime by
/// `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on.
int strokeSize;
/// Milliseconds of inactivity after which an in-progress gesture is
/// discarded.
int timeoutMs;
/// Maximum number of simultaneous pointers a gesture may use.
int maxFingers;
/// Canonical keys that currently have an action bound, e.g. `D-R`,
/// `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized]
/// when an assembled stroke matches one of these.
List<String> activeGestureKeys;
List<Object?> _toList() {
return <Object?>[
enabled,
strokeSize,
timeoutMs,
maxFingers,
activeGestureKeys,
];
}
Object encode() {
return _toList();
}
static GestureConfig decode(Object result) {
result as List<Object?>;
return GestureConfig(
enabled: result[0]! as bool,
strokeSize: result[1]! as int,
timeoutMs: result[2]! as int,
maxFingers: result[3]! as int,
activeGestureKeys: (result[4]! as List<Object?>).cast<String>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! GestureConfig || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(enabled, other.enabled) &&
_deepEquals(strokeSize, other.strokeSize) &&
_deepEquals(timeoutMs, other.timeoutMs) &&
_deepEquals(maxFingers, other.maxFingers) &&
_deepEquals(activeGestureKeys, other.activeGestureKeys);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -6474,6 +6555,9 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is SandboxCaptureEntry) {
buffer.putUint8(251);
writeValue(buffer, value.encode());
} else if (value is GestureConfig) {
buffer.putUint8(252);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -6769,6 +6853,8 @@ class _PigeonCodec extends StandardMessageCodec {
return PwaManifest.decode(readValue(buffer)!);
case 251:
return SandboxCaptureEntry.decode(readValue(buffer)!);
case 252:
return GestureConfig.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -12745,3 +12831,153 @@ abstract class SandboxCaptureHostEvents {
}
}
}
/// Dart → Kotlin. Pushes the current gesture-recognition configuration.
class GeckoGestureApi {
/// Constructor for [GeckoGestureApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
GeckoGestureApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<void> setGestureConfig(GestureConfig config) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureApi.setGestureConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[config],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
}
/// Kotlin → Dart. Emitted when an assembled touch stroke matches a configured
/// gesture key.
abstract class GeckoGestureEvents {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
/// [sequence] Event sequence number for ordering.
/// [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`.
void onGestureRecognized(int sequence, String gestureKey);
/// Emitted while a stroke is being drawn, each time a new direction arrow is
/// appended. Drives the live feedback overlay.
///
/// [sequence] Event sequence number for ordering.
/// [partialKey] Current partial canonical key including start/finger
/// prefixes, e.g. `R:D`.
void onGestureProgress(int sequence, String partialKey);
/// Emitted when an in-progress stroke ends (release, cancel or idle timeout)
/// so the live feedback overlay can be hidden.
///
/// [sequence] Event sequence number for ordering.
void onGestureReset(int sequence);
static void setUp(
GeckoGestureEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureRecognized$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final int arg_sequence = args[0]! as int;
final String arg_gestureKey = args[1]! as String;
try {
api.onGestureRecognized(arg_sequence, arg_gestureKey);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureProgress$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final int arg_sequence = args[0]! as int;
final String arg_partialKey = args[1]! as String;
try {
api.onGestureProgress(arg_sequence, arg_partialKey);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_mozilla_components.GeckoGestureEvents.onGestureReset$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final int arg_sequence = args[0]! as int;
try {
api.onGestureReset(arg_sequence);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
}
}
@@ -3004,3 +3004,72 @@ abstract class SandboxCaptureHostEvents {
String targetUrl,
);
}
// =============================================================================
// Touch Gestures API
// =============================================================================
/// Configuration for native touch-gesture recognition.
///
/// Pushed from Dart whenever the user's gesture settings change. Native
/// recognition is purely observational: it assembles a canonical stroke key
/// (start-position prefix + finger-count prefix + dash-joined directions, e.g.
/// `R:2:D-L`) and only emits when that key matches an entry in
/// [activeGestureKeys]. Strokes that do not match are ignored, so normal
/// scrolling, tapping and pinch-zoom are never affected.
class GestureConfig {
final bool enabled;
/// Base stroke length in logical pixels, scaled at runtime by
/// `min(viewWidth, viewHeight) / 320` to match the reference gesture add-on.
final int strokeSize;
/// Milliseconds of inactivity after which an in-progress gesture is
/// discarded.
final int timeoutMs;
/// Maximum number of simultaneous pointers a gesture may use.
final int maxFingers;
/// Canonical keys that currently have an action bound, e.g. `D-R`,
/// `R:2:D-L`. Native only emits [GeckoGestureEvents.onGestureRecognized]
/// when an assembled stroke matches one of these.
final List<String> activeGestureKeys;
GestureConfig({
this.enabled = false,
this.strokeSize = 50,
this.timeoutMs = 1500,
this.maxFingers = 1,
this.activeGestureKeys = const [],
});
}
/// Dart → Kotlin. Pushes the current gesture-recognition configuration.
@HostApi()
abstract class GeckoGestureApi {
void setGestureConfig(GestureConfig config);
}
/// Kotlin → Dart. Emitted when an assembled touch stroke matches a configured
/// gesture key.
@FlutterApi()
abstract class GeckoGestureEvents {
/// [sequence] Event sequence number for ordering.
/// [gestureKey] Canonical key of the recognized gesture, e.g. `D-R`.
void onGestureRecognized(int sequence, String gestureKey);
/// Emitted while a stroke is being drawn, each time a new direction arrow is
/// appended. Drives the live feedback overlay.
///
/// [sequence] Event sequence number for ordering.
/// [partialKey] Current partial canonical key including start/finger
/// prefixes, e.g. `R:D`.
void onGestureProgress(int sequence, String partialKey);
/// Emitted when an in-progress stroke ends (release, cancel or idle timeout)
/// so the live feedback overlay can be hidden.
///
/// [sequence] Event sequence number for ordering.
void onGestureReset(int sequence);
}