Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -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"]
);
@@ -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"]
}
}
@@ -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()
}
}
}
@@ -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/",
@@ -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,
@@ -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) {
@@ -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
}
@@ -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() {
@@ -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() }
@@ -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))
}
}
}
}
@@ -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,
@@ -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(
@@ -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()
}
}
@@ -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,
)
}
@@ -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,
@@ -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,
)
}
}
}
}
@@ -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()
}
@@ -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"
// }
}
@@ -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>
@@ -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>