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>
@@ -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,
);
}
@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.core.content.ContextCompat
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorApi
import eu.weblibre.flutter_tor.generated.TorConfiguration
@@ -29,22 +30,25 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
}
private var context: Context? = null
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
private var torService: TorService? = null
private var serviceConnection: ServiceConnection? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Service connection state
@Volatile
private var serviceConnected = CompletableDeferred<Unit>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onAttachedToEngine")
context = flutterPluginBinding.applicationContext
binaryMessenger = flutterPluginBinding.binaryMessenger
// Setup Pigeon API
TorApi.setUp(flutterPluginBinding.binaryMessenger, this)
// Bind to TorService
bindTorService(flutterPluginBinding)
// Reconnect to an already-running TorService without creating a new idle instance.
bindTorService(createIfNeeded = false)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -60,40 +64,62 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
scope.cancel()
context = null
binaryMessenger = null
}
/**
* Bind to TorService
* Bind to TorService. Idempotent — used both for initial bind and rebind on disconnect.
*/
private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
private fun bindTorService(createIfNeeded: Boolean) {
val ctx = context ?: return
val messenger = binaryMessenger ?: return
if (serviceConnection != null) return
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "TorService connected")
val binder = service as? TorService.LocalBinder
torService = binder?.getService()
torService?.initialize(flutterPluginBinding.binaryMessenger)
torService?.initialize(messenger)
// Signal that service is connected
serviceConnected.complete(Unit)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
Log.w(TAG, "TorService disconnected — will rebind")
torService = null
// Reset connection deferred for potential reconnection
serviceConnected = CompletableDeferred()
// Drop the stale ServiceConnection reference so bindTorService() reattempts.
serviceConnection = null
scope.launch {
delay(500)
bindTorService(createIfNeeded = true)
}
}
}
serviceConnection = connection
val intent = Intent(ctx, TorService::class.java)
// Only bind to service, don't start it yet
// Service will be started when startTor() is called
ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE)
val intent = Intent(ctx, TorService::class.java).apply {
if (createIfNeeded) {
action = TorService.ACTION_START
}
}
try {
if (createIfNeeded) {
ContextCompat.startForegroundService(ctx, intent)
}
val flags = if (createIfNeeded) Context.BIND_AUTO_CREATE else 0
if (!ctx.bindService(intent, connection, flags)) {
Log.w(TAG, "bindService returned false (createIfNeeded=$createIfNeeded)")
serviceConnection = null
}
} catch (e: Exception) {
Log.e(TAG, "bindService failed", e)
serviceConnection = null
}
}
/**
@@ -112,9 +138,12 @@ class FlutterTorPlugin : FlutterPlugin, TorApi {
}
/**
* Wait for service to be connected
* Wait for service to be connected. Triggers a rebind if needed.
*/
private suspend fun waitForService(): TorService {
if (serviceConnection == null) {
bindTorService(createIfNeeded = true)
}
return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) {
serviceConnected.await()
torService
@@ -30,6 +30,9 @@ class PluggableTransportManager private constructor(private val context: Context
private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com")
private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478"
private const val PORT_READY_TIMEOUT_MS = 10_000L
private const val PORT_READY_POLL_MS = 100L
@Volatile
private var instance: PluggableTransportManager? = null
@@ -102,60 +105,25 @@ class PluggableTransportManager private constructor(private val context: Context
try {
when (type) {
TransportType.OBFS4 -> {
val transportName = IPtProxy.Obfs4
controller.start(transportName, null) // null = no proxy
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Obfs4)?.let { ports[IPtProxy.Obfs4] = it }
}
TransportType.SNOWFLAKE -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = false)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it }
}
TransportType.SNOWFLAKE_AMP -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = true)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName (AMP) started on port $port")
}
startAndAwait(IPtProxy.Snowflake)?.let { ports[IPtProxy.Snowflake] = it }
}
TransportType.MEEK, TransportType.MEEK_AZURE -> {
val transportName = IPtProxy.MeekLite
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.MeekLite)?.let { ports[IPtProxy.MeekLite] = it }
}
TransportType.WEBTUNNEL -> {
val transportName = IPtProxy.Webtunnel
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
startAndAwait(IPtProxy.Webtunnel)?.let { ports[IPtProxy.Webtunnel] = it }
}
TransportType.NONE, TransportType.CUSTOM -> {
@@ -169,6 +137,30 @@ class PluggableTransportManager private constructor(private val context: Context
return ports
}
/**
* Start a transport and poll until controller.port(name) returns a usable value.
* IPtProxy assigns the port asynchronously after `start()`; without this poll, we
* may read 0 and silently skip the ClientTransportPlugin line in torrc, causing
* tor to bootstrap with `UseBridges 1` but no transport — bootstrap stalls.
*/
private fun startAndAwait(transportName: String): Int? {
controller.start(transportName, null) // null = no proxy
activeTransports.add(transportName)
val deadline = System.currentTimeMillis() + PORT_READY_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
val port = controller.port(transportName)
if (port > 0) {
Log.d(TAG, "$transportName started on port $port")
return port.toInt()
}
Thread.sleep(PORT_READY_POLL_MS)
}
Log.e(TAG, "$transportName failed to bind a port within ${PORT_READY_TIMEOUT_MS}ms")
throw IllegalStateException("Pluggable transport $transportName did not become ready in time")
}
/**
* Configure Snowflake-specific settings
*/
@@ -1,32 +0,0 @@
package eu.weblibre.flutter_tor
import java.net.ServerSocket
/**
* Manages random port allocation for Tor and pluggable transports
*/
object PortManager {
/**
* Find an available random port by binding to port 0
* @return Available port number
*/
fun findAvailablePort(): Int {
return ServerSocket(0).use { socket ->
socket.localPort
}
}
/**
* Check if a specific port is available
* @param port Port to check
* @return true if port is available
*/
fun isPortAvailable(port: Int): Boolean {
return try {
ServerSocket(port).use { true }
} catch (e: Exception) {
false
}
}
}
@@ -5,39 +5,36 @@ import IPtProxy.IPtProxy
import java.io.File
/**
* Generates Tor configuration (torrc) based on user settings
* Generates Tor configuration (torrc) based on user settings.
*
* Notes on what we deliberately do NOT set here:
* - DataDirectory, ControlSocket, CookieAuthentication, RunAsDaemon, CacheDirectory,
* SyslogIdentityTag — upstream org.torproject.jni.TorService passes these on the
* `tor` command line. Setting them here causes duplicate-option warnings or contradicts
* the values upstream needs (e.g. RunAsDaemon must be 0 in-process).
* - SocksPort / HTTPTunnelPort — upstream rewrites defaults-torrc on every start with
* `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`. tor REJECTS mixing
* `SocksPort 0` with any other SocksPort line ("Invalid SocksPort configuration"),
* so we cannot override these from torrc. Instead we just consume what upstream
* binds — read it from the static `TorService.socksPort` field (or via
* `getInfo net/listeners/socks`).
* - DNSPort / TransPort default to 0 in tor; no need to set them.
*/
class TorConfig(private val config: TorConfiguration) {
/**
* Generate torrc file content
* @param socksPort SOCKS proxy port
* @param dataDir Tor data directory
* @param geoipFile GeoIP file (optional, for country selection)
* @param geoip6File GeoIP6 file (optional, for IPv6 country selection)
* @param transportPorts Map of transport name to port (from PluggableTransportManager)
* @return torrc content as string
*
* Note: ControlPort is NOT set in torrc. The tor-android library automatically
* uses ControlSocket (Unix domain socket) which is more secure than TCP ControlPort.
* See SECURITY_CONTROL_PORT.md for details.
*/
fun generateTorrc(
socksPort: Int,
dataDir: File,
geoipFile: File?,
geoip6File: File?,
transportPorts: Map<String, Int>
): String = buildString {
// Core Tor settings
append("# Generated torrc for flutter_tor\n")
append("SocksPort 127.0.0.1:$socksPort\n")
// ControlPort is NOT set - tor-android uses ControlSocket (Unix domain socket)
// This is more secure as it uses file permissions instead of TCP authentication
append("DataDirectory ${dataDir.absolutePath}\n")
// Start with networking disabled. Re-enabled via control port
// (setConf DisableNetwork 0) AFTER our event listener is wired up so we
// don't miss bootstrap events.
append("DisableNetwork 1\n")
append("\n")
// GeoIP files for country-based node selection
if (geoipFile != null && geoipFile.exists()) {
append("GeoIPFile ${geoipFile.absolutePath}\n")
}
@@ -46,34 +43,27 @@ class TorConfig(private val config: TorConfiguration) {
}
append("\n")
// Entry node countries
config.entryNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("EntryNodes $formatted\n")
append("EntryNodes ${formatCountries(countries)}\n")
}
}
// Exit node countries
config.exitNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("ExitNodes $formatted\n")
append("ExitNodes ${formatCountries(countries)}\n")
}
}
// Strict nodes (only use specified countries)
if (config.strictNodes == true) {
append("StrictNodes 1\n")
}
append("\n")
// Pluggable transport configuration
val transport = TransportType.fromPigeon(config.transport)
when (transport) {
TransportType.OBFS4 -> {
transportPorts[IPtProxy.Obfs4]?.let { port ->
// Validate port is valid (like Orbot does)
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Obfs4} socks5 127.0.0.1:$port\n")
}
@@ -101,17 +91,12 @@ class TorConfig(private val config: TorConfiguration) {
}
}
TransportType.CUSTOM -> {
// Custom bridges - transport plugin defined in bridge line
// We'll try to detect and configure based on bridge lines
configureCustomTransports(transportPorts)
}
TransportType.NONE -> {
// Direct connection, no pluggable transports
}
TransportType.NONE -> {}
}
append("\n")
// Bridge configuration
if (transport != TransportType.NONE) {
val normalizedBridges = BridgeParser.normalize(config.bridgeLines)
if (normalizedBridges.isNotEmpty()) {
@@ -123,9 +108,6 @@ class TorConfig(private val config: TorConfiguration) {
}
}
// Additional Tor settings (matching Orbot's configuration)
append("# Additional settings\n")
append("RunAsDaemon 1\n")
append("AvoidDiskWrites 1\n")
append("SafeSocks 0\n")
append("TestSocks 0\n")
@@ -133,14 +115,6 @@ class TorConfig(private val config: TorConfiguration) {
append("AutomapHostsOnResolve 1\n")
append("DormantClientTimeout 10 minutes\n")
append("DormantCanceledByStartup 1\n")
// CRITICAL: Set DisableNetwork 1 to prevent bootstrap before event listener is ready
// This will be changed to 0 via control port AFTER we set up event listeners
// This matches Orbot's approach and ensures we receive all bootstrap events
append("DisableNetwork 1\n")
append("Log notice stdout\n") // Log to stdout for capture
append("\n")
}
/**
@@ -155,15 +129,11 @@ class TorConfig(private val config: TorConfiguration) {
.split(",")
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.filter { it.length == 2 } // ISO 3166-1 alpha-2 codes
.filter { it.length == 2 }
return codes.joinToString(",") { "{$it}" }
}
/**
* Configure custom transports based on bridge lines
* Detects transport type from bridge lines and configures accordingly
*/
private fun StringBuilder.configureCustomTransports(transportPorts: Map<String, Int>) {
val bridgeTransports = config.bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
@@ -195,11 +165,6 @@ class TorConfig(private val config: TorConfiguration) {
}
}
/**
* Write torrc to file
* @param torrcFile File to write to
* @param content torrc content
*/
fun writeTorrc(torrcFile: File, content: String) {
torrcFile.parentFile?.mkdirs()
torrcFile.writeText(content)
@@ -1,11 +1,14 @@
package eu.weblibre.flutter_tor
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import kotlinx.coroutines.*
@@ -27,12 +30,18 @@ class TorManager(
) {
companion object {
const val TAG = "TorManager"
private const val STATUS_OFF_TIMEOUT_MS = 10_000L
}
private val dataDir = File(context.filesDir, "tor_data")
// GeoIP files live alongside other install assets. tor-android owns its own
// DataDirectory/CacheDirectory under getDir("TorService"), so we don't define one.
private val installDir = File(context.filesDir, "tor_install")
private var torServiceConnection: ServiceConnection? = null
@Volatile
private var controlConnection: TorControlConnection? = null
@Volatile
private var torService: TorService? = null
val pluggableTransportManager = PluggableTransportManager.getInstance(context)
@@ -45,20 +54,67 @@ class TorManager(
var socksPort: Int = -1
private set
// Note: No controlPort - tor-android uses ControlSocket (Unix domain socket) instead
// This is more secure than TCP ControlPort as it uses file permissions for access control
@Volatile
private var isRunning = false
@Volatile
private var bootstrapProgress = 0
// Lock for synchronizing status updates
private val statusLock = Any()
// Latch awaited by stop() — completed when upstream broadcasts STATUS_OFF.
@Volatile
private var stopSignal: CompletableDeferred<Unit>? = null
// Background bootstrap-progress poller — fallback for when NOTICE/STATUS_CLIENT
// events are silently dropped by jtorctl/tor (observed intermittently in the wild).
private var bootstrapPollJob: Job? = null
// Mirror of upstream TorService's status — its own static `currentStatus` is package-private.
@Volatile
private var lastUpstreamStatus: String = TorService.STATUS_OFF
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val status = intent?.getStringExtra(TorService.EXTRA_STATUS) ?: return
Log.d(TAG, "TorService broadcast: $status")
lastUpstreamStatus = status
if (status == TorService.STATUS_OFF) {
stopSignal?.complete(Unit)
}
}
}
private val errorReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val msg = intent?.getStringExtra(Intent.EXTRA_TEXT) ?: "unknown"
Log.e(TAG, "TorService error broadcast: $msg")
logHandler.error("Tor service error: $msg")
}
}
private var receiversRegistered = false
private fun registerReceivers() {
if (receiversRegistered) return
val lbm = LocalBroadcastManager.getInstance(context)
lbm.registerReceiver(statusReceiver, IntentFilter(TorService.ACTION_STATUS))
lbm.registerReceiver(errorReceiver, IntentFilter(TorService.ACTION_ERROR))
receiversRegistered = true
}
private fun unregisterReceivers() {
if (!receiversRegistered) return
val lbm = LocalBroadcastManager.getInstance(context)
try { lbm.unregisterReceiver(statusReceiver) } catch (_: Exception) {}
try { lbm.unregisterReceiver(errorReceiver) } catch (_: Exception) {}
receiversRegistered = false
}
/**
* Start Tor with the given configuration
* @param config Tor configuration from Flutter
* @return SOCKS port
* @return SOCKS port (discovered from tor via `getInfo net/listeners/socks`)
*/
suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) {
if (isRunning) {
@@ -69,66 +125,41 @@ class TorManager(
try {
logHandler.notice("Starting Tor...")
// Create directories
dataDir.mkdirs()
installDir.mkdirs()
lastUpstreamStatus = TorService.STATUS_STARTING
registerReceivers()
// Allocate random SOCKS port
// Note: We don't allocate a control port - tor-android uses ControlSocket instead
socksPort = PortManager.findAvailablePort()
Log.d(TAG, "Allocated SOCKS port: $socksPort")
Log.d(TAG, "Control connection will use ControlSocket (Unix domain socket)")
logHandler.notice("SOCKS port: $socksPort")
// Start pluggable transports if needed
val transport = TransportType.fromPigeon(config.transport)
val transportPorts = if (transport != TransportType.NONE && transport != TransportType.CUSTOM) {
logHandler.notice("Starting pluggable transport: $transport")
pluggableTransportManager.startTransport(transport)
} else if (transport == TransportType.CUSTOM) {
// For custom, we need to detect and start appropriate transports
startCustomTransports(config.bridgeLines)
} else {
emptyMap()
}
// Generate torrc
val geoipFile = geoIpManager.getGeoIpFile(installDir)
val geoip6File = geoIpManager.getGeoIp6File(installDir)
val torConfig = TorConfig(config)
val torrcContent = torConfig.generateTorrc(
socksPort = socksPort,
// controlPort removed - tor-android uses ControlSocket (Unix domain socket) for security
dataDir = dataDir,
geoipFile = geoipFile,
geoip6File = geoip6File,
transportPorts = transportPorts
)
// Write torrc to the correct location (like Orbot does)
// CRITICAL: Must use TorService.getTorrc() so TorService can find it!
// Tor reads torrc from the location upstream TorService passes via -f.
val torrcFile = TorService.getTorrc(context)
torConfig.writeTorrc(torrcFile, torrcContent)
Log.d(TAG, "Generated torrc at ${torrcFile.absolutePath}:\n$torrcContent")
// Write defaults torrc (required by tor-android)
// Set DisableNetwork 1 initially like Orbot does, will be enabled via control port
// Also disable DNSPort and TransPort (matching Orbot)
val defaultsTorrcFile = TorService.getDefaultsTorrc(context)
defaultsTorrcFile.writeText("""
DNSPort 0
TransPort 0
DisableNetwork 1
""".trimIndent())
// Note: do NOT write defaults-torrc here. Upstream's setDefaultProxyPorts()
// truncates and rewrites it on every start with `SOCKSPort/HTTPTunnelPort auto`.
// We neutralise those listeners with `SOCKSPort 0` / `HTTPTunnelPort 0` in
// the regular torrc instead (see TorConfig.kt).
// Start TorService
// Note: torrcFile is now written to the correct location via TorService.getTorrc()
// so TorService will automatically find and use it
// Note: isRunning will be set to true in setupControlConnection() before network is enabled
// This ensures status is consistent when bootstrap events start arriving
startTorService()
socksPort
@@ -136,13 +167,11 @@ class TorManager(
Log.e(TAG, "Failed to start Tor", e)
logHandler.error("Failed to start Tor: ${e.message}")
cleanup()
unregisterReceivers()
throw e
}
}
/**
* Start custom transports based on bridge lines
*/
private fun startCustomTransports(bridgeLines: List<String>): Map<String, Int> {
val transports = bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
@@ -168,8 +197,7 @@ class TorManager(
}
/**
* Start the native TorService and bind to it
* TorService will automatically use the torrc written to TorService.getTorrc(context)
* Start upstream tor-android TorService and bind to it.
*/
private suspend fun startTorService() = suspendCancellableCoroutine<Unit> { continuation ->
val connection = object : ServiceConnection {
@@ -178,28 +206,48 @@ class TorManager(
val binder = service as? TorService.LocalBinder
torService = binder?.service
// Wait for control connection to be available
// Wait for control connection to be available AND for TorService's
// own controlPortThread to have finished its setup (auth + addRawEventListener
// + setEvents). TorService.torControlConnection becomes non-null immediately
// after `new TorControlConnection(...)`, but TorService then calls
// setEvents([EVENT_STATUS_CLIENT]) — if we race and call our setEvents first,
// TorService overwrites it and we stop receiving NOTICE/BW/CIRC events.
// TorService.socksPort is set AFTER its setEvents call, so polling for it
// gives us a reliable "TorService is done initializing the control port" signal.
scope.launch {
var conn: TorControlConnection? = null
var attempts = 0
while (conn == null && attempts < 60) { // 30 seconds timeout
while ((conn == null || TorService.socksPort == -1) && attempts < 60) {
delay(500)
conn = torService?.torControlConnection
attempts++
// Bail early if upstream gave up (typically due to a torrc
// parse error caught by `tor --verify-config`).
if (lastUpstreamStatus == TorService.STATUS_OFF ||
lastUpstreamStatus == TorService.STATUS_STOPPING) {
break
}
}
if (conn != null) {
// Wait an additional second before setting up event listener
// This matches Orbot's behavior and ensures Tor is fully initialized
delay(1000)
if (conn != null && TorService.socksPort != -1) {
controlConnection = conn
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit)
try {
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit)
}
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
} else {
val error = Exception("Failed to get control connection after 30 seconds")
val error = Exception(
"Failed to get fully-initialized control connection " +
"(conn=${conn != null}, torServiceSocksPort=${TorService.socksPort}, " +
"upstreamStatus=$lastUpstreamStatus). " +
"If upstreamStatus is STOPPING/OFF, tor likely rejected the torrc — check logcat for TorService."
)
if (continuation.isActive) {
continuation.resumeWithException(error)
}
@@ -220,7 +268,6 @@ class TorManager(
val intent = Intent(context, org.torproject.jni.TorService::class.java)
try {
// Start the service first (like Orbot does) before binding
context.startService(intent)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
@@ -231,26 +278,20 @@ class TorManager(
}
/**
* Setup control connection and event listeners
* This follows Orbot's approach: query ports first, then set up events, then enable network
* Setup control connection and event listeners.
* Order: discover real SOCKS port → register listener → subscribe events enable network.
*/
private fun setupControlConnection(conn: TorControlConnection) {
try {
// Query control connection to verify SOCKS port (like Orbot's initControlConnection)
// This also properly initializes the control connection for event delivery
try {
conn.getInfo("net/listeners/socks")
} catch (e: Exception) {
Log.w(TAG, "Could not query SOCKS port from control connection", e)
}
logHandler.notice("Connected to Tor control port")
// Create and store event listener instance (prevents garbage collection)
// Wire up event listener BEFORE enabling network so we don't miss bootstrap
// notices. Created as a field to keep a strong reference (avoids GC).
torEventListener = TorEventListener()
conn.addRawEventListener(torEventListener)
// Subscribe to events (matching Orbot's event subscriptions)
// EVENT_STATUS_CLIENT must be included — upstream TorService relies on it
// internally to detect circuit-established and broadcast STATUS_ON.
conn.setEvents(listOf(
TorControlCommands.EVENT_OR_CONN_STATUS,
TorControlCommands.EVENT_CIRCUIT_STATUS,
@@ -259,26 +300,53 @@ class TorManager(
TorControlCommands.EVENT_ERR_MSG,
TorControlCommands.EVENT_BANDWIDTH_USED,
TorControlCommands.EVENT_NEW_DESC,
TorControlCommands.EVENT_ADDRMAP
TorControlCommands.EVENT_ADDRMAP,
TorControlCommands.EVENT_STATUS_CLIENT
))
Log.d(TAG, "Control connection setup complete, enabling network")
// Replace upstream's default SOCKS/HTTP listeners with our own. Upstream's
// defaults-torrc binds `SOCKSPort 9050|auto` and `HTTPTunnelPort 8118|auto`;
// a well-known port lets other apps on the device probe / use our SOCKS proxy,
// so we want a random one. We can't disable these in torrc (tor rejects mixing
// `SocksPort 0` with other SocksPort lines), so SETCONF here:
// - SETCONF replaces the listener list (closes prev, opens new).
// - `auto` is a bare-port token; SocksPort defaults to binding 127.0.0.1.
// - SETCONF is recorded immediately, but listeners only physically bind
// once DisableNetwork=0 (see GoLog: "DisableNetwork is set. ... Shutting
// down all existing connections.") — so we read the port back AFTER
// enabling network below.
try {
conn.setConf("HTTPTunnelPort", "0")
} catch (e: Exception) {
Log.w(TAG, "Could not disable HTTPTunnelPort", e)
}
conn.setConf("SocksPort", "auto")
// Set isRunning=true BEFORE enabling network so status is consistent
// when bootstrap events start arriving
// when bootstrap events start arriving.
synchronized(statusLock) {
isRunning = true
}
logHandler.notice("Tor started successfully")
sendStatusUpdate()
// Enable network now that configuration is complete (like Orbot does)
// This will trigger bootstrap events to start
Log.d(TAG, "Control connection setup complete, enabling network")
conn.setConf("DisableNetwork", "0")
// Now that listeners are physically bound, discover the random port tor
// chose. Poll briefly — binding takes a few hundred ms after enabling net.
socksPort = awaitSocksListener(conn)
if (socksPort <= 0) {
throw IllegalStateException("Failed to discover SOCKS listener after enabling network")
}
Log.d(TAG, "Bound random SOCKS port: $socksPort")
logHandler.notice("SOCKS port: $socksPort")
sendStatusUpdate()
startBootstrapPoller(conn)
} catch (e: Exception) {
Log.e(TAG, "Failed to setup control connection", e)
logHandler.error("Control connection error: ${e.message}")
// Reset isRunning on failure
synchronized(statusLock) {
isRunning = false
}
@@ -287,6 +355,74 @@ class TorManager(
}
}
/**
* Poll `getInfo("status/bootstrap-phase")` until progress reaches 100, the
* connection is gone, or tor stops. This is a fallback for the intermittent
* issue where NOTICE / STATUS_CLIENT events are silently not delivered to
* our raw event listener even though tor accepted SETEVENTS for them.
*
* Reply format: `NOTICE BOOTSTRAP PROGRESS=85 TAG=loading_descriptors SUMMARY="..."`
*/
private fun startBootstrapPoller(conn: TorControlConnection) {
bootstrapPollJob?.cancel()
bootstrapPollJob = scope.launch {
try {
while (isActive && isRunning && controlConnection === conn) {
val info = try {
conn.getInfo("status/bootstrap-phase")
} catch (e: Exception) {
Log.w(TAG, "bootstrap-phase poll failed", e)
null
}
if (info != null) {
val progress = Regex("""PROGRESS=(\d+)""").find(info)
?.groupValues?.get(1)?.toIntOrNull() ?: -1
if (progress >= 0) {
val changed = synchronized(statusLock) {
if (progress > bootstrapProgress) {
bootstrapProgress = progress
true
} else false
}
if (changed) {
sendStatusUpdate()
if (progress == 100) {
logHandler.notice("Tor is ready!")
break
}
} else if (progress == 100) {
break
}
}
}
delay(750)
}
} finally {
Log.d(TAG, "Bootstrap poller exiting")
}
}
}
private fun awaitSocksListener(conn: TorControlConnection): Int {
val deadline = System.currentTimeMillis() + 5_000L
while (System.currentTimeMillis() < deadline) {
val port = parseSocksPort(conn.getInfo("net/listeners/socks"))
if (port > 0) return port
Thread.sleep(100)
}
return -1
}
/**
* Parse `net/listeners/socks` reply, e.g. `"127.0.0.1:43251"` or
* `"127.0.0.1:43251" "127.0.0.1:9050"`. Returns the first numeric port, or -1.
*/
private fun parseSocksPort(reply: String?): Int {
if (reply.isNullOrBlank()) return -1
val match = Regex("""127\.0\.0\.1:(\d+)""").find(reply) ?: return -1
return match.groupValues[1].toIntOrNull() ?: -1
}
/**
* Stop Tor and cleanup
*/
@@ -294,25 +430,32 @@ class TorManager(
Log.d(TAG, "Stopping Tor")
logHandler.notice("Stopping Tor...")
val signal = CompletableDeferred<Unit>().also { stopSignal = it }
try {
// DON'T call shutdownTor() here - let the service's onDestroy() handle it
// Otherwise we get a broken pipe error when service tries to shutdown again
// DON'T call shutdownTor() here - let upstream's onDestroy() handle it,
// otherwise we get a broken pipe error.
cleanup()
// Give the native service time to fully stop before potential restart
// This is CRITICAL to prevent binding to a stale service instance
delay(2000)
// Wait for upstream to broadcast STATUS_OFF (which it does after releasing
// its static runLock). Without this, the next start() can hang on runLock.lock().
val arrived = withTimeoutOrNull(STATUS_OFF_TIMEOUT_MS) { signal.await(); true } ?: false
if (!arrived) {
Log.w(TAG, "Did not receive STATUS_OFF within ${STATUS_OFF_TIMEOUT_MS}ms — proceeding anyway")
}
logHandler.notice("Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Tor", e)
cleanup()
delay(2000)
} finally {
stopSignal = null
unregisterReceivers()
}
}
/**
* Cleanup resources
* Cleanup resources. Safe to call from any thread.
*/
private fun cleanup() {
synchronized(statusLock) {
@@ -321,47 +464,34 @@ class TorManager(
socksPort = -1
}
try {
// Unsubscribe from all events before removing listener
if (controlConnection != null) {
try {
controlConnection?.setEvents(emptyList())
} catch (e: Exception) {
// Connection may already be closed
}
}
bootstrapPollJob?.cancel()
bootstrapPollJob = null
// Remove event listener
if (torEventListener != null && controlConnection != null) {
try {
controlConnection?.removeRawEventListener(torEventListener)
} catch (e: Exception) {
// Connection may already be closed
}
val conn = controlConnection
val listener = torEventListener
if (conn != null) {
try { conn.setEvents(emptyList()) } catch (_: Exception) {}
if (listener != null) {
try { conn.removeRawEventListener(listener) } catch (_: Exception) {}
}
torEventListener = null
controlConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error closing control connection", e)
}
torEventListener = null
controlConnection = null
// Service cleanup
runBlocking(Dispatchers.Main) {
try {
torServiceConnection?.let {
context.unbindService(it)
}
torServiceConnection = null
// unbindService / stopService are safe from any thread; no need to hop to Main
// (the previous runBlocking(Dispatchers.Main) wrapper risked deadlocking with
// the IO-dispatcher caller chain in FlutterTorPlugin.stopTor).
try {
torServiceConnection?.let { context.unbindService(it) }
} catch (e: Exception) {
Log.w(TAG, "Error unbinding TorService", e)
}
torServiceConnection = null
// Small delay to ensure unbind completes before stopping service
delay(100)
// Stop the native TorService to ensure clean restart
val intent = Intent(context, org.torproject.jni.TorService::class.java)
context.stopService(intent)
} catch (e: Exception) {
Log.w(TAG, "Error unbinding TorService", e)
}
try {
context.stopService(Intent(context, org.torproject.jni.TorService::class.java))
} catch (e: Exception) {
Log.w(TAG, "Error stopping TorService", e)
}
torService = null
@@ -384,9 +514,6 @@ class TorManager(
}
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
synchronized(statusLock) {
val status = TorStatus(
@@ -403,9 +530,6 @@ class TorManager(
}
}
/**
* Send status update to Flutter
*/
private fun sendStatusUpdate() {
logHandler.sendStatusChange(getStatus())
}
@@ -415,41 +539,44 @@ class TorManager(
*/
private inner class TorEventListener : RawEventListener {
override fun onEvent(eventType: String, eventData: String) {
// Handle bootstrap progress (comes in NOTICE events)
if (eventData.contains("Bootstrapped")) {
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
synchronized(statusLock) {
bootstrapProgress = progress
}
sendStatusUpdate()
Log.d(TAG, "ReceivedData: $eventType: $eventData")
// Bootstrap progress can arrive in two formats:
// - EVENT_NOTICE_MSG: "Bootstrapped 85% (loading_descriptors): ..."
// - EVENT_STATUS_CLIENT: "NOTICE BOOTSTRAP PROGRESS=85 TAG=... SUMMARY=..."
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
val changed = synchronized(statusLock) {
if (progress > bootstrapProgress) {
bootstrapProgress = progress
true
} else false
}
if (changed) {
sendStatusUpdate()
if (progress == 100) {
logHandler.notice("Tor is ready!")
}
}
}
// Forward to log handler
logHandler.handleTorEvent(eventType, eventData)
}
private fun extractBootstrapProgress(eventData: String): Int {
// Extract from format like "Bootstrapped 85% (loading_descriptors): ..."
val regex = "Bootstrapped\\s+(\\d+)%".toRegex()
return regex.find(eventData)?.groupValues?.get(1)?.toIntOrNull() ?: -1
Regex("""Bootstrapped\s+(\d+)%""").find(eventData)?.let {
return it.groupValues[1].toIntOrNull() ?: -1
}
Regex("""BOOTSTRAP\s+PROGRESS=(\d+)""").find(eventData)?.let {
return it.groupValues[1].toIntOrNull() ?: -1
}
return -1
}
}
/**
* Cleanup when manager is destroyed
*/
fun destroy() {
cleanup()
unregisterReceivers()
scope.cancel()
runBlocking {
if (isRunning) {
stop()
}
}
}
}
@@ -55,6 +55,11 @@ class TorService : Service() {
Log.d(TAG, "onStartCommand: ${intent?.action}")
when (intent?.action) {
ACTION_START -> {
// startForegroundService() must promote the service promptly, before
// the later binder call reaches startTor().
startForeground(NOTIFICATION_ID, createNotification("Tor is connecting..."))
}
ACTION_STOP -> {
scope.launch {
stopTor()
@@ -66,7 +71,8 @@ class TorService : Service() {
}
}
return START_STICKY
// Tor needs explicit user start; don't auto-restart with a null intent.
return START_NOT_STICKY
}
/**
@@ -92,10 +98,7 @@ class TorService : Service() {
suspend fun startTor(config: TorConfiguration): Int {
Log.d(TAG, "Starting Tor...")
// Start foreground service with notification
// This keeps the service alive even when the app is backgrounded
startForeground(NOTIFICATION_ID, createNotification("Tor is connecting..."))
Log.d(TAG, "Started foreground service")
val manager = torManager ?: throw IllegalStateException("Service not initialized")
@@ -105,9 +108,8 @@ class TorService : Service() {
return socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
updateNotification("Failed to start Tor")
// Stop foreground on failure
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
throw e
}
}
@@ -120,9 +122,8 @@ class TorService : Service() {
torManager?.stop()
// Stop foreground service and remove notification
stopForeground(STOP_FOREGROUND_REMOVE)
Log.d(TAG, "Stopped foreground service")
stopSelf()
}
/**
@@ -199,17 +200,13 @@ class TorService : Service() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
// Stop pluggable transports synchronously to release Go references.
// Previously this was scope.launch { torManager?.destroy() } followed by
// scope.cancel(), which meant the cleanup coroutine was immediately cancelled
// and never ran — causing "trackGoRef called with Java refnum" crashes when
// TorService was recreated and tried to create a new IPtProxy.Controller.
//
// Note: We only stop transports here. The PluggableTransportManager singleton
// and its Controller persist across service restarts by design.
// Full TorManager.destroy() is not called because it would deadlock
// (cleanup() uses runBlocking(Dispatchers.Main) while onDestroy runs on Main).
torManager?.pluggableTransportManager?.stopAll()
// Tear down TorManager synchronously so receivers, transports, and the
// upstream tor-android service do not survive wrapper service teardown.
try {
torManager?.destroy()
} catch (e: Exception) {
Log.w(TAG, "Error destroying TorManager", e)
}
torManager = null
logHandler = null
@@ -32,10 +32,11 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import eu.weblibre.simple_intent_receiver.pigeons.IntentHost
import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener {
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, IntentHost {
companion object {
// Stable names that must match the notification replay path and shared-prefs schema.
private const val PREFS_NAME = "weblibre_intent_gatekeeper"
@@ -53,10 +54,28 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
private var activity: Activity? = null
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
/**
* Caches the launch intent so Dart can retrieve it after setUp().
* On cold start, onAttachedToActivity fires before Dart registers its
* Pigeon handler, so the initial sendIntent message is lost. This field
* lets Dart call getInitialIntent() to recover it.
*
* On Android configuration change (rotation, theme switch) the activity
* is recreated and onAttachedToActivity fires again with the same
* launching intent. The `lastHandledIntent` guard below prevents
* re-caching that identical intent. If a NEW deep link arrives via the
* launcher between two Dart-side reads of getInitialIntent(),
* pendingInitialIntent is overwritten — only the latest intent is
* delivered. This is intentional: dropping the stale one keeps the
* "initial" intent meaning "what should the app open into right now".
*/
private var pendingInitialIntent: PigeonIntent? = null
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger)
binaryMessenger = flutterPluginBinding.binaryMessenger
IntentHost.setUp(flutterPluginBinding.binaryMessenger, this)
IntentGatekeeperHostApi.setUp(
flutterPluginBinding.binaryMessenger,
IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext),
@@ -65,10 +84,19 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
intentReceiver = null
binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) }
binaryMessenger?.let {
IntentHost.setUp(it, null)
IntentGatekeeperHostApi.setUp(it, null)
}
binaryMessenger = null
}
override fun getInitialIntent(): PigeonIntent? {
val intent = pendingInitialIntent
pendingInitialIntent = null
return intent
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addOnNewIntentListener(this)
@@ -76,7 +104,10 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
binding.activity.intent?.let { intent ->
val uri = intent.toUri(0)
if (lastHandledIntent != uri) {
handleIntent(intent)
// Cache the launch intent for Dart to retrieve via getInitialIntent().
// Don't send via Pigeon here — the Dart handler isn't registered yet
// during cold start so the message would be lost.
pendingInitialIntent = prepareIntentForDelivery(intent)
lastHandledIntent = uri
}
}
@@ -100,8 +131,7 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
return handleIntent(intent)
}
private fun handleIntent(intent: Intent): Boolean {
// Grant URI permissions for content URIs
private fun grantUriPermissions(intent: Intent) {
intent.data?.let { uri ->
if (uri.scheme == "content") {
try {
@@ -116,7 +146,6 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
}
}
// Handle SEND action with STREAM extra
intent.getStringExtra(Intent.EXTRA_STREAM)?.let { streamUri ->
try {
val uri = Uri.parse(streamUri)
@@ -131,15 +160,23 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
Log.w("SimpleIntentReceiver", "Could not grant URI permission for stream: $streamUri", e)
}
}
}
private fun handleIntent(intent: Intent): Boolean {
val pigeonIntent = prepareIntentForDelivery(intent)
intentReceiver?.sendIntent(pigeonIntent)
return true
}
private fun prepareIntentForDelivery(intent: Intent): PigeonIntent {
grantUriPermissions(intent)
if (intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val notificationApproval = consumeNotificationApproval(intent)
val pigeonIntent = convertToPigeonIntent(intent, notificationApproval)
intentReceiver?.sendIntent(pigeonIntent)
return true
return convertToPigeonIntent(intent, notificationApproval)
}
private fun resolveCallerPackage(intent: Intent, notificationApproval: NotificationApproval?): String? {
@@ -270,6 +270,43 @@ private open class IntentPigeonCodec : StandardMessageCodec() {
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface IntentHost {
/**
* Returns the launch intent that started the activity, if any.
* This allows Dart to retrieve an intent that arrived before
* IntentEvents.setUp() was called (cold-start deep links).
* Returns null if no launch intent is pending.
*/
fun getInitialIntent(): Intent?
companion object {
/** The codec used by IntentHost. */
val codec: MessageCodec<Any?> by lazy {
IntentPigeonCodec()
}
/** Sets up an instance of `IntentHost` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: IntentHost?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getInitialIntent())
} catch (exception: Throwable) {
IntentPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
class IntentEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
@@ -26,11 +26,61 @@ class IntentReceiver extends IntentEvents {
final _controller = StreamController<Intent>.broadcast();
int? _lastAdded;
Stream<Intent> get events => _controller.stream;
/// Intent events, including the cold-start launch intent for each listener.
///
/// New-intent callbacks still arrive through the broadcast controller below.
/// The initial intent is replayed per listener so existing callers that only
/// listen to [events] continue to receive terminated-app launches.
Stream<Intent> get events {
return Stream.multi((controller) {
final subscription = _controller.stream.listen(
controller.add,
onError: controller.addError,
onDone: controller.close,
);
unawaited(
initialIntent.then(
(intent) {
if (intent != null && !controller.isClosed) {
controller.add(intent);
}
},
onError: (Object error, StackTrace stackTrace) {
if (!controller.isClosed) {
controller.addError(error, stackTrace);
}
},
),
);
controller.onCancel = subscription.cancel;
}, isBroadcast: true);
}
/// The launch intent recovered from the host, if any. Resolves to
/// `Future<null>` for instances not constructed via [IntentReceiver.setUp]
/// (e.g. test fakes / subclasses), so callers can always `await` without
/// guarding for `LateInitializationError`.
///
/// On cold start the Android plugin sees the launch intent from
/// onAttachedToActivity before Dart has registered the Pigeon handler, so it
/// caches the value for Dart to recover. The [events] stream already replays
/// this value for compatibility; use this future directly only when the
/// launch intent needs one-shot handling outside the event stream.
///
/// Note on rotation: the Android plugin's `pendingInitialIntent` cache
/// is intentionally overwritten on configuration change. If the user
/// triggers a new deep link via the activity launcher before Dart
/// drains the previous initial intent (rare — the future is read on
/// IntentReceiver construction, which happens during app bootstrap),
/// only the newest intent is delivered.
Future<Intent?> initialIntent = Future.value(null);
@override
void onIntentReceived(int sequence, Intent intent) {
if (_lastAdded == null || sequence > _lastAdded!) {
_lastAdded = sequence;
_controller.add(intent);
}
}
@@ -44,6 +94,12 @@ class IntentReceiver extends IntentEvents {
binaryMessenger: binaryMessenger,
messageChannelSuffix: messageChannelSuffix,
);
final host = IntentHost(
binaryMessenger: binaryMessenger,
messageChannelSuffix: messageChannelSuffix,
);
initialIntent = host.getInitialIntent();
}
Future<void> dispose() async {
@@ -199,6 +199,43 @@ class _PigeonCodec extends StandardMessageCodec {
}
}
class IntentHost {
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
/// Returns the launch intent that started the activity, if any.
/// This allows Dart to retrieve an intent that arrived before
/// IntentEvents.setUp() was called (cold-start deep links).
/// Returns null if no launch intent is pending.
Future<Intent?> getInitialIntent() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as Intent?;
}
}
abstract class IntentEvents {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -49,6 +49,15 @@ class Intent {
dartPackageName: 'simple_intent_receiver',
),
)
@HostApi()
abstract class IntentHost {
/// Returns the launch intent that started the activity, if any.
/// This allows Dart to retrieve an intent that arrived before
/// IntentEvents.setUp() was called (cold-start deep links).
/// Returns null if no launch intent is pending.
Intent? getInitialIntent();
}
@FlutterApi()
abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent);