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