Add Supa account and search changes
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
// WebLibre sandbox capture subresource firewall.
|
||||
//
|
||||
// Cancels any request issued from a document served by the local capture
|
||||
// server (http://127.0.0.1:*/captures/* or /loader*) unless the request
|
||||
// also targets the same loopback server. This is the second line of
|
||||
// defence behind AppRequestInterceptor — SingleFile captures are already
|
||||
// fully-inlined, so this firewall should rarely have anything to block
|
||||
// in practice, but it guarantees the privacy contract.
|
||||
|
||||
const LOOPBACK_PATTERN = /^http:\/\/127\.0\.0\.1:\d+\/(captures|loader)(\/|\?|$)/;
|
||||
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
(details) => {
|
||||
const doc = details.documentUrl || details.originUrl;
|
||||
if (!doc || !LOOPBACK_PATTERN.test(doc)) {
|
||||
return {};
|
||||
}
|
||||
if (LOOPBACK_PATTERN.test(details.url)) {
|
||||
return {};
|
||||
}
|
||||
if (details.url.startsWith("data:") || details.url.startsWith("blob:")) {
|
||||
return {};
|
||||
}
|
||||
return { cancel: true };
|
||||
},
|
||||
{ urls: ["<all_urls>"] },
|
||||
["blocking"]
|
||||
);
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "WebLibre Sandbox Capture",
|
||||
"version": "0.1.0",
|
||||
"description": "Blocks all non-loopback requests from documents served by the WebLibre sandbox capture server.",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "sandbox-capture@weblibre.eu"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"<all_urls>",
|
||||
"geckoViewAddons",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -109,6 +109,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
// Browser scroll-handling detection feature
|
||||
private var browserHandlingScrollFeature: BrowserHandlingScrollFeature? = null
|
||||
|
||||
protected open val shouldStartBrowserHandlingScrollFeature: Boolean = true
|
||||
|
||||
// Registers a photo picker activity launcher in single-select mode.
|
||||
private val singleMediaPicker =
|
||||
AndroidPhotoPicker.singleMediaPicker(
|
||||
@@ -568,8 +570,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
||||
it.start(binding.root)
|
||||
}
|
||||
|
||||
browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also {
|
||||
it.start()
|
||||
if (shouldStartBrowserHandlingScrollFeature) {
|
||||
browserHandlingScrollFeature = BrowserHandlingScrollFeature(viewportEvents).also {
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
@@ -132,6 +133,8 @@ object EngineProvider {
|
||||
"mozacReaderExtract",
|
||||
).install(it)
|
||||
|
||||
SandboxCaptureFeature.install(it)
|
||||
|
||||
BuiltInWebExtensionController(
|
||||
"readerview@mozac.org",
|
||||
"resource://android/assets/extensions/readerview/",
|
||||
|
||||
+54
-5
@@ -12,12 +12,13 @@ import android.os.Environment
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupWindow
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
@@ -56,6 +57,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
|
||||
private var customTabToolbar: CustomTabToolbar? = null
|
||||
private var activePopup: PopupWindow? = null
|
||||
private var fixedToolbarVisible = false
|
||||
|
||||
override val shouldStartBrowserHandlingScrollFeature: Boolean = false
|
||||
|
||||
private val customTabSessionId: String?
|
||||
get() = arguments?.getString(CUSTOM_TAB_SESSION_ID_KEY)
|
||||
@@ -130,15 +134,22 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
if (!isPwaOrTwa) {
|
||||
setupCustomTabToolbar(sessionId)
|
||||
}
|
||||
|
||||
resetExternalDynamicToolbar()
|
||||
}
|
||||
|
||||
private fun setupCustomTabToolbar(sessionId: String) {
|
||||
val view = requireView()
|
||||
|
||||
if (customTabToolbar != null) {
|
||||
setFixedToolbarVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
val toolbar = CustomTabToolbar(requireContext()).apply {
|
||||
layoutParams = AppBarLayout.LayoutParams(
|
||||
AppBarLayout.LayoutParams.MATCH_PARENT,
|
||||
AppBarLayout.LayoutParams.WRAP_CONTENT
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
customTabToolbar = toolbar
|
||||
@@ -146,6 +157,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
binding.customTabAppBar.apply {
|
||||
removeAllViews()
|
||||
addView(toolbar)
|
||||
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateBrowserContentMargins()
|
||||
}
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
@@ -172,6 +186,38 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
owner = this,
|
||||
view = view
|
||||
)
|
||||
|
||||
setFixedToolbarVisible(true)
|
||||
}
|
||||
|
||||
private fun setFixedToolbarVisible(visible: Boolean) {
|
||||
fixedToolbarVisible = visible
|
||||
binding.customTabAppBar.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
resetExternalDynamicToolbar()
|
||||
updateBrowserContentMargins()
|
||||
|
||||
if (visible) {
|
||||
binding.customTabAppBar.post { updateBrowserContentMargins() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBrowserContentMargins() {
|
||||
val toolbarHeight = if (fixedToolbarVisible) binding.customTabAppBar.height else 0
|
||||
val layoutParams = binding.browserContent.layoutParams as ViewGroup.MarginLayoutParams
|
||||
if (layoutParams.topMargin == toolbarHeight && layoutParams.bottomMargin == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
layoutParams.topMargin = toolbarHeight
|
||||
layoutParams.bottomMargin = 0
|
||||
binding.browserContent.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
private fun resetExternalDynamicToolbar() {
|
||||
components.externalAppEngineView?.apply {
|
||||
setDynamicToolbarMaxHeight(0)
|
||||
setVerticalClipping(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomTabMenu(sessionId: String) {
|
||||
@@ -316,7 +362,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
val store = components.core.store
|
||||
val customTab = store.state.findCustomTab(sessionId) ?: return
|
||||
|
||||
components.activeEngineView?.setDynamicToolbarMaxHeight(0)
|
||||
resetExternalDynamicToolbar()
|
||||
|
||||
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
|
||||
components.core.webAppManifestStorage.getManifestCache(url)
|
||||
@@ -341,6 +387,9 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
||||
scope = viewLifecycleOwner.lifecycleScope,
|
||||
) { toolbarVisible ->
|
||||
Logger.debug("Custom tab toolbar visibility: $toolbarVisible")
|
||||
if (toolbarVisible) {
|
||||
setupCustomTabToolbar(sessionId)
|
||||
}
|
||||
},
|
||||
owner = this,
|
||||
view = view,
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@
|
||||
package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoBrowserApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
@@ -21,10 +22,11 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
browserApi.attachBinding(flutterPluginBinding)
|
||||
GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi)
|
||||
SandboxCaptureFeature.wireFlutterEvents(flutterPluginBinding.binaryMessenger)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
|
||||
SandboxCaptureFeature.detachFlutterEvents(binding.binaryMessenger)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
import eu.weblibre.flutter_mozilla_components.widget.BackGestureFilterFrameLayout
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import io.flutter.plugin.platform.PlatformViewFactory
|
||||
@@ -52,7 +53,7 @@ private class NativeFragmentView(
|
||||
throw IllegalStateException("Activity cannot be null when creating NativeFragmentView")
|
||||
}
|
||||
|
||||
container = FrameLayout(activity)
|
||||
container = BackGestureFilterFrameLayout(activity, activity)
|
||||
container.layoutParams = vParams
|
||||
container.id = containerId
|
||||
}
|
||||
|
||||
+27
@@ -7,6 +7,7 @@
|
||||
package eu.weblibre.flutter_mozilla_components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonCollection
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
|
||||
@@ -21,6 +22,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
|
||||
import eu.weblibre.flutter_mozilla_components.services.PrivateTabsNotificationService
|
||||
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
|
||||
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
|
||||
@@ -39,6 +41,7 @@ import mozilla.components.browser.state.selector.findCustomTab
|
||||
import mozilla.components.ExperimentalAndroidComponentsApi
|
||||
import mozilla.components.concept.engine.selection.SelectionActionDelegate
|
||||
import mozilla.components.concept.engine.preferences.Branch
|
||||
import mozilla.components.feature.privatemode.notification.PrivateNotificationFeature
|
||||
import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider
|
||||
import mozilla.components.support.base.facts.Facts
|
||||
import mozilla.components.support.base.facts.processor.LogFactProcessor
|
||||
@@ -57,6 +60,8 @@ private const val UBLOCK_FILTER_LISTS_PREF = "browser.weblibre.uBO.filterLists"
|
||||
object GlobalComponents {
|
||||
private var _components: Components? = null
|
||||
private var currentMode: ComponentsMode? = null
|
||||
private var privateTabsNotificationFeature:
|
||||
PrivateNotificationFeature<PrivateTabsNotificationService>? = null
|
||||
|
||||
val components: Components?
|
||||
get() = _components
|
||||
@@ -102,6 +107,25 @@ object GlobalComponents {
|
||||
var startupUBlockFilterListsPref: String? = null
|
||||
var clearStartupUBlockFilterListsPref: Boolean = false
|
||||
|
||||
private fun startPrivateTabsNotificationFeature(components: Components) {
|
||||
privateTabsNotificationFeature = PrivateNotificationFeature(
|
||||
components.profileApplicationContext,
|
||||
components.core.store,
|
||||
PrivateTabsNotificationService::class,
|
||||
).also {
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPrivateTabsNotificationFeature() {
|
||||
val context = _components?.profileApplicationContext
|
||||
|
||||
privateTabsNotificationFeature?.stop()
|
||||
privateTabsNotificationFeature = null
|
||||
|
||||
context?.stopService(Intent(context, PrivateTabsNotificationService::class.java))
|
||||
}
|
||||
|
||||
fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean {
|
||||
return when (engineSettingsApi!!.getAppLinksMode()) {
|
||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true
|
||||
@@ -244,6 +268,8 @@ object GlobalComponents {
|
||||
}
|
||||
}
|
||||
|
||||
stopPrivateTabsNotificationFeature()
|
||||
|
||||
//newComponents.crashReporter.install(applicationContext)
|
||||
|
||||
//Facts.registerProcessor(LogFactProcessor())
|
||||
@@ -256,6 +282,7 @@ object GlobalComponents {
|
||||
if (mode == ComponentsMode.FULL) {
|
||||
newComponents.core.engine.warmUp()
|
||||
applyStartupUBlockFilterListsPref(newComponents)
|
||||
startPrivateTabsNotificationFeature(newComponents)
|
||||
}
|
||||
|
||||
fun restorePreviousCustomTabs() {
|
||||
|
||||
+2
@@ -512,6 +512,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
||||
|
||||
// 2. Stop component-level services
|
||||
try {
|
||||
GlobalComponents.stopPrivateTabsNotificationFeature()
|
||||
|
||||
GlobalComponents.components?.let { components ->
|
||||
// Stop the FxA web channel feature
|
||||
runCatching { components.services.fxaWebChannelFeature.stop() }
|
||||
|
||||
+231
@@ -1,19 +1,28 @@
|
||||
package eu.weblibre.flutter_mozilla_components.api
|
||||
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.DocumentType
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.FrecencyThresholdOption
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryHighlight
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryHighlightWeights
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryMetadata
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryMetadataKey
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistorySuggestion
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.PageObservation
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.TopFrecentSiteInfo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.VisitInfo
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.VisitType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.browser.state.state.content.DownloadState
|
||||
import mozilla.components.concept.storage.HistoryMetadataObservation
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class GeckoHistoryApiImpl() : GeckoHistoryApi {
|
||||
@@ -239,6 +248,40 @@ class GeckoHistoryApiImpl() : GeckoHistoryApi {
|
||||
}
|
||||
}
|
||||
|
||||
private fun mozilla.components.concept.storage.DocumentType.toPigeon(): DocumentType =
|
||||
when (this) {
|
||||
mozilla.components.concept.storage.DocumentType.Regular -> DocumentType.REGULAR
|
||||
mozilla.components.concept.storage.DocumentType.Media -> DocumentType.MEDIA
|
||||
}
|
||||
|
||||
private fun DocumentType.toConcept(): mozilla.components.concept.storage.DocumentType =
|
||||
when (this) {
|
||||
DocumentType.REGULAR -> mozilla.components.concept.storage.DocumentType.Regular
|
||||
DocumentType.MEDIA -> mozilla.components.concept.storage.DocumentType.Media
|
||||
}
|
||||
|
||||
private fun HistoryMetadataKey.toConcept(): mozilla.components.concept.storage.HistoryMetadataKey =
|
||||
mozilla.components.concept.storage.HistoryMetadataKey(
|
||||
url = url,
|
||||
searchTerm = searchTerm,
|
||||
referrerUrl = referrerUrl,
|
||||
)
|
||||
|
||||
private fun mozilla.components.concept.storage.HistoryMetadata.toPigeon(): HistoryMetadata =
|
||||
HistoryMetadata(
|
||||
key = HistoryMetadataKey(
|
||||
url = key.url,
|
||||
searchTerm = key.searchTerm,
|
||||
referrerUrl = key.referrerUrl,
|
||||
),
|
||||
title = title,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
totalViewTime = totalViewTime.toLong(),
|
||||
documentType = documentType.toPigeon(),
|
||||
previewImageUrl = previewImageUrl,
|
||||
)
|
||||
|
||||
override fun getTopFrecentSites(
|
||||
limit: Long,
|
||||
frecencyThreshold: FrecencyThresholdOption,
|
||||
@@ -265,4 +308,192 @@ class GeckoHistoryApiImpl() : GeckoHistoryApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLatestHistoryMetadataForUrl(
|
||||
url: String,
|
||||
callback: (Result<HistoryMetadata?>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val metadata = components.core.historyStorage
|
||||
.getLatestHistoryMetadataForUrl(url)
|
||||
?.toPigeon()
|
||||
callback(Result.success(metadata))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLatestHistoryMetadataForUrls(
|
||||
urls: List<String>,
|
||||
callback: (Result<List<HistoryMetadata?>>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Run lookups concurrently so Rust JNI calls don't serialize
|
||||
// per-URL on the Pigeon roundtrip. Order is preserved by
|
||||
// `awaitAll` honoring the input ordering.
|
||||
val results = coroutineScope {
|
||||
urls.map { url ->
|
||||
async {
|
||||
components.core.historyStorage
|
||||
.getLatestHistoryMetadataForUrl(url)
|
||||
?.toPigeon()
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
callback(Result.success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getVisited(
|
||||
urls: List<String>,
|
||||
callback: (Result<List<Boolean>>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val visited = components.core.historyStorage.getVisited(urls)
|
||||
callback(Result.success(visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSuggestions(
|
||||
query: String,
|
||||
limit: Long,
|
||||
callback: (Result<List<HistorySuggestion>>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val suggestions = components.core.historyStorage
|
||||
.getSuggestions(query, limit.toInt())
|
||||
.map {
|
||||
HistorySuggestion(
|
||||
url = it.url,
|
||||
title = it.title,
|
||||
score = it.score.toLong(),
|
||||
)
|
||||
}
|
||||
callback(Result.success(suggestions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryHistoryMetadata(
|
||||
query: String,
|
||||
limit: Long,
|
||||
callback: (Result<List<HistoryMetadata>>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val results = components.core.historyStorage
|
||||
.queryHistoryMetadata(query, limit.toInt())
|
||||
.map { it.toPigeon() }
|
||||
callback(Result.success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun recordObservation(
|
||||
url: String,
|
||||
observation: PageObservation,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.recordObservation(
|
||||
url,
|
||||
mozilla.components.concept.storage.PageObservation(
|
||||
title = observation.title,
|
||||
previewImageUrl = observation.previewImageUrl,
|
||||
),
|
||||
)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun noteHistoryMetadataViewTime(
|
||||
key: HistoryMetadataKey,
|
||||
viewTimeMs: Long,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.noteHistoryMetadataObservation(
|
||||
key.toConcept(),
|
||||
HistoryMetadataObservation.ViewTimeObservation(
|
||||
viewTime = viewTimeMs.toInt(),
|
||||
),
|
||||
)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun noteHistoryMetadataDocumentType(
|
||||
key: HistoryMetadataKey,
|
||||
documentType: DocumentType,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.noteHistoryMetadataObservation(
|
||||
key.toConcept(),
|
||||
HistoryMetadataObservation.DocumentTypeObservation(
|
||||
documentType = documentType.toConcept(),
|
||||
),
|
||||
)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteVisitsFor(
|
||||
url: String,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.deleteVisitsFor(url)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteVisitsSince(
|
||||
sinceMillis: Long,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.deleteVisitsSince(sinceMillis)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteEverything(
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage.deleteEverything()
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteHistoryMetadataOlderThan(
|
||||
olderThanMillis: Long,
|
||||
callback: (Result<Unit>) -> Unit
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
components.core.historyStorage
|
||||
.deleteHistoryMetadataOlderThan(olderThanMillis)
|
||||
callback(Result.success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -12,6 +12,15 @@ import eu.weblibre.flutter_mozilla_components.pigeons.*
|
||||
import kotlinx.coroutines.*
|
||||
import mozilla.components.browser.icons.BrowserIcons
|
||||
import mozilla.components.browser.icons.Icon
|
||||
import mozilla.components.browser.icons.loader.DataUriIconLoader
|
||||
import mozilla.components.browser.icons.loader.DiskIconLoader
|
||||
import mozilla.components.browser.icons.loader.MemoryIconLoader
|
||||
import mozilla.components.browser.icons.preparer.DiskIconPreparer
|
||||
import mozilla.components.browser.icons.preparer.MemoryIconPreparer
|
||||
import mozilla.components.browser.icons.processor.DiskIconProcessor
|
||||
import mozilla.components.browser.icons.processor.MemoryIconProcessor
|
||||
import mozilla.components.browser.icons.utils.IconDiskCache
|
||||
import mozilla.components.browser.icons.utils.IconMemoryCache
|
||||
import mozilla.components.concept.engine.manifest.Size as HtmlSize
|
||||
import mozilla.components.feature.addons.logger
|
||||
|
||||
@@ -33,6 +42,29 @@ class GeckoIconsApiImpl : GeckoIconsApi {
|
||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||
}
|
||||
|
||||
private val noNetworkIcons by lazy {
|
||||
val memoryCache = IconMemoryCache()
|
||||
val diskCache = IconDiskCache()
|
||||
|
||||
BrowserIcons(
|
||||
context = components.profileApplicationContext,
|
||||
httpClient = components.core.client,
|
||||
preparers = listOf(
|
||||
MemoryIconPreparer(memoryCache),
|
||||
DiskIconPreparer(diskCache),
|
||||
),
|
||||
loaders = listOf(
|
||||
MemoryIconLoader(memoryCache),
|
||||
DiskIconLoader(diskCache),
|
||||
DataUriIconLoader(),
|
||||
),
|
||||
processors = listOf(
|
||||
MemoryIconProcessor(memoryCache),
|
||||
DiskIconProcessor(diskCache),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun loadIcon(request: IconRequest, callback: (Result<IconResult>) -> Unit) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
@@ -54,7 +86,12 @@ class GeckoIconsApiImpl : GeckoIconsApi {
|
||||
|
||||
private suspend fun loadIconAsync(request: MozillaIconRequest): IconResult {
|
||||
return try {
|
||||
val result = components.core.icons.loadIcon(request).await()
|
||||
val browserIcons = if (request.waitOnNetworkLoad) {
|
||||
components.core.icons
|
||||
} else {
|
||||
noNetworkIcons
|
||||
}
|
||||
val result = browserIcons.loadIcon(request).await()
|
||||
val imageBytes = result.bitmap.toWebPBytes()
|
||||
IconResult(
|
||||
image = imageBytes,
|
||||
|
||||
+4
@@ -24,6 +24,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.SandboxCaptureMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
||||
@@ -205,6 +206,9 @@ class Core(
|
||||
val store by lazy {
|
||||
BrowserStore(
|
||||
middleware = listOf(
|
||||
// Must run before any engine middleware so we can rewrite
|
||||
// sandbox new-tab URLs before Gecko issues a request.
|
||||
SandboxCaptureMiddleware,
|
||||
HistoryMetadataMiddleware(historyMetadataService),
|
||||
FlutterEventMiddleware(flutterEvents),
|
||||
DownloadMiddleware(
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
import eu.weblibre.flutter_mozilla_components.ActiveProfile
|
||||
import eu.weblibre.flutter_mozilla_components.PwaConstants
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureApi
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureEntry
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureHostEvents
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import mozilla.components.concept.engine.Engine
|
||||
import mozilla.components.support.base.log.logger.Logger
|
||||
import mozilla.components.support.webextensions.BuiltInWebExtensionController
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
// Installs the bundled sandbox-capture WebExtension (subresource firewall)
|
||||
// and serves as the Dart -> Kotlin SandboxCaptureApi endpoint. The extension
|
||||
// is stateless; no content-message handler is registered.
|
||||
object SandboxCaptureFeature : SandboxCaptureApi {
|
||||
private val logger = Logger("SandboxCapture")
|
||||
|
||||
private const val EXTENSION_ID = "sandbox-capture@weblibre.eu"
|
||||
private const val EXTENSION_URL =
|
||||
"resource://android/assets/extensions/sandbox_capture/"
|
||||
|
||||
// Highest sandbox_captures.json envelope version this build understands.
|
||||
// Bump in lockstep with `_version` in Dart's SandboxCaptureStore. A
|
||||
// newer envelope leaves the registry empty for the current cold start
|
||||
// so sandbox tabs reopen as about:blank rather than potentially being
|
||||
// mis-rehydrated.
|
||||
private const val SUPPORTED_VERSION = 1
|
||||
|
||||
private val extensionController = BuiltInWebExtensionController(
|
||||
EXTENSION_ID,
|
||||
EXTENSION_URL,
|
||||
// The extension doesn't open a content port; we pass a dummy channel
|
||||
// name because BuiltInWebExtensionController requires one.
|
||||
"sandboxCapture",
|
||||
)
|
||||
|
||||
fun install(engine: Engine) {
|
||||
extensionController.install(
|
||||
engine,
|
||||
onSuccess = {
|
||||
logger.debug("Installed sandbox_capture webextension")
|
||||
},
|
||||
onError = { throwable ->
|
||||
logger.error("Failed to install sandbox_capture", throwable)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Rehydrate the registry from disk before Gecko tab restore fires any
|
||||
// AppRequestInterceptor.onLoadRequest. Each entry is stamped with
|
||||
// redirectUrl="about:blank" so the first load after cold start shows a
|
||||
// blank page rather than hitting the live web. Dart will call resetAll
|
||||
// again after CaptureServer comes up to supply the real loader/capture
|
||||
// URLs.
|
||||
fun preRestoreBootstrap(context: Context) {
|
||||
try {
|
||||
val file = sandboxCapturesFile(context) ?: return
|
||||
if (!file.exists()) return
|
||||
|
||||
val json = JSONObject(file.readText())
|
||||
|
||||
// Treat a missing `version` field as v1 to stay compatible with
|
||||
// envelopes written before versioning was introduced. Reject
|
||||
// newer versions loudly — see SUPPORTED_VERSION comment.
|
||||
val version = json.optInt("version", 1)
|
||||
if (version > SUPPORTED_VERSION) {
|
||||
logger.warn(
|
||||
"sandbox_captures.json version $version is newer than " +
|
||||
"SUPPORTED_VERSION=$SUPPORTED_VERSION; starting empty " +
|
||||
"to avoid mis-rehydrating.",
|
||||
)
|
||||
SandboxCaptureRegistry.resetAll(emptyMap())
|
||||
return
|
||||
}
|
||||
|
||||
val entries = json.optJSONArray("entries") ?: return
|
||||
val populated = HashMap<String, SandboxCaptureRegistry.SandboxEntry>()
|
||||
|
||||
for (i in 0 until entries.length()) {
|
||||
val obj = entries.optJSONObject(i) ?: continue
|
||||
val tabId = obj.optString("tabId")
|
||||
val captureId = obj.optString("captureId")
|
||||
val sourceUrl = obj.optString("sourceUrl")
|
||||
val status = obj.optString("status", "pending")
|
||||
if (tabId.isEmpty() || captureId.isEmpty() || sourceUrl.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
populated[tabId] = SandboxCaptureRegistry.SandboxEntry(
|
||||
captureId = captureId,
|
||||
sourceUrl = sourceUrl,
|
||||
redirectUrl = "about:blank",
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
|
||||
SandboxCaptureRegistry.resetAll(populated)
|
||||
logger.debug("Rehydrated ${populated.size} sandbox tab(s) from disk")
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Failed to rehydrate sandbox registry; starting empty", t)
|
||||
SandboxCaptureRegistry.resetAll(emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
fun wireFlutterEvents(binaryMessenger: BinaryMessenger) {
|
||||
SandboxCaptureApi.setUp(binaryMessenger, this)
|
||||
SandboxCaptureBridge.events = SandboxCaptureHostEvents(binaryMessenger)
|
||||
}
|
||||
|
||||
fun detachFlutterEvents(binaryMessenger: BinaryMessenger) {
|
||||
SandboxCaptureApi.setUp(binaryMessenger, null)
|
||||
SandboxCaptureBridge.events = null
|
||||
}
|
||||
|
||||
// SandboxCaptureApi implementation: Dart -> Kotlin.
|
||||
|
||||
override fun resetAll(entries: List<SandboxCaptureEntry>) {
|
||||
val map = HashMap<String, SandboxCaptureRegistry.SandboxEntry>()
|
||||
for (e in entries) {
|
||||
map[e.tabId] = e.toRegistryEntry()
|
||||
}
|
||||
SandboxCaptureRegistry.resetAll(map)
|
||||
}
|
||||
|
||||
override fun mark(entry: SandboxCaptureEntry) {
|
||||
SandboxCaptureRegistry.put(entry.tabId, entry.toRegistryEntry())
|
||||
}
|
||||
|
||||
override fun unmark(tabId: String) {
|
||||
SandboxCaptureRegistry.remove(tabId)
|
||||
}
|
||||
|
||||
// JSON mirror file under the active profile dir. Dart writes it on every
|
||||
// mutation; Kotlin reads it during pre-Gecko bootstrap.
|
||||
private fun sandboxCapturesFile(context: Context): File? {
|
||||
if (ActiveProfile.prefix == null) {
|
||||
ActiveProfile.resolveFromDisk(context)
|
||||
}
|
||||
val prefix = ActiveProfile.prefix ?: return null
|
||||
val profileDir = File(
|
||||
context.filesDir,
|
||||
"${PwaConstants.PROFILES_DIR_NAME}/$prefix",
|
||||
)
|
||||
return File(profileDir, "files/sandbox_captures.json")
|
||||
}
|
||||
|
||||
// Exposed for Dart tests that want to verify the JSON format without
|
||||
// writing a binding — the Dart side uses the same path convention.
|
||||
fun sandboxCapturesJson(entries: List<SandboxCaptureRegistry.SandboxEntry>, tabIds: List<String>): String {
|
||||
require(entries.size == tabIds.size)
|
||||
val array = JSONArray()
|
||||
entries.forEachIndexed { i, e ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("tabId", tabIds[i])
|
||||
put("captureId", e.captureId)
|
||||
put("sourceUrl", e.sourceUrl)
|
||||
put("status", e.status)
|
||||
},
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("version", SUPPORTED_VERSION)
|
||||
.put("entries", array)
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.net.Uri
|
||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureEntry
|
||||
import eu.weblibre.flutter_mozilla_components.pigeons.SandboxCaptureHostEvents
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Per-tab sandbox capture state consulted by AppRequestInterceptor on every
|
||||
// load. The registry is authoritative for runtime decisions and is populated
|
||||
// from three sources:
|
||||
// 1. Pre-Gecko-init rehydration from <profileDir>/files/sandbox_captures.json
|
||||
// (see SandboxCaptureFeature.preRestoreBootstrap).
|
||||
// 2. Dart pushing resetAll / mark / unmark at runtime via the
|
||||
// SandboxCaptureApi pigeon channel.
|
||||
// 3. The search-results entry point when the user opens a root capture.
|
||||
object SandboxCaptureRegistry {
|
||||
data class SandboxEntry(
|
||||
val captureId: String,
|
||||
val sourceUrl: String,
|
||||
val redirectUrl: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
private val store = ConcurrentHashMap<String, SandboxEntry>()
|
||||
|
||||
fun get(tabId: String): SandboxEntry? = store[tabId]
|
||||
|
||||
fun put(tabId: String, entry: SandboxEntry) {
|
||||
store[tabId] = entry
|
||||
}
|
||||
|
||||
fun remove(tabId: String) {
|
||||
store.remove(tabId)
|
||||
}
|
||||
|
||||
fun resetAll(newEntries: Map<String, SandboxEntry>) {
|
||||
store.clear()
|
||||
store.putAll(newEntries)
|
||||
}
|
||||
|
||||
fun snapshot(): Map<String, SandboxEntry> = HashMap(store)
|
||||
|
||||
// Returns true for URLs the loopback CaptureServer owns: /captures/* and
|
||||
// /loader*. Used by the interceptor to allow self-redirects and by the
|
||||
// middleware to skip re-dispatching Dart-originated navigations.
|
||||
fun isLoopbackRedirect(url: String): Boolean {
|
||||
val parsed = Uri.parse(url)
|
||||
if (parsed.scheme != "http") return false
|
||||
if (parsed.host != "127.0.0.1") return false
|
||||
val path = parsed.path.orEmpty()
|
||||
return path.startsWith("/captures") || path.startsWith("/loader")
|
||||
}
|
||||
}
|
||||
|
||||
// Inert external schemes that never trigger a network request from the
|
||||
// current tab. When a sandbox tab navigates to one of these we let the
|
||||
// normal AppLinksInterceptor handle it (usually by dispatching an Android
|
||||
// intent).
|
||||
object InertExternalSchemes {
|
||||
private val schemes = setOf(
|
||||
"mailto",
|
||||
"tel",
|
||||
"sms",
|
||||
"mms",
|
||||
"geo",
|
||||
"intent",
|
||||
"market",
|
||||
"maps",
|
||||
)
|
||||
|
||||
fun matches(uri: Uri): Boolean {
|
||||
val scheme = uri.scheme?.lowercase() ?: return false
|
||||
return schemes.contains(scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// Holds the lazily-initialised SandboxCaptureHostEvents pigeon sender so the
|
||||
// interceptor and middleware can fire-and-forget dispatch link clicks / new
|
||||
// tabs without knowing about the Flutter binding lifecycle.
|
||||
object SandboxCaptureBridge {
|
||||
@Volatile
|
||||
var events: SandboxCaptureHostEvents? = null
|
||||
|
||||
fun dispatchLinkClick(parentTabId: String, targetUrl: String) {
|
||||
val e = events ?: return
|
||||
e.onSandboxLinkClick(EventSequence.next(), parentTabId, targetUrl) {}
|
||||
}
|
||||
|
||||
fun dispatchNewTab(parentTabId: String, newTabId: String, targetUrl: String) {
|
||||
val e = events ?: return
|
||||
e.onSandboxNewTab(EventSequence.next(), parentTabId, newTabId, targetUrl) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun SandboxCaptureEntry.toRegistryEntry(): SandboxCaptureRegistry.SandboxEntry {
|
||||
return SandboxCaptureRegistry.SandboxEntry(
|
||||
captureId = captureId,
|
||||
sourceUrl = sourceUrl,
|
||||
redirectUrl = redirectUrl,
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
+73
@@ -6,8 +6,15 @@
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.interceptor
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureRegistry
|
||||
import mozilla.components.browser.errorpages.ErrorPages
|
||||
import mozilla.components.browser.errorpages.ErrorType
|
||||
import mozilla.components.browser.state.selector.findTabOrCustomTab
|
||||
@@ -42,6 +49,72 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parsed once and reused by both the sandbox-capture handling and the
|
||||
// weblibre:// deep-link check below. `Uri.parse` never throws for
|
||||
// malformed input — it returns a Uri with empty fields — so callers
|
||||
// must check the scheme explicitly.
|
||||
val parsed = Uri.parse(uri)
|
||||
|
||||
// Sandbox capture tabs: rewrite loads to loopback, deny links to live URLs.
|
||||
val sandboxEntry = (customTab?.id)?.let { SandboxCaptureRegistry.get(it) }
|
||||
if (sandboxEntry != null) {
|
||||
when {
|
||||
// Loopback redirects emitted by us — pass through.
|
||||
SandboxCaptureRegistry.isLoopbackRedirect(uri) -> {
|
||||
// Fall through to existing accounts/applinks logic below —
|
||||
// loopback URLs don't match any of them and the default
|
||||
// "return null" at the bottom lets Gecko load normally.
|
||||
}
|
||||
// Canonical source URL — redirect to current loader/capture.
|
||||
uri == sandboxEntry.sourceUrl -> {
|
||||
return RequestInterceptor.InterceptionResponse.Url(
|
||||
sandboxEntry.redirectUrl,
|
||||
)
|
||||
}
|
||||
// Inert external schemes — hand off to existing AppLinks logic.
|
||||
InertExternalSchemes.matches(parsed) -> {
|
||||
// Fall through to AppLinks handling below.
|
||||
}
|
||||
// Any other URL — sandbox tab is trying to navigate to the live
|
||||
// web. Deny and ask Flutter to open a new sandbox tab instead.
|
||||
else -> {
|
||||
customTab.id.let { parentId ->
|
||||
SandboxCaptureBridge.dispatchLinkClick(parentId, uri)
|
||||
}
|
||||
return RequestInterceptor.InterceptionResponse.Deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept weblibre:// deep links and dispatch them as Android intents
|
||||
// so the Flutter side can handle them (e.g. account callback handoff).
|
||||
if (parsed.scheme == "weblibre") {
|
||||
val intent = Intent(Intent.ACTION_VIEW, parsed).apply {
|
||||
setPackage(context.packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
// No fallbackUrl — the intent targets our own package, so if
|
||||
// startActivity didn't throw we've handled the navigation
|
||||
// completely and there's no in-engine load to fall back to.
|
||||
// appName is null because there's no user-facing app picker
|
||||
// (the intent is package-scoped to us via setPackage above).
|
||||
return RequestInterceptor.InterceptionResponse.AppIntent(
|
||||
appIntent = intent,
|
||||
url = uri,
|
||||
fallbackUrl = null,
|
||||
appName = null,
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No activity is registered for this weblibre:// URI (cold-start
|
||||
// race, manifest mismatch, etc.). Let the load fall through so
|
||||
// Gecko shows a normal "can't load" page instead of crashing the
|
||||
// load flow on an unhandled exception.
|
||||
Log.w("AppRequestInterceptor", "No activity for $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
components.services.accountsAuthFeature.interceptor.onLoadRequest(
|
||||
engineSession,
|
||||
uri,
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.feature.SandboxCaptureBridge
|
||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureRegistry
|
||||
import mozilla.components.browser.state.action.BrowserAction
|
||||
import mozilla.components.browser.state.action.TabListAction
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.lib.state.Middleware
|
||||
|
||||
// Stops _blank / window.open / middle-click from leaking the first request
|
||||
// when the originating tab is a sandbox capture tab.
|
||||
//
|
||||
// WindowFeature creates new app tabs via TabListAction.AddTabAction with the
|
||||
// target URL already populated. AppRequestInterceptor.onLoadRequest would fire
|
||||
// on the first load, but by that point Gecko has already issued the request.
|
||||
// This middleware runs synchronously before that: it rewrites the new tab's
|
||||
// URL to about:blank and dispatches the capture flow to Dart.
|
||||
val SandboxCaptureMiddleware: Middleware<BrowserState, BrowserAction> =
|
||||
{ _, next, action ->
|
||||
if (action !is TabListAction.AddTabAction) {
|
||||
next(action)
|
||||
} else {
|
||||
val parentId = action.tab.parentId
|
||||
val parentEntry = parentId?.let { SandboxCaptureRegistry.get(it) }
|
||||
val targetUrl = action.tab.content.url
|
||||
when {
|
||||
parentEntry == null -> {
|
||||
next(action)
|
||||
}
|
||||
SandboxCaptureRegistry.isLoopbackRedirect(targetUrl) -> {
|
||||
// We initiated this AddTab for a sandbox capture.
|
||||
next(action)
|
||||
}
|
||||
else -> {
|
||||
val rewrittenTab = action.tab.copy(
|
||||
content = action.tab.content.copy(url = "about:blank"),
|
||||
)
|
||||
next(TabListAction.AddTabAction(rewrittenTab, action.select))
|
||||
SandboxCaptureBridge.dispatchNewTab(
|
||||
parentTabId = parentId,
|
||||
newTabId = action.tab.id,
|
||||
targetUrl = targetUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+935
-219
File diff suppressed because it is too large
Load Diff
+74
@@ -0,0 +1,74 @@
|
||||
/* 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.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||
import eu.weblibre.flutter_mozilla_components.R
|
||||
import eu.weblibre.flutter_mozilla_components.activities.AuthCustomTabActivity
|
||||
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
|
||||
import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity
|
||||
import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity
|
||||
import mozilla.components.browser.state.store.BrowserStore
|
||||
import mozilla.components.feature.privatemode.notification.AbstractPrivateNotificationService
|
||||
import mozilla.components.support.base.android.NotificationsDelegate
|
||||
|
||||
class PrivateTabsNotificationService : AbstractPrivateNotificationService() {
|
||||
private val components by lazy {
|
||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||
}
|
||||
|
||||
override val store: BrowserStore by lazy { components.core.store }
|
||||
|
||||
override val notificationsDelegate: NotificationsDelegate by lazy {
|
||||
components.notificationsDelegate
|
||||
}
|
||||
|
||||
override fun NotificationCompat.Builder.buildNotification() {
|
||||
setSmallIcon(R.drawable.mdi_icon_domino_mask)
|
||||
|
||||
val contentTitle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
applicationContext.getString(R.string.private_tabs_notification_title_android_14)
|
||||
} else {
|
||||
applicationContext.getString(R.string.private_tabs_notification_text)
|
||||
}
|
||||
|
||||
val contentText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
applicationContext.getString(R.string.private_tabs_notification_text_android_14)
|
||||
} else {
|
||||
applicationContext.getString(R.string.private_tabs_notification_text)
|
||||
}
|
||||
|
||||
setContentTitle(contentTitle)
|
||||
setContentText(contentText)
|
||||
|
||||
color = ContextCompat.getColor(
|
||||
this@PrivateTabsNotificationService,
|
||||
R.color.private_tab_mask_accent,
|
||||
)
|
||||
}
|
||||
|
||||
override fun notifyLocaleChanged() {
|
||||
refreshNotification()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun erasePrivateTabs() {
|
||||
components.useCases.tabsUseCases.removePrivateTabs()
|
||||
}
|
||||
|
||||
override fun ignoreTaskComponentClasses(): List<String> = listOf(
|
||||
ExternalAppBrowserActivity::class.qualifiedName!!,
|
||||
IntentReceiverActivity::class.qualifiedName!!,
|
||||
AuthIntentReceiverActivity::class.qualifiedName!!,
|
||||
AuthCustomTabActivity::class.qualifiedName!!,
|
||||
)
|
||||
|
||||
override fun ignoreTaskActions(): List<String> = emptyList()
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package eu.weblibre.flutter_mozilla_components.widget
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
// import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Root container for the embedded browser fragment.
|
||||
*
|
||||
* Why: Flutter's platform-view motion-event pipeline does not reliably forward
|
||||
* ACTION_CANCEL when the Android system claims a back gesture mid-stream. On
|
||||
* affected devices (notably Samsung One UI) GeckoView's APZ receives the
|
||||
* leading touches of the back gesture and produces a short, unwanted page
|
||||
* scroll before the gesture is recognized.
|
||||
*
|
||||
* This view watches every touch in dispatchTouchEvent (rather than
|
||||
* onInterceptTouchEvent — NestedGeckoView calls requestDisallowInterceptTouchEvent
|
||||
* on ACTION_DOWN, which would suppress the intercept hook for the rest of the
|
||||
* gesture). When a horizontal drag begins inside the system back-gesture inset
|
||||
* and the gesture becomes unambiguous (horizontal travel exceeds touch slop and
|
||||
* dominates over vertical travel), it dispatches a synthetic ACTION_CANCEL to
|
||||
* children and swallows the remainder of the gesture. APZ resets cleanly on
|
||||
* the cancel.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class BackGestureFilterFrameLayout(
|
||||
context: Context,
|
||||
private val activity: Activity,
|
||||
) : FrameLayout(context) {
|
||||
private var downX = 0f
|
||||
private var downY = 0f
|
||||
private var startedInEdgeZone = false
|
||||
private var hasIntercepted = false
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downX = ev.x
|
||||
downY = ev.y
|
||||
hasIntercepted = false
|
||||
val (left, right) = currentGestureInsets()
|
||||
startedInEdgeZone = isInEdgeZone(ev.x, left, right)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (hasIntercepted) {
|
||||
return true
|
||||
}
|
||||
if (startedInEdgeZone) {
|
||||
val dx = abs(ev.x - downX)
|
||||
val dy = abs(ev.y - downY)
|
||||
when {
|
||||
// Horizontal-dominant: this is a back-gesture pan. The OS will
|
||||
// CANCEL shortly, but APZ's internal pan threshold is smaller
|
||||
// than Android's touchSlop, so even the small leading MOVEs
|
||||
// already scrolled the page. Cancel APZ now and swallow the rest.
|
||||
dx > dy + 1 -> {
|
||||
hasIntercepted = true
|
||||
// Log.d(TAG, "INTERCEPT dx=$dx dy=$dy")
|
||||
dispatchSyntheticCancel(ev)
|
||||
return true
|
||||
}
|
||||
// Vertical-dominant: the gesture is a real in-page scroll;
|
||||
// stop watching so subsequent MOVEs pass through.
|
||||
dy > dx + 1 -> {
|
||||
startedInEdgeZone = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
val wasIntercepted = hasIntercepted
|
||||
resetGestureState()
|
||||
if (wasIntercepted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
private fun dispatchSyntheticCancel(source: MotionEvent) {
|
||||
val cancel = MotionEvent.obtain(source).apply {
|
||||
action = MotionEvent.ACTION_CANCEL
|
||||
}
|
||||
super.dispatchTouchEvent(cancel)
|
||||
cancel.recycle()
|
||||
}
|
||||
|
||||
private fun resetGestureState() {
|
||||
startedInEdgeZone = false
|
||||
hasIntercepted = false
|
||||
}
|
||||
|
||||
private fun currentGestureInsets(): Pair<Int, Int> {
|
||||
val rawInsets = activity.window.decorView.rootWindowInsets ?: return 0 to 0
|
||||
val gestureInsets = WindowInsetsCompat.toWindowInsetsCompat(rawInsets)
|
||||
.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
return gestureInsets.left to gestureInsets.right
|
||||
}
|
||||
|
||||
private fun isInEdgeZone(x: Float, left: Int, right: Int): Boolean {
|
||||
if (left == 0 && right == 0) {
|
||||
// 3-button navigation (or unknown): no system back gesture to defend
|
||||
// against, so all edge touches must reach the engine view normally.
|
||||
return false
|
||||
}
|
||||
return x <= left || x >= width - right
|
||||
}
|
||||
|
||||
// companion object {
|
||||
// private const val TAG = "BackGestureFilter"
|
||||
// }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M15.83,9.81C14.7,9.7 13.69,10.38 13.46,11.5C13.46,11.84 14.81,12.29 16.05,12.29C17.29,12.29 18.41,11.5 18.41,11.28C18.41,11.05 17.63,9.93 15.83,9.81M8.18,9.81C6.38,9.93 5.59,10.94 5.59,11.27C5.59,11.5 6.82,12.29 7.95,12.29S10.54,11.84 10.54,11.5C10.31,10.38 9.19,9.7 8.18,9.81M16.95,16C15.04,16 13.8,13.75 12,13.75S8.85,16 7.05,16C4.69,16 3,13.86 3,10.04C3,7.68 3.68,7 6.71,7S10.54,8.24 12,8.24 14.36,7 17.29,7 21,7.79 21,10.04C21,13.86 19.31,16 16.95,16Z" />
|
||||
</vector>
|
||||
+4
-6
@@ -5,24 +5,22 @@
|
||||
|
||||
<!-- Main browser fragment layout -->
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
<FrameLayout
|
||||
android:id="@+id/customTabAppBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Placeholder for custom tab toolbar - ExternalAppBrowserFragment provides its own -->
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/browserContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<mozilla.components.ui.widgets.VerticalSwipeRefreshLayout
|
||||
android:id="@+id/swipeToRefresh"
|
||||
|
||||
@@ -26,5 +26,8 @@
|
||||
<string name="mozac_feature_customtabs_exit_button">Close</string>
|
||||
<string name="mozac_feature_customtabs_menu_button">Menu</string>
|
||||
<string name="mozac_feature_customtabs_security_indicator">Security</string>
|
||||
<string name="private_tabs_notification_text">Close private tabs</string>
|
||||
<string name="private_tabs_notification_title_android_14">Close private tabs?</string>
|
||||
<string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -61,6 +61,7 @@ export 'src/pigeons/gecko.g.dart'
|
||||
CookieBannerHandlingMode,
|
||||
CookieSameSiteStatus,
|
||||
CustomCookiePolicy,
|
||||
DocumentType,
|
||||
DohSettings,
|
||||
DohSettingsMode,
|
||||
EmailHitResult,
|
||||
@@ -78,7 +79,9 @@ export 'src/pigeons/gecko.g.dart'
|
||||
GeoHitResult,
|
||||
HistoryHighlight,
|
||||
HistoryHighlightWeights,
|
||||
HistoryMetadata,
|
||||
HistoryMetadataKey,
|
||||
HistorySuggestion,
|
||||
HitResult,
|
||||
HttpsOnlyMode,
|
||||
IconSource,
|
||||
@@ -95,6 +98,9 @@ export 'src/pigeons/gecko.g.dart'
|
||||
QueryParameterStripping,
|
||||
Resource,
|
||||
ResourceSize,
|
||||
SandboxCaptureApi,
|
||||
SandboxCaptureEntry,
|
||||
SandboxCaptureHostEvents,
|
||||
SecurityInfoState,
|
||||
SitePermissionStatus,
|
||||
SitePermissions,
|
||||
|
||||
@@ -74,4 +74,72 @@ class GeckoHistoryService {
|
||||
}) {
|
||||
return _api.getTopFrecentSites(limit, frecencyThreshold);
|
||||
}
|
||||
|
||||
Future<HistoryMetadata?> getLatestHistoryMetadataForUrl(String url) {
|
||||
return _api.getLatestHistoryMetadataForUrl(url);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata?>> getLatestHistoryMetadataForUrls(
|
||||
List<String> urls,
|
||||
) {
|
||||
if (urls.isEmpty) return Future.value(const []);
|
||||
return _api.getLatestHistoryMetadataForUrls(urls);
|
||||
}
|
||||
|
||||
Future<List<bool>> getVisited(List<String> urls) {
|
||||
if (urls.isEmpty) return Future.value(const []);
|
||||
return _api.getVisited(urls);
|
||||
}
|
||||
|
||||
Future<List<HistorySuggestion>> getSuggestions(String query, {int limit = 10}) {
|
||||
return _api.getSuggestions(query, limit);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata>> queryHistoryMetadata(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) {
|
||||
return _api.queryHistoryMetadata(query, limit);
|
||||
}
|
||||
|
||||
Future<void> recordObservation(
|
||||
String url, {
|
||||
String? title,
|
||||
String? previewImageUrl,
|
||||
}) {
|
||||
return _api.recordObservation(
|
||||
url,
|
||||
PageObservation(title: title, previewImageUrl: previewImageUrl),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> noteHistoryMetadataViewTime(
|
||||
HistoryMetadataKey key,
|
||||
Duration viewTime,
|
||||
) {
|
||||
return _api.noteHistoryMetadataViewTime(key, viewTime.inMilliseconds);
|
||||
}
|
||||
|
||||
Future<void> noteHistoryMetadataDocumentType(
|
||||
HistoryMetadataKey key,
|
||||
DocumentType documentType,
|
||||
) {
|
||||
return _api.noteHistoryMetadataDocumentType(key, documentType);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsFor(String url) {
|
||||
return _api.deleteVisitsFor(url);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsSince(DateTime since) {
|
||||
return _api.deleteVisitsSince(since.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
Future<void> deleteEverything() {
|
||||
return _api.deleteEverything();
|
||||
}
|
||||
|
||||
Future<void> deleteHistoryMetadataOlderThan(DateTime olderThan) {
|
||||
return _api.deleteHistoryMetadataOlderThan(olderThan.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -555,6 +555,59 @@ class TopFrecentSiteInfo {
|
||||
|
||||
enum FrecencyThresholdOption { none, skipOneTimePages }
|
||||
|
||||
/// Document type associated with a [HistoryMetadata] record.
|
||||
enum DocumentType { regular, media }
|
||||
|
||||
/// Per-URL metadata maintained by Places. The unique identity of a record is
|
||||
/// [HistoryMetadataKey] (url + searchTerm + referrerUrl); we surface only the
|
||||
/// most recent record per URL via `getLatestHistoryMetadataForUrl`.
|
||||
class HistoryMetadata {
|
||||
final HistoryMetadataKey key;
|
||||
final String? title;
|
||||
|
||||
/// Unix milliseconds.
|
||||
final int createdAt;
|
||||
|
||||
/// Unix milliseconds.
|
||||
final int updatedAt;
|
||||
|
||||
/// Total view time in milliseconds.
|
||||
final int totalViewTime;
|
||||
|
||||
final DocumentType documentType;
|
||||
final String? previewImageUrl;
|
||||
|
||||
HistoryMetadata(
|
||||
this.key,
|
||||
this.title,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.totalViewTime,
|
||||
this.documentType,
|
||||
this.previewImageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// Frecency-ranked autocomplete suggestion. Backs `getSuggestions`.
|
||||
class HistorySuggestion {
|
||||
final String url;
|
||||
final String? title;
|
||||
|
||||
/// Larger is more relevant. Unbounded; only meaningful relative to other
|
||||
/// suggestions returned in the same call.
|
||||
final int score;
|
||||
|
||||
HistorySuggestion(this.url, this.title, this.score);
|
||||
}
|
||||
|
||||
/// Optional metadata observation for a URL. `null` fields are not written.
|
||||
class PageObservation {
|
||||
final String? title;
|
||||
final String? previewImageUrl;
|
||||
|
||||
PageObservation({this.title, this.previewImageUrl});
|
||||
}
|
||||
|
||||
class HistoryItem {
|
||||
final String url;
|
||||
final String title;
|
||||
@@ -2133,6 +2186,69 @@ abstract class GeckoHistoryApi {
|
||||
int limit,
|
||||
FrecencyThresholdOption frecencyThreshold,
|
||||
);
|
||||
|
||||
/// Returns the most recent [HistoryMetadata] record for [url], or `null` if
|
||||
/// no metadata has been recorded for that URL.
|
||||
@async
|
||||
HistoryMetadata? getLatestHistoryMetadataForUrl(String url);
|
||||
|
||||
/// Bulk variant of [getLatestHistoryMetadataForUrl]. Returns one entry per
|
||||
/// input URL aligned by index; entries are `null` for URLs Places has no
|
||||
/// metadata for. Used by the local search re-rank to collapse N IPC
|
||||
/// roundtrips into one.
|
||||
@async
|
||||
List<HistoryMetadata?> getLatestHistoryMetadataForUrls(List<String> urls);
|
||||
|
||||
/// Bulk visited check: returns booleans aligned with [urls] indicating
|
||||
/// whether Places has any visit recorded for each URL.
|
||||
@async
|
||||
List<bool> getVisited(List<String> urls);
|
||||
|
||||
/// Frecency-ranked autocomplete results. Mirrors Places' awesomebar input.
|
||||
@async
|
||||
List<HistorySuggestion> getSuggestions(String query, int limit);
|
||||
|
||||
/// Places' built-in metadata text search (matches title / url / searchTerm).
|
||||
/// Useful as a comparison baseline against the local content FTS.
|
||||
@async
|
||||
List<HistoryMetadata> queryHistoryMetadata(String query, int limit);
|
||||
|
||||
/// Records a title / preview-image observation for [url] without recording
|
||||
/// a visit. Intended for manual flows; the engine middleware records these
|
||||
/// automatically as the user browses.
|
||||
@async
|
||||
void recordObservation(String url, PageObservation observation);
|
||||
|
||||
/// Records a view-time observation against the metadata record identified
|
||||
/// by [key]. View time is added to the existing total.
|
||||
@async
|
||||
void noteHistoryMetadataViewTime(HistoryMetadataKey key, int viewTimeMs);
|
||||
|
||||
/// Records a document-type observation against the metadata record
|
||||
/// identified by [key].
|
||||
@async
|
||||
void noteHistoryMetadataDocumentType(
|
||||
HistoryMetadataKey key,
|
||||
DocumentType documentType,
|
||||
);
|
||||
|
||||
/// Removes all visits for [url]. May propagate to remote devices via Sync.
|
||||
@async
|
||||
void deleteVisitsFor(String url);
|
||||
|
||||
/// Removes all visits since [sinceMillis] (inclusive). May propagate to
|
||||
/// remote devices via Sync.
|
||||
@async
|
||||
void deleteVisitsSince(int sinceMillis);
|
||||
|
||||
/// Removes all locally stored history. Sync will not remove remote history,
|
||||
/// but it will prevent deleted entries from returning.
|
||||
@async
|
||||
void deleteEverything();
|
||||
|
||||
/// Prunes history metadata older than [olderThanMillis] (exclusive).
|
||||
@async
|
||||
void deleteHistoryMetadataOlderThan(int olderThanMillis);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
@@ -2766,3 +2882,70 @@ abstract class GeckoPwaApi {
|
||||
String? overrideShortcutName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-tab sandbox capture state shared with the native side. The Kotlin
|
||||
/// [AppRequestInterceptor] consults an in-memory registry populated from
|
||||
/// these entries to decide how to handle loads in sandbox tabs.
|
||||
///
|
||||
/// [redirectUrl] is precomputed by Dart and always points at a loopback URL
|
||||
/// (loader or capture). Dart is responsible for keeping it current; Kotlin
|
||||
/// never calls back into Dart to resolve it.
|
||||
class SandboxCaptureEntry {
|
||||
final String tabId;
|
||||
final String captureId;
|
||||
final String sourceUrl;
|
||||
|
||||
/// `http://127.0.0.1:<port>/loader?…` while pending/failed, or
|
||||
/// `http://127.0.0.1:<port>/captures/…?t=<token>` once ready.
|
||||
final String redirectUrl;
|
||||
|
||||
/// `pending` | `ready` | `failed`.
|
||||
final String status;
|
||||
|
||||
SandboxCaptureEntry({
|
||||
required this.tabId,
|
||||
required this.captureId,
|
||||
required this.sourceUrl,
|
||||
required this.redirectUrl,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
|
||||
/// Dart → Kotlin. Mutates the native [SandboxCaptureRegistry] that the
|
||||
/// request interceptor consults on every load.
|
||||
@HostApi()
|
||||
abstract class SandboxCaptureApi {
|
||||
/// Replaces the entire registry with [entries]. Called at startup after
|
||||
/// Dart has brought up [CaptureServer] and reconciled local artifacts with
|
||||
/// the `capture_tab` rows.
|
||||
void resetAll(List<SandboxCaptureEntry> entries);
|
||||
|
||||
/// Inserts or updates the registry entry for [entry.tabId].
|
||||
void mark(SandboxCaptureEntry entry);
|
||||
|
||||
/// Removes the registry entry for [tabId].
|
||||
void unmark(String tabId);
|
||||
}
|
||||
|
||||
/// Kotlin → Dart. Fire-and-forget notifications from the request
|
||||
/// interceptor / BrowserStore middleware. All handlers are non-blocking;
|
||||
/// the interceptor never waits for a Dart response.
|
||||
@FlutterApi()
|
||||
abstract class SandboxCaptureHostEvents {
|
||||
/// Emitted when a sandbox tab attempted to navigate to a non-loopback,
|
||||
/// non-source URL (e.g., user clicked a link or typed a new URL into the
|
||||
/// address bar). Dart should open a new sandbox tab and capture [targetUrl].
|
||||
void onSandboxLinkClick(int sequence, String parentTabId, String targetUrl);
|
||||
|
||||
/// Emitted when GeckoView created a new tab (via `window.open`,
|
||||
/// `target="_blank"`, or a middle-click) whose parent is a sandbox tab.
|
||||
/// The native middleware has already rewritten the new tab's URL to
|
||||
/// `about:blank`; Dart should register it as sandbox and run the capture
|
||||
/// pipeline for [targetUrl].
|
||||
void onSandboxNewTab(
|
||||
int sequence,
|
||||
String parentTabId,
|
||||
String newTabId,
|
||||
String targetUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user