initial
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+200
@@ -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()
|
||||
}
|
||||
}
|
||||
+112
@@ -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)
|
||||
}
|
||||
}
|
||||
+200
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+497
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user