This commit is contained in:
Fabian Freund
2026-01-08 08:58:42 +01:00
parent 63712edbc7
commit 6f4654e0be
75 changed files with 654677 additions and 369 deletions
+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
+80
View File
@@ -0,0 +1,80 @@
group = "eu.weblibre.flutter_tor"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.2.20"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "eu.weblibre.flutter_tor"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 24
}
dependencies {
// Tor core libraries
implementation("info.guardianproject:tor-android:0.4.8.21.1")
implementation("info.guardianproject:jtorctl:0.4.5.7")
// Pluggable transports
implementation("com.netzarchitekten:IPtProxy:4.3.0")
// Coroutines for async operations
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// Testing
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
@@ -0,0 +1 @@
rootProject.name = 'flutter_tor'
@@ -0,0 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.weblibre.flutter_tor">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Native TorService from tor-android library -->
<service
android:name="org.torproject.jni.TorService"
android:enabled="true"
android:exported="false" />
<!-- TorService - Foreground service for running Tor -->
<service
android:name=".TorService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Tor proxy service for anonymous networking" />
</service>
</application>
</manifest>
@@ -0,0 +1 @@
# GeoIP files will be extracted from tor-android library at runtime
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
package eu.weblibre.flutter_tor
/**
* Parses and validates bridge lines
*/
object BridgeParser {
/**
* Parse a bridge line and extract transport type
* Examples:
* "obfs4 192.0.2.4:443 cert=..."
* "snowflake 192.0.2.3:1 fingerprint=..."
* "webtunnel [2001:db8::1]:443 url=..."
*
* @param bridgeLine Bridge line to parse
* @return Transport type or null if invalid
*/
fun extractTransportType(bridgeLine: String): String? {
val trimmed = bridgeLine.trim()
if (trimmed.isEmpty()) return null
// Bridge line format: <transport> <address:port> [<key=value>...]
val parts = trimmed.split("\\s+".toRegex(), limit = 2)
if (parts.isEmpty()) return null
return parts[0].lowercase()
}
/**
* Validate if a bridge line is properly formatted
* @param bridgeLine Bridge line to validate
* @return true if valid
*/
fun isValid(bridgeLine: String): Boolean {
val trimmed = bridgeLine.trim()
if (trimmed.isEmpty()) return false
// Must have at least transport and address:port
val parts = trimmed.split("\\s+".toRegex())
if (parts.size < 2) return false
// Second part should contain a colon (address:port)
return parts[1].contains(":")
}
/**
* Normalize bridge lines (trim, remove empty lines)
* @param bridgeLines List of bridge lines
* @return Normalized list
*/
fun normalize(bridgeLines: List<String>): List<String> {
return bridgeLines
.map { it.trim() }
.filter { it.isNotEmpty() }
.filter { !it.startsWith("#") } // Remove comments
}
}
@@ -0,0 +1,200 @@
package eu.weblibre.flutter_tor
import IPtProxy.Controller
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorApi
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.embedding.engine.plugins.FlutterPlugin
import kotlinx.coroutines.*
import java.io.File
/**
* FlutterTorPlugin - Main plugin class
* Implements Pigeon-generated TorApi and manages TorService
*/
class FlutterTorPlugin : FlutterPlugin, TorApi {
companion object {
private const val TAG = "FlutterTorPlugin"
private const val SERVICE_CONNECTION_TIMEOUT_MS = 10000L
}
private var context: Context? = null
private var torService: TorService? = null
private var serviceConnection: ServiceConnection? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Service connection state
private var serviceConnected = CompletableDeferred<Unit>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onAttachedToEngine")
context = flutterPluginBinding.applicationContext
// Setup Pigeon API
TorApi.setUp(flutterPluginBinding.binaryMessenger, this)
// Bind to TorService
bindTorService(flutterPluginBinding)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onDetachedFromEngine")
// Cleanup Pigeon API
TorApi.setUp(binding.binaryMessenger, null)
// Unbind service
unbindTorService()
// Cancel coroutines
scope.cancel()
context = null
}
/**
* Bind to TorService
*/
private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
val ctx = context ?: 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)
// Signal that service is connected
serviceConnected.complete(Unit)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
torService = null
// Reset connection deferred for potential reconnection
serviceConnected = CompletableDeferred()
}
}
serviceConnection = connection
val intent = Intent(ctx, TorService::class.java)
intent.action = TorService.ACTION_START
ctx.startService(intent)
ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
/**
* Unbind from TorService
*/
private fun unbindTorService() {
serviceConnection?.let { conn ->
try {
context?.unbindService(conn)
} catch (e: Exception) {
Log.w(TAG, "Error unbinding service", e)
}
}
serviceConnection = null
torService = null
}
/**
* Wait for service to be connected
*/
private suspend fun waitForService(): TorService {
return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) {
serviceConnected.await()
torService
} ?: throw Exception("TorService connection timeout")
}
// ========== Pigeon TorApi Implementation ==========
// Note: These methods are now async with callbacks to avoid blocking the main thread
override fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit) {
Log.d(TAG, "startTor called with transport: ${config.transport}")
scope.launch {
try {
// Wait for service to be connected
val service = waitForService()
val socksPort = withContext(Dispatchers.IO) {
service.startTor(config)
}
val result = socksPort.toLong()
Log.d(TAG, "Returning SOCKS port to Flutter: $socksPort")
callback(Result.success(result))
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
callback(Result.failure(e))
}
}
}
override fun stopTor(callback: (Result<Unit>) -> Unit) {
Log.d(TAG, "stopTor called")
val service = torService
if (service == null) {
callback(Result.success(Unit))
return
}
scope.launch {
try {
withContext(Dispatchers.IO) {
service.stopTor()
}
callback(Result.success(Unit))
} catch (e: Exception) {
Log.e(TAG, "Failed to stop Tor", e)
callback(Result.failure(e))
}
}
}
override fun getStatus(): TorStatus {
val service = torService
?: return TorStatus(
isRunning = false,
socksPort = null,
bootstrapProgress = 0,
currentCircuit = null,
exitNodeCountry = null
)
return try {
service.getStatus()
} catch (e: Exception) {
Log.e(TAG, "Failed to get status", e)
throw e
}
}
override fun requestNewIdentity() {
Log.d(TAG, "requestNewIdentity called")
val service = torService
?: throw Exception("TorService not initialized")
try {
service.requestNewIdentity()
} catch (e: Exception) {
Log.e(TAG, "Failed to request new identity", e)
throw e
}
}
}
@@ -0,0 +1,76 @@
package eu.weblibre.flutter_tor
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileOutputStream
/**
* Manages GeoIP database files for country-based node selection
* GeoIP files are provided by the tor-android library
*/
class GeoIpManager(private val context: Context) {
companion object {
private const val TAG = "GeoIpManager"
private const val GEOIP_FILE = "geoip"
private const val GEOIP6_FILE = "geoip6"
}
/**
* Get the GeoIP file path, extracting from assets if necessary
* @param installDir Directory to install GeoIP files
* @return GeoIP file or null if not available
*/
fun getGeoIpFile(installDir: File): File? {
val geoipFile = File(installDir, GEOIP_FILE)
if (!geoipFile.exists()) {
extractAsset(GEOIP_FILE, geoipFile)
}
return if (geoipFile.exists()) geoipFile else null
}
/**
* Get the GeoIP6 file path, extracting from assets if necessary
* @param installDir Directory to install GeoIP files
* @return GeoIP6 file or null if not available
*/
fun getGeoIp6File(installDir: File): File? {
val geoip6File = File(installDir, GEOIP6_FILE)
if (!geoip6File.exists()) {
extractAsset(GEOIP6_FILE, geoip6File)
}
return if (geoip6File.exists()) geoip6File else null
}
/**
* Extract asset file to destination
* Note: tor-android library should provide these files in its assets
*/
private fun extractAsset(assetName: String, destFile: File) {
try {
context.assets.open(assetName).use { input ->
destFile.parentFile?.mkdirs()
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "Extracted $assetName to ${destFile.absolutePath}")
} catch (e: Exception) {
Log.w(TAG, "Could not extract $assetName from assets: ${e.message}")
// GeoIP files are optional - Tor will work without them
// but country-based node selection won't be available
}
}
/**
* Check if GeoIP files are available
* @param installDir Directory where GeoIP files should be
* @return true if both geoip and geoip6 exist
*/
fun areGeoIpFilesAvailable(installDir: File): Boolean {
val geoip = File(installDir, GEOIP_FILE)
val geoip6 = File(installDir, GEOIP6_FILE)
return geoip.exists() && geoip6.exists()
}
}
@@ -0,0 +1,112 @@
package eu.weblibre.flutter_tor
import android.os.Handler
import android.os.Looper
import android.util.Log
import eu.weblibre.flutter_tor.generated.TorLogApi
import eu.weblibre.flutter_tor.generated.TorLogMessage
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.plugin.common.BinaryMessenger
/**
* Handles streaming logs and status updates from Tor to Flutter
* All Flutter API calls are posted to the main thread to avoid threading issues
*/
class LogStreamHandler(messenger: BinaryMessenger) {
companion object {
private const val TAG = "LogStreamHandler"
}
private val torLogApi = TorLogApi(messenger)
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Send a log message to Flutter
* @param severity Log severity (NOTICE, WARN, ERR, DEBUG)
* @param message Log message
*/
fun sendLog(severity: String, message: String) {
mainHandler.post {
try {
val logMessage = TorLogMessage(
severity = severity,
message = message,
timestamp = System.currentTimeMillis()
)
torLogApi.onLogMessage(logMessage) { }
} catch (e: Exception) {
Log.e(TAG, "Error sending log to Flutter: ${e.message}", e)
}
}
}
/**
* Send status change to Flutter
* @param status Current Tor status
*/
fun sendStatusChange(status: TorStatus) {
Log.d(TorManager.Companion.TAG, "sendStatusChange() returning: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}")
mainHandler.post {
try {
torLogApi.onStatusChanged(status) { }
} catch (e: Exception) {
Log.e(TAG, "Error sending status to Flutter: ${e.message}", e)
}
}
}
/**
* Parse and send Tor control port event
* @param eventType Event type from TorControlConnection (e.g., "NOTICE", "WARN", "ERR", "CIRC", "BW")
* @param eventData Event data
*/
fun handleTorEvent(eventType: String, eventData: String) {
when (eventType) {
"NOTICE" -> sendLog("NOTICE", eventData)
"WARN" -> sendLog("WARN", eventData)
"ERR" -> sendLog("ERR", eventData)
"DEBUG" -> sendLog("DEBUG", eventData)
"INFO" -> sendLog("INFO", eventData)
// Don't log circuit/bandwidth events to UI, they're too verbose
"CIRC", "ORCONN", "BW", "STREAM", "ADDRMAP", "NEWDESC" -> {
// These are logged to logcat by TorManager for debugging,
// but not sent to Flutter UI
}
else -> {
// Unknown event types, log for debugging
sendLog("DEBUG", "$eventType: $eventData")
}
}
}
/**
* Helper to send notice logs
*/
fun notice(message: String) {
sendLog("NOTICE", message)
}
/**
* Helper to send warning logs
*/
fun warn(message: String) {
sendLog("WARN", message)
}
/**
* Helper to send error logs
*/
fun error(message: String) {
sendLog("ERR", message)
}
/**
* Helper to send debug logs
*/
fun debug(message: String) {
sendLog("DEBUG", message)
}
}
@@ -0,0 +1,200 @@
package eu.weblibre.flutter_tor
import android.content.Context
import android.util.Log
import IPtProxy.Controller
import IPtProxy.IPtProxy
import IPtProxy.OnTransportStopped
import java.io.File
/**
* Manages pluggable transports via IPtProxy
* Supports: obfs4, snowflake, meek, webtunnel
*/
class PluggableTransportManager(private val context: Context) {
companion object {
private const val TAG = "PTManager"
// Snowflake configuration
private const val SNOWFLAKE_BROKER = "https://snowflake-broker.torproject.net/"
private const val SNOWFLAKE_BROKER_AMP = "https://snowflake-broker.torproject.net.global.prod.fastly.net/"
private const val SNOWFLAKE_AMP_CACHE = "https://cdn.ampproject.org/"
private val SNOWFLAKE_FRONTS = listOf("foursquare.com", "github.githubassets.com")
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 val stateDir = File(context.cacheDir, "iptproxy")
private val activeTransports = mutableSetOf<String>()
private val statusCallback = object : OnTransportStopped {
override fun stopped(name: String?, exception: Exception?) {
if (name != null) {
activeTransports.remove(name)
if (exception != null) {
Log.e(TAG, "$name stopped with error: ${exception.message}", exception)
} else {
Log.d(TAG, "$name stopped normally")
}
}
}
}
// Lazy singleton controller (like Orbot does)
val controller: Controller by lazy {
Controller(
stateDir.absolutePath,
true, // enableLogging
false, // unsafeLogging
"INFO", // logLevel
statusCallback
)
}
init {
stateDir.mkdirs()
}
/**
* Start pluggable transport for the given type
* @param type Transport type
* @return Map of transport name to port (e.g., {"obfs4": 12345})
*/
fun startTransport(type: TransportType): Map<String, Int> {
Log.d(TAG, "Starting transport: $type")
// Stop any currently running transports before starting new ones
stopAll()
val ports = mutableMapOf<String, Int>()
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")
}
}
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")
}
}
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")
}
}
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")
}
}
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")
}
}
TransportType.NONE, TransportType.CUSTOM -> {
// No pluggable transport needed
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start transport $type: ${e.message}", e)
}
return ports
}
/**
* Configure Snowflake-specific settings
*/
private fun configureSnowflake(useAmp: Boolean) {
controller.snowflakeIceServers = SNOWFLAKE_ICE_SERVERS
if (useAmp) {
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER_AMP
controller.snowflakeFrontDomains = SNOWFLAKE_AMP_FRONTS.joinToString(",")
controller.snowflakeAmpCacheUrl = SNOWFLAKE_AMP_CACHE
} else {
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER
controller.snowflakeFrontDomains = SNOWFLAKE_FRONTS.joinToString(",")
controller.snowflakeAmpCacheUrl = ""
}
controller.snowflakeSqsUrl = ""
controller.snowflakeSqsCreds = ""
Log.d(TAG, "Configured Snowflake: broker=${controller.snowflakeBrokerUrl}, amp=$useAmp")
}
/**
* Stop all running pluggable transports
*/
fun stopAll() {
Log.d(TAG, "Stopping all transports")
// Stop each active transport
activeTransports.toList().forEach { transportName ->
try {
controller.stop(transportName)
Log.d(TAG, "Stopped transport: $transportName")
} catch (e: Exception) {
Log.w(TAG, "Error stopping $transportName: ${e.message}")
}
}
activeTransports.clear()
Log.d(TAG, "All transports stopped")
}
/**
* Get the port for a specific transport
* @param transportName Transport name (e.g., "obfs4", "snowflake")
* @return Port number or null
*/
fun getPort(transportName: String): Int? {
val port = controller.port(transportName)
return if (port > 0) port.toInt() else null
}
/**
* Check if a transport is currently running
*/
fun isRunning(): Boolean {
return activeTransports.isNotEmpty()
}
}
@@ -0,0 +1,32 @@
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
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2025 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.weblibre.flutter_tor
import IPtProxy.IPtProxy
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TransportType
class ProxyImpl(val controller: IPtProxy.Controller) : IPtProxyController {
override fun start(proxyType: TransportType, proxy: String): Long {
val type = when (proxyType) {
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
TransportType.MEEK -> IPtProxy.MeekLite
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
TransportType.OBFS4 -> IPtProxy.Obfs4
TransportType.NONE -> null
else -> {
throw Exception("Unsupported transport type")
}
}
controller.start(type, proxy)
return controller.port(type)
}
override fun stop(proxyType: TransportType) {
val type = when (proxyType) {
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
TransportType.MEEK -> IPtProxy.MeekLite
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
TransportType.OBFS4 -> IPtProxy.Obfs4
TransportType.NONE -> null
else -> {
throw Exception("Unsupported transport type")
}
}
controller.stop(type)
}
}
@@ -0,0 +1,207 @@
package eu.weblibre.flutter_tor
import eu.weblibre.flutter_tor.generated.TorConfiguration
import IPtProxy.IPtProxy
import java.io.File
/**
* Generates Tor configuration (torrc) based on user settings
*/
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")
append("\n")
// GeoIP files for country-based node selection
if (geoipFile != null && geoipFile.exists()) {
append("GeoIPFile ${geoipFile.absolutePath}\n")
}
if (geoip6File != null && geoip6File.exists()) {
append("GeoIPv6File ${geoip6File.absolutePath}\n")
}
append("\n")
// Entry node countries
config.entryNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("EntryNodes $formatted\n")
}
}
// Exit node countries
config.exitNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("ExitNodes $formatted\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")
}
}
}
TransportType.SNOWFLAKE, TransportType.SNOWFLAKE_AMP -> {
transportPorts[IPtProxy.Snowflake]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Snowflake} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.MEEK, TransportType.MEEK_AZURE -> {
transportPorts[IPtProxy.MeekLite]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.MeekLite} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.WEBTUNNEL -> {
transportPorts[IPtProxy.Webtunnel]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Webtunnel} socks5 127.0.0.1:$port\n")
}
}
}
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
}
}
append("\n")
// Bridge configuration
if (transport != TransportType.NONE) {
val normalizedBridges = BridgeParser.normalize(config.bridgeLines)
if (normalizedBridges.isNotEmpty()) {
append("UseBridges 1\n")
normalizedBridges.forEach { bridge ->
append("Bridge $bridge\n")
}
append("\n")
}
}
// 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")
append("VirtualAddrNetwork 10.192.0.0/10\n")
append("AutomapHostsOnResolve 1\n")
append("DormantClientTimeout 10 minutes\n")
append("DormantCanceledByStartup 1\n")
// Note: DisableNetwork is set to 1 in defaults.torrc
// It will be enabled via control port after setup completes (matching Orbot)
// We DON'T set it here to avoid overriding the defaults.torrc setting
append("DisableNetwork 0\n")
append("Log notice stdout\n") // Log to stdout for capture
append("\n")
}
/**
* Format country codes for Tor configuration
* Input: "de,fr,nl" or "{de},{fr},{nl}" or "de, fr, nl"
* Output: "{de},{fr},{nl}"
*/
private fun formatCountries(countries: String): String {
val codes = countries
.replace("{", "")
.replace("}", "")
.split(",")
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.filter { it.length == 2 } // ISO 3166-1 alpha-2 codes
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) }
.distinct()
bridgeTransports.forEach { transportName ->
when (transportName) {
"obfs4" -> transportPorts[IPtProxy.Obfs4]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin obfs4 socks5 127.0.0.1:$port\n")
}
}
"snowflake" -> transportPorts[IPtProxy.Snowflake]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin snowflake socks5 127.0.0.1:$port\n")
}
}
"meek_lite" -> transportPorts[IPtProxy.MeekLite]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin meek_lite socks5 127.0.0.1:$port\n")
}
}
"webtunnel" -> transportPorts[IPtProxy.Webtunnel]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin webtunnel socks5 127.0.0.1:$port\n")
}
}
}
}
}
/**
* 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)
}
}
@@ -0,0 +1,392 @@
package eu.weblibre.flutter_tor
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import net.freehaven.tor.control.RawEventListener
import net.freehaven.tor.control.TorControlCommands
import net.freehaven.tor.control.TorControlConnection
import org.torproject.jni.TorService
import java.io.File
/**
* Core Tor lifecycle manager
* Handles starting/stopping Tor, control port connection, and event listening
*/
class TorManager(
private val context: Context,
private val logHandler: LogStreamHandler
) {
companion object {
const val TAG = "TorManager"
}
private val dataDir = File(context.filesDir, "tor_data")
private val installDir = File(context.filesDir, "tor_install")
private var torServiceConnection: ServiceConnection? = null
private var controlConnection: TorControlConnection? = null
private var torService: TorService? = null
val pluggableTransportManager = PluggableTransportManager(context)
private val geoIpManager = GeoIpManager(context)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
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
private var isRunning = false
private var bootstrapProgress = 0
/**
* Start Tor with the given configuration
* @param config Tor configuration from Flutter
* @return SOCKS port
*/
suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) {
if (isRunning) {
Log.w(TAG, "Tor already running")
return@withContext socksPort
}
try {
logHandler.notice("Starting Tor...")
// Create directories
dataDir.mkdirs()
installDir.mkdirs()
// 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!
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())
// Start TorService
// Note: torrcFile is now written to the correct location via TorService.getTorrc()
// so TorService will automatically find and use it
startTorService()
isRunning = true
logHandler.notice("Tor started successfully")
sendStatusUpdate()
socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
logHandler.error("Failed to start Tor: ${e.message}")
cleanup()
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) }
.distinct()
val ports = mutableMapOf<String, Int>()
transports.forEach { transportName ->
val transportType = when (transportName) {
"obfs4" -> TransportType.OBFS4
"snowflake" -> TransportType.SNOWFLAKE
"meek_lite" -> TransportType.MEEK
"webtunnel" -> TransportType.WEBTUNNEL
else -> null
}
transportType?.let { type ->
ports.putAll(pluggableTransportManager.startTransport(type))
}
}
return ports
}
/**
* Start the native TorService and bind to it
* TorService will automatically use the torrc written to TorService.getTorrc(context)
*/
private suspend fun startTorService() = suspendCancellableCoroutine<Unit> { continuation ->
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "TorService connected")
val binder = service as? TorService.LocalBinder
torService = binder?.service
// Wait for control connection to be available
scope.launch {
var conn: TorControlConnection? = null
var attempts = 0
while (conn == null && attempts < 60) { // 30 seconds timeout
delay(500)
conn = torService?.torControlConnection
attempts++
}
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)
controlConnection = conn
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit) {}
}
} else {
val error = Exception("Failed to get control connection after 30 seconds")
if (continuation.isActive) {
continuation.resumeWithException(error)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
torService = null
controlConnection = null
}
}
torServiceConnection = connection
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) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
/**
* Setup control connection and event listeners
*/
private fun setupControlConnection(conn: TorControlConnection) {
try {
// Add event listener
conn.addRawEventListener(TorEventListener())
// Subscribe to events (matching Orbot's event subscriptions)
conn.setEvents(listOf(
TorControlCommands.EVENT_OR_CONN_STATUS,
TorControlCommands.EVENT_CIRCUIT_STATUS,
TorControlCommands.EVENT_NOTICE_MSG,
TorControlCommands.EVENT_WARN_MSG,
TorControlCommands.EVENT_ERR_MSG,
TorControlCommands.EVENT_BANDWIDTH_USED,
TorControlCommands.EVENT_NEW_DESC,
TorControlCommands.EVENT_ADDRMAP
))
// Enable network now that configuration is complete (like Orbot does)
conn.setConf("DisableNetwork", "0")
Log.d(TAG, "Control connection setup complete")
logHandler.notice("Connected to Tor control port")
} catch (e: Exception) {
Log.e(TAG, "Failed to setup control connection", e)
logHandler.error("Control connection error: ${e.message}")
}
}
/**
* Stop Tor and cleanup
*/
suspend fun stop() = withContext(Dispatchers.IO) {
Log.d(TAG, "Stopping Tor")
logHandler.notice("Stopping Tor...")
try {
// Shutdown Tor gracefully
controlConnection?.shutdownTor("SHUTDOWN")
delay(1000) // Give Tor time to shutdown
cleanup()
logHandler.notice("Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Tor", e)
cleanup()
}
}
/**
* Cleanup resources
*/
private fun cleanup() {
isRunning = false
bootstrapProgress = 0
socksPort = -1
try {
controlConnection?.let {
// Don't shutdown again, just close
}
controlConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error closing control connection", e)
}
try {
torServiceConnection?.let {
context.unbindService(it)
}
torServiceConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error unbinding TorService", e)
}
torService = null
pluggableTransportManager.stopAll()
sendStatusUpdate()
}
/**
* Request a new Tor identity (new circuit)
*/
fun requestNewIdentity() {
scope.launch {
try {
controlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
logHandler.notice("Requested new Tor identity")
} catch (e: Exception) {
Log.e(TAG, "Failed to request new identity", e)
logHandler.error("Failed to request new identity: ${e.message}")
}
}
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
val status = TorStatus(
isRunning = isRunning,
socksPort = if (isRunning) socksPort.toLong() else null,
bootstrapProgress = bootstrapProgress.toLong(),
currentCircuit = null, // TODO: track current circuit
exitNodeCountry = null // TODO: track exit node country
)
Log.d(TAG, "getStatus() returning: isRunning=$isRunning, socksPort=$socksPort, bootstrap=$bootstrapProgress")
// logHandler.sendStatusChange(status)
return status
}
/**
* Send status update to Flutter
*/
private fun sendStatusUpdate() {
logHandler.sendStatusChange(getStatus())
}
/**
* Event listener for Tor control port events
*/
private inner class TorEventListener : RawEventListener {
override fun onEvent(eventType: String, eventData: String) {
Log.d(TAG, "Tor event: $eventType - $eventData")
// Handle bootstrap progress (comes in NOTICE events)
if (eventData.contains("Bootstrapped")) {
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
bootstrapProgress = progress
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
}
}
/**
* Cleanup when manager is destroyed
*/
fun destroy() {
scope.cancel()
runBlocking {
if (isRunning) {
stop()
}
}
}
}
@@ -0,0 +1,202 @@
package eu.weblibre.flutter_tor
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.plugin.common.BinaryMessenger
import kotlinx.coroutines.*
/**
* Foreground service for running Tor in the background
* Keeps Tor running even when the app is backgrounded
*/
class TorService : Service() {
companion object {
private const val TAG = "TorService"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "flutter_tor_service"
const val ACTION_START = "eu.weblibre.flutter_tor.START"
const val ACTION_STOP = "eu.weblibre.flutter_tor.STOP"
const val EXTRA_CONFIG = "config"
}
private val binder = LocalBinder()
private var torManager: TorManager? = null
private var logHandler: LogStreamHandler? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
inner class LocalBinder : Binder() {
fun getService(): TorService = this@TorService
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: ${intent?.action}")
when (intent?.action) {
ACTION_START -> {
// Start in foreground immediately
startForeground(NOTIFICATION_ID, createNotification("Starting Tor..."))
// Actual start will be handled via binder methods
}
ACTION_STOP -> {
scope.launch {
stopTor()
stopSelf()
}
}
}
return START_STICKY
}
/**
* Initialize the service with Flutter messenger for log streaming
*/
fun initialize(messenger: BinaryMessenger) {
if (logHandler == null) {
logHandler = LogStreamHandler(messenger)
torManager = TorManager(applicationContext, logHandler!!)
IPtProxyController.setUp(
messenger,
ProxyImpl(controller = torManager!!.pluggableTransportManager.controller)
)
Log.d(TAG, "Service initialized with messenger")
}
}
/**
* Start Tor with configuration
*/
suspend fun startTor(config: TorConfiguration): Int {
Log.d(TAG, "Starting Tor...")
updateNotification("Starting Tor...")
val manager = torManager ?: throw IllegalStateException("Service not initialized")
try {
val socksPort = manager.start(config)
updateNotification("Tor is running (SOCKS: $socksPort)")
return socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
updateNotification("Failed to start Tor")
throw e
}
}
/**
* Stop Tor
*/
suspend fun stopTor() {
Log.d(TAG, "Stopping Tor...")
updateNotification("Stopping Tor...")
torManager?.stop()
updateNotification("Tor stopped")
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
return torManager?.getStatus() ?: TorStatus(
isRunning = false,
socksPort = null,
bootstrapProgress = 0,
currentCircuit = null,
exitNodeCountry = null
)
}
/**
* Request new Tor identity
*/
fun requestNewIdentity() {
torManager?.requestNewIdentity()
}
/**
* Update notification text
*/
private fun updateNotification(text: String) {
val notification = createNotification(text)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Create notification for foreground service
*/
private fun createNotification(text: String): Notification {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Tor Service")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Use custom icon
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
/**
* Create notification channel (required for Android 8+)
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Tor Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps Tor running in the background"
setShowBadge(false)
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
scope.launch {
torManager?.destroy()
}
scope.cancel()
}
}
@@ -0,0 +1,34 @@
package eu.weblibre.flutter_tor
/**
* Transport types for Tor connections
* Maps to Pigeon-generated enum
*/
enum class TransportType {
NONE, // Direct Tor connection (no bridges)
OBFS4, // obfs4 pluggable transport
SNOWFLAKE, // Snowflake (default broker)
SNOWFLAKE_AMP, // Snowflake via AMP cache
MEEK, // Meek pluggable transport
MEEK_AZURE, // Meek via Azure CDN
WEBTUNNEL, // WebTunnel pluggable transport
CUSTOM; // Custom bridge lines (passthrough)
companion object {
/**
* Convert from Pigeon-generated enum
*/
fun fromPigeon(pigeon: eu.weblibre.flutter_tor.generated.TransportType): TransportType {
return when (pigeon) {
eu.weblibre.flutter_tor.generated.TransportType.NONE -> NONE
eu.weblibre.flutter_tor.generated.TransportType.OBFS4 -> OBFS4
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE -> SNOWFLAKE
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE_AMP -> SNOWFLAKE_AMP
eu.weblibre.flutter_tor.generated.TransportType.MEEK -> MEEK
eu.weblibre.flutter_tor.generated.TransportType.MEEK_AZURE -> MEEK_AZURE
eu.weblibre.flutter_tor.generated.TransportType.WEBTUNNEL -> WEBTUNNEL
eu.weblibre.flutter_tor.generated.TransportType.CUSTOM -> CUSTOM
}
}
}
}
@@ -0,0 +1,497 @@
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package eu.weblibre.flutter_tor.generated
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object TorApiPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).contains(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Transport types for Tor connections */
enum class TransportType(val raw: Int) {
/** Direct Tor connection (no bridges) */
NONE(0),
/** obfs4 pluggable transport */
OBFS4(1),
/** Snowflake pluggable transport (default broker) */
SNOWFLAKE(2),
/** Snowflake via AMP cache */
SNOWFLAKE_AMP(3),
/** Meek pluggable transport */
MEEK(4),
/** Meek via Azure CDN */
MEEK_AZURE(5),
/** WebTunnel pluggable transport */
WEBTUNNEL(6),
/** Custom bridge lines (passthrough) */
CUSTOM(7);
companion object {
fun ofRaw(raw: Int): TransportType? {
return values().firstOrNull { it.raw == raw }
}
}
}
/**
* Configuration for starting Tor
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorConfiguration (
/** Transport type to use */
val transport: TransportType,
/** Bridge lines for the transport (empty for direct connection) */
val bridgeLines: List<String>,
/** Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl") */
val entryNodeCountries: String? = null,
/** Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se") */
val exitNodeCountries: String? = null,
/** If true, never use nodes outside specified countries */
val strictNodes: Boolean? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorConfiguration {
val transport = pigeonVar_list[0] as TransportType
val bridgeLines = pigeonVar_list[1] as List<String>
val entryNodeCountries = pigeonVar_list[2] as String?
val exitNodeCountries = pigeonVar_list[3] as String?
val strictNodes = pigeonVar_list[4] as Boolean?
return TorConfiguration(transport, bridgeLines, entryNodeCountries, exitNodeCountries, strictNodes)
}
}
fun toList(): List<Any?> {
return listOf(
transport,
bridgeLines,
entryNodeCountries,
exitNodeCountries,
strictNodes,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorConfiguration) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Current Tor status
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorStatus (
/** Whether Tor is running */
val isRunning: Boolean,
/** SOCKS proxy port (if running) */
val socksPort: Long? = null,
/** Bootstrap progress (0-100) */
val bootstrapProgress: Long,
/** Current circuit ID */
val currentCircuit: String? = null,
/** Exit node country code */
val exitNodeCountry: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorStatus {
val isRunning = pigeonVar_list[0] as Boolean
val socksPort = pigeonVar_list[1] as Long?
val bootstrapProgress = pigeonVar_list[2] as Long
val currentCircuit = pigeonVar_list[3] as String?
val exitNodeCountry = pigeonVar_list[4] as String?
return TorStatus(isRunning, socksPort, bootstrapProgress, currentCircuit, exitNodeCountry)
}
}
fun toList(): List<Any?> {
return listOf(
isRunning,
socksPort,
bootstrapProgress,
currentCircuit,
exitNodeCountry,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorStatus) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Log message from Tor
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorLogMessage (
/** Log severity (NOTICE, WARN, ERR, DEBUG) */
val severity: String,
/** Log message */
val message: String,
/** Timestamp (milliseconds since epoch) */
val timestamp: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorLogMessage {
val severity = pigeonVar_list[0] as String
val message = pigeonVar_list[1] as String
val timestamp = pigeonVar_list[2] as Long
return TorLogMessage(severity, message, timestamp)
}
}
fun toList(): List<Any?> {
return listOf(
severity,
message,
timestamp,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorLogMessage) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class TorApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
TransportType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorConfiguration.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorStatus.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorLogMessage.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is TransportType -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is TorConfiguration -> {
stream.write(130)
writeValue(stream, value.toList())
}
is TorStatus -> {
stream.write(131)
writeValue(stream, value.toList())
}
is TorLogMessage -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/**
* Host API (Flutter -> Native)
*
* Generated interface from Pigeon that represents a handler of messages from Flutter.
*/
interface TorApi {
/**
* Start Tor with the given configuration
* Returns a Future to avoid blocking the main thread
*/
fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit)
/** Stop Tor */
fun stopTor(callback: (Result<Unit>) -> Unit)
/** Get current status */
fun getStatus(): TorStatus
/** Request a new Tor identity (new circuit) */
fun requestNewIdentity()
companion object {
/** The codec used by TorApi. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
/** Sets up an instance of `TorApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: TorApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.startTor$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val configArg = args[0] as TorConfiguration
api.startTor(configArg) { result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(TorApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(TorApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.stopTor$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.stopTor{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(TorApiPigeonUtils.wrapError(error))
} else {
reply.reply(TorApiPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.getStatus$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getStatus())
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.requestNewIdentity()
listOf(null)
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/**
* Flutter API (Native -> Flutter)
*
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
*/
class TorLogApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by TorLogApi. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
}
/** Called when a log message is received */
fun onLogMessage(logArg: TorLogMessage, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(logArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
}
}
}
/** Called when status changes */
fun onStatusChanged(statusArg: TorStatus, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(statusArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface IPtProxyController {
fun start(proxyType: TransportType, proxy: String): Long
fun stop(proxyType: TransportType)
companion object {
/** The codec used by IPtProxyController. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
/** Sets up an instance of `IPtProxyController` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: IPtProxyController?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.start$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyTypeArg = args[0] as TransportType
val proxyArg = args[1] as String
val wrapped: List<Any?> = try {
listOf(api.start(proxyTypeArg, proxyArg))
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyTypeArg = args[0] as TransportType
val wrapped: List<Any?> = try {
api.stop(proxyTypeArg)
listOf(null)
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,27 @@
package eu.weblibre.flutter_tor
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito
import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class FlutterTorPluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = FlutterTorPlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}