container history initial
This commit is contained in:
+17
@@ -7,8 +7,10 @@
|
||||
package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
@@ -23,10 +25,25 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
||||
browserApi.attachBinding(flutterPluginBinding)
|
||||
GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi)
|
||||
SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger)
|
||||
|
||||
// Register the engine-settings API at attach time (before GeckoBrowserService
|
||||
// .initialize) so Dart can push the history-exclusion contextId set to native
|
||||
// *before* the engine starts recording restored-tab visits, closing the
|
||||
// startup window where an excluded container could leak to Places.
|
||||
// setExcludedHistoryContextIds only writes GlobalComponents state and needs
|
||||
// no initialized components; the remaining settings methods resolve
|
||||
// components lazily and are not invoked until after initialize. The same
|
||||
// instance is reused by GeckoBrowserApiImpl.initialize.
|
||||
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl(
|
||||
flutterPluginBinding.applicationContext,
|
||||
)
|
||||
GeckoEngineSettingsApi.setUp(flutterPluginBinding.binaryMessenger, engineSettingsApiImpl)
|
||||
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
|
||||
GlobalComponents.historyEvents = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
|
||||
+40
-2
@@ -8,6 +8,8 @@ package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
|
||||
@@ -15,6 +17,7 @@ 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.GeckoHistoryEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
|
||||
@@ -59,7 +62,9 @@ private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 //
|
||||
private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST =
|
||||
"__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid"
|
||||
private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists"
|
||||
|
||||
private const val EXCLUDED_HISTORY_CONTEXT_IDS_PREF =
|
||||
"browser.weblibre.excludedHistoryContextIds"
|
||||
|
||||
object GlobalComponents {
|
||||
private var _components: Components? = null
|
||||
private var currentMode: ComponentsMode? = null
|
||||
@@ -105,6 +110,36 @@ object GlobalComponents {
|
||||
// GestureRecognizer on the UI thread.
|
||||
var gestureEvents: GeckoGestureEvents? = null
|
||||
|
||||
// Native -> Dart history visit notifications, consumed by Core's history
|
||||
// delegate to forward the visit's WebLibre container. Null on the headless
|
||||
// path (no Flutter engine); the delegate still hard-excludes persisted
|
||||
// container contextIds but skips Dart relation emits.
|
||||
var historyEvents: GeckoHistoryEvents? = null
|
||||
|
||||
// Gecko contextIds of containers with hard exclude-from-history enabled.
|
||||
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
|
||||
// write for visits resolved to one of these containers.
|
||||
@Volatile
|
||||
var excludedHistoryContextIds: Set<String> = emptySet()
|
||||
|
||||
fun setExcludedHistoryContextIds(context: Context?, contextIds: Collection<String>) {
|
||||
val contextIdSet = contextIds.toSet()
|
||||
excludedHistoryContextIds = contextIdSet
|
||||
|
||||
if (context != null) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit {
|
||||
putStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, contextIdSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadExcludedHistoryContextIds(context: Context) {
|
||||
excludedHistoryContextIds = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getStringSet(EXCLUDED_HISTORY_CONTEXT_IDS_PREF, emptySet())
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var gestureConfig: GestureConfig? = null
|
||||
|
||||
@@ -461,6 +496,9 @@ object GlobalComponents {
|
||||
val profileContext = ProfileContext(baseContext.applicationContext, profileFolder)
|
||||
val messenger = NoopBinaryMessenger()
|
||||
|
||||
loadExcludedHistoryContextIds(baseContext.applicationContext)
|
||||
historyEvents = null
|
||||
|
||||
val selectionActionEvents = GeckoSelectionActionEvents(messenger)
|
||||
val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) { actions ->
|
||||
val processTextAction = "android.intent.action.PROCESS_TEXT"
|
||||
@@ -493,7 +531,7 @@ object GlobalComponents {
|
||||
mode = ComponentsMode.EXTERNAL,
|
||||
)
|
||||
|
||||
engineSettingsApi = GeckoEngineSettingsApiImpl()
|
||||
engineSettingsApi = GeckoEngineSettingsApiImpl(baseContext.applicationContext)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -49,6 +49,7 @@ 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.GeckoHistoryEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
@@ -265,6 +266,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
|
||||
val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger)
|
||||
|
||||
// Set before GlobalComponents.setUp (which lazily builds the engine and
|
||||
// its history delegate) so Core can wrap the delegate to forward the
|
||||
// visit's WebLibre container to Dart.
|
||||
GlobalComponents.historyEvents =
|
||||
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
||||
|
||||
GlobalComponents.setUp(
|
||||
profileApplicationContext,
|
||||
_flutterEvents,
|
||||
@@ -281,12 +288,20 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
syncTokenServerOverride,
|
||||
)
|
||||
|
||||
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl()
|
||||
GeckoEngineSettingsApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
engineSettingsApiImpl
|
||||
)
|
||||
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
|
||||
// GeckoEngineSettingsApi was already registered at plugin-attach time
|
||||
// (FlutterMozillaComponentsPlugin.onAttachedToEngine) so the startup
|
||||
// history-exclusion push lands before the engine starts. Reuse that
|
||||
// instance; only register a fresh one if attach somehow did not run.
|
||||
if (GlobalComponents.engineSettingsApi == null) {
|
||||
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl(
|
||||
_flutterPluginBinding.applicationContext,
|
||||
)
|
||||
GeckoEngineSettingsApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
engineSettingsApiImpl
|
||||
)
|
||||
GlobalComponents.engineSettingsApi = engineSettingsApiImpl
|
||||
}
|
||||
GeckoAddonsApi.setUp(
|
||||
_flutterPluginBinding.binaryMessenger,
|
||||
GeckoAddonsApiImpl(profileApplicationContext)
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.api
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
|
||||
@@ -74,7 +75,9 @@ internal fun TrackingProtectionPolicy.withBounceTrackingProtectionMode(
|
||||
/**
|
||||
* Implementation of GeckoEngineSettingsApi that manages engine-specific settings
|
||||
*/
|
||||
class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
|
||||
class GeckoEngineSettingsApiImpl(
|
||||
private val applicationContext: Context? = null,
|
||||
) : GeckoEngineSettingsApi {
|
||||
companion object {
|
||||
private const val TAG = "GeckoEngineSettingsApi"
|
||||
}
|
||||
@@ -515,4 +518,8 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
|
||||
activeReaderSessions,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setExcludedHistoryContextIds(contextIds: List<String>) {
|
||||
GlobalComponents.setExcludedHistoryContextIds(applicationContext, contextIds)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -18,6 +18,7 @@ import eu.weblibre.flutter_mozilla_components.services.DownloadService
|
||||
import eu.weblibre.flutter_mozilla_components.EngineProvider
|
||||
import eu.weblibre.flutter_mozilla_components.EngineProvider.getOrCreateRuntime
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate
|
||||
import eu.weblibre.flutter_mozilla_components.PermissionStorage
|
||||
import eu.weblibre.flutter_mozilla_components.services.MediaSessionService
|
||||
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
|
||||
@@ -26,6 +27,7 @@ import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.SandboxCaptureMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
@@ -99,9 +101,14 @@ class Core(
|
||||
|
||||
val engineSettings by lazy {
|
||||
DefaultSettings(
|
||||
//historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage)
|
||||
requestInterceptor = requestInterceptor,
|
||||
historyTrackingDelegate = HistoryDelegate(lazyHistoryStorage),
|
||||
// Wrap the Places-feeding delegate so WebLibre can hard-exclude
|
||||
// container visits even on the headless external path. When Flutter
|
||||
// is available, the wrapper also emits visit→container relations.
|
||||
historyTrackingDelegate = WebLibreHistoryDelegate(
|
||||
HistoryDelegate(lazyHistoryStorage),
|
||||
GlobalComponents.historyEvents,
|
||||
),
|
||||
testingModeEnabled = false,
|
||||
remoteDebuggingEnabled = false,
|
||||
automaticFontSizeAdjustment = true,
|
||||
@@ -225,6 +232,9 @@ class Core(
|
||||
// sandbox new-tab URLs before Gecko issues a request.
|
||||
SandboxCaptureMiddleware,
|
||||
HistoryMetadataMiddleware(historyMetadataService),
|
||||
// Correlates url -> contextId so WebLibreHistoryDelegate can
|
||||
// resolve a visit's container at record time.
|
||||
HistoryVisitCorrelationMiddleware(),
|
||||
FlutterEventMiddleware(flutterEvents),
|
||||
DownloadMiddleware(
|
||||
applicationContext = context,
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package eu.weblibre.flutter_mozilla_components.history
|
||||
|
||||
/**
|
||||
* Short-lived `url -> contextId` correlation captured at navigation time.
|
||||
*
|
||||
* Android Components strips the session before it reaches
|
||||
* [mozilla.components.concept.engine.history.HistoryTrackingDelegate.onVisited],
|
||||
* so the delegate only sees the visited URL — not which tab (and therefore which
|
||||
* Gecko contextual identity / WebLibre container) produced it. The
|
||||
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
|
||||
* records the navigating tab's contextId here on every `UpdateUrlAction`; the
|
||||
* delegate reads it back when the matching visit is recorded moments later.
|
||||
*
|
||||
* This is the primary signal: the delegate trusts a live correlation before
|
||||
* falling back to the selected tab (see
|
||||
* [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate]). The
|
||||
* TTL is deliberately short — a record and its visit are milliseconds apart, so
|
||||
* anything older is an orphan (e.g. recorded on a reload that fired its
|
||||
* `onVisited` before the record) and must expire quickly, or it can leak onto a
|
||||
* later same-URL visit in a different container.
|
||||
*/
|
||||
object HistoryVisitCorrelationCache {
|
||||
private const val TTL_MS = 3_000L
|
||||
private const val MAX_ENTRIES = 64
|
||||
|
||||
private data class Entry(val contextId: String?, val timestamp: Long)
|
||||
|
||||
// Insertion-ordered so we can drop the oldest entry when over capacity.
|
||||
private val entries = LinkedHashMap<String, Entry>()
|
||||
|
||||
/** Record the [contextId] (nullable = uncontained tab) that navigated to [url]. */
|
||||
@Synchronized
|
||||
fun record(url: String, contextId: String?) {
|
||||
val now = System.currentTimeMillis()
|
||||
evictExpired(now)
|
||||
// Re-insert so the most recent navigation to a URL wins and stays newest.
|
||||
entries.remove(url)
|
||||
entries[url] = Entry(contextId, now)
|
||||
while (entries.size > MAX_ENTRIES) {
|
||||
val oldest = entries.keys.firstOrNull() ?: break
|
||||
entries.remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/** A live correlation for a URL: present in the cache, its [contextId]
|
||||
* possibly null (the producing tab was uncontained). Distinct from a cache
|
||||
* miss (`resolve` returns null), so the caller can tell a known-uncontained
|
||||
* navigation apart from an unknown one. */
|
||||
data class Resolution(val contextId: String?)
|
||||
|
||||
/**
|
||||
* The correlation most recently recorded for [url] within the TTL, or null
|
||||
* if there is no live entry. A non-null [Resolution] with a null contextId
|
||||
* means the producing tab was uncontained — the caller must treat that as an
|
||||
* authoritative "uncontained" answer, NOT as a miss, so it does not fall
|
||||
* back to a less specific signal (e.g. a merely-loading foreground tab).
|
||||
*
|
||||
* Consume-on-read: the entry is removed so a stale correlation can NEVER be
|
||||
* reused for a later, unrelated visit of the same URL — e.g. a page first
|
||||
* visited in a container and later reopened uncontained (or in a different
|
||||
* container) must not inherit the earlier container. A repeat navigation
|
||||
* re-records it via the middleware.
|
||||
*/
|
||||
@Synchronized
|
||||
fun resolve(url: String): Resolution? {
|
||||
val now = System.currentTimeMillis()
|
||||
evictExpired(now)
|
||||
val entry = entries.remove(url) ?: return null
|
||||
return Resolution(entry.contextId)
|
||||
}
|
||||
|
||||
private fun evictExpired(now: Long) {
|
||||
val iterator = entries.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
if (now - iterator.next().value.timestamp > TTL_MS) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package eu.weblibre.flutter_mozilla_components.history
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.browser.state.selector.selectedTab
|
||||
import mozilla.components.concept.engine.history.HistoryTrackingDelegate
|
||||
import mozilla.components.concept.storage.PageVisit
|
||||
|
||||
/**
|
||||
* Wraps the Android Components [HistoryTrackingDelegate] (which feeds Mozilla
|
||||
* Places / FxA sync) and, on each recorded visit, additionally notifies Dart of
|
||||
* the WebLibre container that produced it.
|
||||
*
|
||||
* Mozilla Places stays the source of truth for the visit itself (url, title,
|
||||
* visit type, visit time); WebLibre only needs the one thing Places can't store:
|
||||
* which container the visit belonged to. Places strips the session before
|
||||
* [onVisited], so the container is recovered from the `url -> contextId`
|
||||
* correlation cache populated by
|
||||
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
|
||||
* at navigation time; Dart maps the contextId to a WebLibre container and writes
|
||||
* the visit→container relation row.
|
||||
*
|
||||
* Purely additive for Places/sync: every call delegates to [wrapped]. The one
|
||||
* exception is hard exclude-from-history — for a container opted out of history
|
||||
* recording, the Places write is skipped too so the visit never lands anywhere.
|
||||
*/
|
||||
class WebLibreHistoryDelegate(
|
||||
private val wrapped: HistoryTrackingDelegate,
|
||||
private val events: GeckoHistoryEvents?,
|
||||
) : HistoryTrackingDelegate by wrapped {
|
||||
|
||||
override suspend fun onVisited(uri: String, visit: PageVisit) {
|
||||
// Resolve which container produced this visit once, and reuse it for
|
||||
// both the hard-exclude decision and the Dart relation emit.
|
||||
val resolution = resolveContextId(uri)
|
||||
|
||||
// Hard exclude-from-history: for a visit whose container has
|
||||
// exclude-from-history enabled, skip BOTH the Places write and the Dart
|
||||
// relation emit — the visit must not land in either store.
|
||||
if (isHistoryExcluded(uri, resolution)) {
|
||||
return
|
||||
}
|
||||
|
||||
wrapped.onVisited(uri, visit)
|
||||
|
||||
// Timestamp near the Places record time; Dart joins to the actual Places
|
||||
// visit by (url, nearest visit_time), tolerating the small skew. Best
|
||||
// effort for tagging: even the non-authoritative guess is emitted, since
|
||||
// a wrong tag is recoverable (unlike the exclude decision above).
|
||||
val visitTime = System.currentTimeMillis()
|
||||
|
||||
// Flutter's binary messenger must be used on the platform (main) thread.
|
||||
if (events != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
events.onVisitRecorded(uri, visitTime, resolution.contextId) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this visit belongs to a hard exclude-from-history
|
||||
* ("incognito") container and must therefore land nowhere.
|
||||
*
|
||||
* Fails **closed**: unlike tagging (where a missing tag is recoverable),
|
||||
* leaking an excluded container's visit to Places is not, so any ambiguity
|
||||
* that could involve an excluded container is resolved as "excluded".
|
||||
*
|
||||
* - When [resolution] is authoritative (steps 1–2 pinned the producing tab),
|
||||
* trust it: excluded iff that tab's contextId is in the excluded set.
|
||||
* - Otherwise (the last-resort step-3 guess or a full miss), scan open tabs:
|
||||
* if any non-private tab is currently on this exact URL in an excluded
|
||||
* container, the visit may have originated there — drop it. This closes the
|
||||
* window where a background-tab visit's correlation was evicted/expired.
|
||||
*/
|
||||
private fun isHistoryExcluded(uri: String, resolution: ContextResolution): Boolean {
|
||||
val excluded = GlobalComponents.excludedHistoryContextIds
|
||||
if (excluded.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (resolution.authoritative) {
|
||||
return resolution.contextId != null && resolution.contextId in excluded
|
||||
}
|
||||
|
||||
val tabs = GlobalComponents.components?.core?.store?.state?.tabs ?: return false
|
||||
return tabs.any { tab ->
|
||||
!tab.content.private &&
|
||||
tab.content.url == uri &&
|
||||
tab.contextId != null &&
|
||||
tab.contextId in excluded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The producing Gecko [contextId] for a visit, plus whether it was pinned
|
||||
* [authoritative]ly (a definite producer) or is only a best-effort guess.
|
||||
*/
|
||||
private data class ContextResolution(
|
||||
val contextId: String?,
|
||||
val authoritative: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve the visited [uri] to the Gecko contextId that produced it.
|
||||
*
|
||||
* Resolution order, most specific first:
|
||||
*
|
||||
* 1. The per-navigation correlation recorded by
|
||||
* [eu.weblibre.flutter_mozilla_components.middleware.HistoryVisitCorrelationMiddleware]
|
||||
* for the tab that actually navigated to [uri]. This correctly attributes
|
||||
* a **background-tab** visit and — crucially — stops a loading foreground
|
||||
* tab from stealing another tab's same-URL visit. A live
|
||||
* [HistoryVisitCorrelationCache.Resolution] (even with a null contextId,
|
||||
* i.e. known-uncontained) is authoritative.
|
||||
* 2. On a cache miss, the selected tab only when it is **actively loading
|
||||
* exactly [uri]** (`loading && url == uri`). With no recorded competing
|
||||
* navigation, it is the best live producer signal. Authoritative.
|
||||
* 3. Otherwise, the loading selected tab as a last resort: redirect chains
|
||||
* can fire `onVisited(uri)` before the selected tab's url settles on [uri]
|
||||
* and before the middleware records it. Marked **not** authoritative — it
|
||||
* may mis-attribute a background-tab visit, so the exclude decision does
|
||||
* not trust it and re-checks via [isHistoryExcluded].
|
||||
*
|
||||
* A full miss returns a non-authoritative uncontained (null) result.
|
||||
*/
|
||||
private fun resolveContextId(uri: String): ContextResolution {
|
||||
val selected = GlobalComponents.components?.core?.store?.state?.selectedTab
|
||||
|
||||
val correlation = HistoryVisitCorrelationCache.resolve(uri)
|
||||
if (correlation != null) {
|
||||
return ContextResolution(correlation.contextId, authoritative = true)
|
||||
}
|
||||
|
||||
if (selected != null && !selected.content.private &&
|
||||
selected.content.loading && selected.content.url == uri
|
||||
) {
|
||||
return ContextResolution(selected.contextId, authoritative = true)
|
||||
}
|
||||
|
||||
if (selected != null && !selected.content.private &&
|
||||
selected.content.loading
|
||||
) {
|
||||
return ContextResolution(selected.contextId, authoritative = false)
|
||||
}
|
||||
|
||||
return ContextResolution(null, authoritative = false)
|
||||
}
|
||||
}
|
||||
+18
@@ -6,6 +6,7 @@
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.middleware
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import mozilla.components.browser.state.action.BrowserAction
|
||||
import mozilla.components.browser.state.action.ContentAction
|
||||
import mozilla.components.browser.state.action.HistoryMetadataAction
|
||||
@@ -102,13 +103,30 @@ class HistoryMetadataMiddleware(
|
||||
store: Store<BrowserState, BrowserAction>,
|
||||
tab: TabSessionState,
|
||||
) {
|
||||
// Hard exclude-from-history: a tab in an excluded ("incognito") container
|
||||
// must not persist anywhere in Places. WebLibreHistoryDelegate already
|
||||
// skips the visit write; metadata is a separate persistent Places path
|
||||
// (highlights / suggestions), so it must be gated on the same set.
|
||||
if (isHistoryExcluded(tab)) {
|
||||
return
|
||||
}
|
||||
|
||||
val key = historyMetadataService.createMetadata(tab)
|
||||
store.dispatch(HistoryMetadataAction.SetHistoryMetadataKeyAction(tab.id, key))
|
||||
}
|
||||
|
||||
private fun updateHistoryMetadata(tab: TabSessionState) {
|
||||
if (isHistoryExcluded(tab)) {
|
||||
return
|
||||
}
|
||||
|
||||
tab.historyMetadata?.let {
|
||||
historyMetadataService.updateMetadata(it, tab)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHistoryExcluded(tab: TabSessionState): Boolean {
|
||||
val contextId = tab.contextId ?: return false
|
||||
return contextId in GlobalComponents.excludedHistoryContextIds
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.middleware
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.history.HistoryVisitCorrelationCache
|
||||
import mozilla.components.browser.state.action.BrowserAction
|
||||
import mozilla.components.browser.state.action.ContentAction
|
||||
import mozilla.components.browser.state.selector.findCustomTab
|
||||
import mozilla.components.browser.state.selector.findNormalTab
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.lib.state.Middleware
|
||||
import mozilla.components.lib.state.Store
|
||||
|
||||
/**
|
||||
* Records a short-lived `url -> contextId` correlation whenever a normal tab or
|
||||
* custom tab/PWA URL changes, so
|
||||
* [eu.weblibre.flutter_mozilla_components.history.WebLibreHistoryDelegate] can
|
||||
* resolve the tab's Gecko contextual identity (and hence its WebLibre container)
|
||||
* at visit time — the delegate itself only receives the visited URL.
|
||||
*
|
||||
* See [HistoryVisitCorrelationCache].
|
||||
*/
|
||||
class HistoryVisitCorrelationMiddleware :
|
||||
Middleware<BrowserState, BrowserAction> {
|
||||
override fun invoke(
|
||||
store: Store<BrowserState, BrowserAction>,
|
||||
next: (BrowserAction) -> Unit,
|
||||
action: BrowserAction,
|
||||
) {
|
||||
next(action)
|
||||
|
||||
if (action is ContentAction.UpdateUrlAction) {
|
||||
val normalTab = store.state.findNormalTab(action.sessionId)
|
||||
if (normalTab != null) {
|
||||
HistoryVisitCorrelationCache.record(action.url, normalTab.contextId)
|
||||
return
|
||||
}
|
||||
|
||||
store.state.findCustomTab(action.sessionId)?.let { customTab ->
|
||||
HistoryVisitCorrelationCache.record(action.url, customTab.contextId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -7305,6 +7305,13 @@ interface GeckoEngineSettingsApi {
|
||||
* cold-started reader view resolves the right value before Flutter runs.
|
||||
*/
|
||||
fun setReaderViewPureBlack(enabled: Boolean)
|
||||
/**
|
||||
* The set of Gecko contextual-identity ids ("container" contextIds) whose
|
||||
* browsing history must NOT be written to Mozilla Places (hard
|
||||
* exclude-from-history / "incognito container"). WebLibreHistoryDelegate
|
||||
* skips the Places write for a visit resolved to one of these containers.
|
||||
*/
|
||||
fun setExcludedHistoryContextIds(contextIds: List<String>)
|
||||
|
||||
companion object {
|
||||
/** The codec used by GeckoEngineSettingsApi. */
|
||||
@@ -7490,6 +7497,24 @@ interface GeckoEngineSettingsApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val contextIdsArg = args[0] as List<String>
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setExcludedHistoryContextIds(contextIdsArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
GeckoPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8955,7 +8980,7 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
||||
}
|
||||
} else {
|
||||
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
|
||||
@@ -10314,6 +10339,46 @@ interface GeckoDeleteBrowsingDataController {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
|
||||
* on each recorded Mozilla Places visit so WebLibre can persist the one thing
|
||||
* Places can't store: which container the visit belonged to. The visit itself
|
||||
* (title, visit type, exact time) stays owned by Places.
|
||||
*
|
||||
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
|
||||
*/
|
||||
class GeckoHistoryEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||
companion object {
|
||||
/** The codec used by GeckoHistoryEvents. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
GeckoPigeonCodec()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* [contextId] is the Gecko contextual identity of the tab that produced the
|
||||
* visit, resolved via the URL→contextId correlation cache (null when it
|
||||
* couldn't be resolved / the tab was uncontained). Dart maps it to a
|
||||
* WebLibre container and writes the visit→container relation, keyed on
|
||||
* ([url], [visitTime]) to join back to the Places visit.
|
||||
*/
|
||||
fun onVisitRecorded(urlArg: String, visitTimeArg: Long, contextIdArg: String?, callback: (Result<Unit>) -> Unit)
|
||||
{
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$separatedMessageChannelSuffix"
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||
channel.send(listOf(urlArg, visitTimeArg, contextIdArg)) {
|
||||
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 GeckoHistoryApi {
|
||||
fun getDetailedVisits(startMillis: Long, endMillis: Long, excludeTypes: List<VisitType>, callback: (Result<List<VisitInfo>>) -> Unit)
|
||||
|
||||
@@ -72,6 +72,7 @@ export 'src/pigeons/gecko.g.dart'
|
||||
GeckoDeleteBrowsingDataController,
|
||||
GeckoEngineSettings,
|
||||
GeckoFetchResponse,
|
||||
GeckoHistoryEvents,
|
||||
GeckoPref,
|
||||
GeckoProxySettings,
|
||||
GeckoPublicSuffixListApi,
|
||||
|
||||
@@ -236,4 +236,8 @@ class GeckoEngineSettingsService {
|
||||
Future<void> setReaderViewPureBlack(bool enabled) {
|
||||
return _api.setReaderViewPureBlack(enabled);
|
||||
}
|
||||
|
||||
Future<void> setExcludedHistoryContextIds(List<String> contextIds) {
|
||||
return _api.setExcludedHistoryContextIds(contextIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,14 @@ class GeckoHistoryService {
|
||||
case VisitType.redirectTemporary:
|
||||
case VisitType.framedLink:
|
||||
case VisitType.reload:
|
||||
// A bookmark-type visit is an ordinary Places visit (the user navigated
|
||||
// via a bookmark); deleting it by (url, time) removes only that visit
|
||||
// record and leaves the bookmark itself intact — same path as any page
|
||||
// visit.
|
||||
case VisitType.bookmark:
|
||||
return _api.deleteVisit(info.url, info.visitTime);
|
||||
case VisitType.download:
|
||||
return _api.deleteDownload(info.contentId!);
|
||||
case VisitType.bookmark:
|
||||
throw UnimplementedError('VisitType.bookmark deletion not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7421,6 +7421,28 @@ class GeckoEngineSettingsApi {
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
/// The set of Gecko contextual-identity ids ("container" contextIds) whose
|
||||
/// browsing history must NOT be written to Mozilla Places (hard
|
||||
/// exclude-from-history / "incognito container"). WebLibreHistoryDelegate
|
||||
/// skips the Places write for a visit resolved to one of these containers.
|
||||
Future<void> setExcludedHistoryContextIds(List<String> contextIds) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setExcludedHistoryContextIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[contextIds]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
class GeckoSessionApi {
|
||||
@@ -10310,6 +10332,48 @@ class GeckoDeleteBrowsingDataController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
|
||||
/// on each recorded Mozilla Places visit so WebLibre can persist the one thing
|
||||
/// Places can't store: which container the visit belonged to. The visit itself
|
||||
/// (title, visit type, exact time) stays owned by Places.
|
||||
abstract class GeckoHistoryEvents {
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
/// [contextId] is the Gecko contextual identity of the tab that produced the
|
||||
/// visit, resolved via the URL→contextId correlation cache (null when it
|
||||
/// couldn't be resolved / the tab was uncontained). Dart maps it to a
|
||||
/// WebLibre container and writes the visit→container relation, keyed on
|
||||
/// ([url], [visitTime]) to join back to the Places visit.
|
||||
void onVisitRecorded(String url, int visitTime, String? contextId);
|
||||
|
||||
static void setUp(GeckoHistoryEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_mozilla_components.GeckoHistoryEvents.onVisitRecorded$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 String arg_url = args[0]! as String;
|
||||
final int arg_visitTime = args[1]! as int;
|
||||
final String? arg_contextId = args[2] as String?;
|
||||
try {
|
||||
api.onVisitRecorded(arg_url, arg_visitTime, arg_contextId);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GeckoHistoryApi {
|
||||
/// Constructor for [GeckoHistoryApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
|
||||
@@ -1586,6 +1586,12 @@ abstract class GeckoEngineSettingsApi {
|
||||
/// Mozilla's reader view extension. Persisted in SharedPreferences so a
|
||||
/// cold-started reader view resolves the right value before Flutter runs.
|
||||
void setReaderViewPureBlack(bool enabled);
|
||||
|
||||
/// The set of Gecko contextual-identity ids ("container" contextIds) whose
|
||||
/// browsing history must NOT be written to Mozilla Places (hard
|
||||
/// exclude-from-history / "incognito container"). WebLibreHistoryDelegate
|
||||
/// skips the Places write for a visit resolved to one of these containers.
|
||||
void setExcludedHistoryContextIds(List<String> contextIds);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
@@ -2230,6 +2236,20 @@ enum ClearDataType {
|
||||
onlyCaches,
|
||||
}
|
||||
|
||||
/// Native -> Dart history visit notifications. Fired from WebLibreHistoryDelegate
|
||||
/// on each recorded Mozilla Places visit so WebLibre can persist the one thing
|
||||
/// Places can't store: which container the visit belonged to. The visit itself
|
||||
/// (title, visit type, exact time) stays owned by Places.
|
||||
@FlutterApi()
|
||||
abstract class GeckoHistoryEvents {
|
||||
/// [contextId] is the Gecko contextual identity of the tab that produced the
|
||||
/// visit, resolved via the URL→contextId correlation cache (null when it
|
||||
/// couldn't be resolved / the tab was uncontained). Dart maps it to a
|
||||
/// WebLibre container and writes the visit→container relation, keyed on
|
||||
/// ([url], [visitTime]) to join back to the Places visit.
|
||||
void onVisitRecorded(String url, int visitTime, String? contextId);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class GeckoHistoryApi {
|
||||
@async
|
||||
|
||||
Reference in New Issue
Block a user