initial
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
- platform: android
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,215 @@
|
||||
# flutter_tor
|
||||
|
||||
A Flutter plugin for running Tor with pluggable transports on Android. This plugin provides a simple SOCKS5 proxy interface to the Tor network with support for multiple transport types and country-based node selection.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Transport Types**: Direct connection, obfs4, Snowflake, Meek, WebTunnel, and custom bridges
|
||||
- **SOCKS5 Proxy**: Returns a random local port for SOCKS5 connections
|
||||
- **Country Selection**: Configure entry and exit node countries
|
||||
- **Background Service**: Keeps Tor running even when app is backgrounded
|
||||
- **Log Streaming**: Real-time log messages from Tor
|
||||
- **Bootstrap Progress**: Track connection progress (0-100%)
|
||||
- **New Identity**: Request new Tor circuits on demand
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ Android
|
||||
- ❌ iOS (not yet implemented)
|
||||
|
||||
## Installation
|
||||
|
||||
Add to your `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_tor:
|
||||
path: ../flutter_tor # Update path as needed
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```dart
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
|
||||
final tor = FlutterTor();
|
||||
|
||||
// Start Tor with direct connection
|
||||
final result = await tor.start(TorConfiguration(
|
||||
transport: TransportType.none,
|
||||
bridgeLines: [],
|
||||
));
|
||||
|
||||
print('SOCKS proxy running on port ${result.socksPort}');
|
||||
```
|
||||
|
||||
### With Snowflake Bridge
|
||||
|
||||
```dart
|
||||
final result = await tor.start(TorConfiguration(
|
||||
transport: TransportType.snowflake,
|
||||
bridgeLines: [
|
||||
'snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F5...',
|
||||
],
|
||||
));
|
||||
```
|
||||
|
||||
### With Country Selection
|
||||
|
||||
```dart
|
||||
final result = await tor.start(TorConfiguration(
|
||||
transport: TransportType.obfs4,
|
||||
bridgeLines: ['obfs4 ...'],
|
||||
entryNodeCountries: 'de,fr,nl', // Entry via Germany, France, or Netherlands
|
||||
exitNodeCountries: 'ch,is', // Exit via Switzerland or Iceland
|
||||
strictNodes: false, // Allow fallback if specified countries unavailable
|
||||
));
|
||||
```
|
||||
|
||||
### Listening to Logs
|
||||
|
||||
```dart
|
||||
tor.logStream.listen((log) {
|
||||
print('[${log.severity}] ${log.message}');
|
||||
});
|
||||
|
||||
tor.bootstrapProgressStream.listen((progress) {
|
||||
print('Bootstrap: $progress%');
|
||||
});
|
||||
|
||||
tor.statusStream.listen((status) {
|
||||
print('Tor running: ${status.isRunning}');
|
||||
print('SOCKS port: ${status.socksPort}');
|
||||
});
|
||||
```
|
||||
|
||||
### Stop/Restart with Different Config
|
||||
|
||||
```dart
|
||||
// Stop Tor
|
||||
await tor.stop();
|
||||
|
||||
// Start again with different config
|
||||
await tor.start(TorConfiguration(
|
||||
transport: TransportType.meek,
|
||||
bridgeLines: ['meek_lite ...'],
|
||||
exitNodeCountries: 'se,no',
|
||||
));
|
||||
```
|
||||
|
||||
### Request New Identity
|
||||
|
||||
```dart
|
||||
// Get a new Tor circuit
|
||||
await tor.requestNewIdentity();
|
||||
```
|
||||
|
||||
## Transport Types
|
||||
|
||||
| Transport | Description |
|
||||
|-----------|-------------|
|
||||
| `none` | Direct Tor connection (no bridges) |
|
||||
| `obfs4` | obfs4 pluggable transport |
|
||||
| `snowflake` | Snowflake (default broker) |
|
||||
| `snowflakeAmp` | Snowflake via AMP cache |
|
||||
| `meek` | Meek pluggable transport |
|
||||
| `meekAzure` | Meek via Azure CDN |
|
||||
| `webtunnel` | WebTunnel pluggable transport |
|
||||
| `custom` | Custom bridge lines (auto-detected) |
|
||||
|
||||
## Permissions
|
||||
|
||||
The plugin requires the following Android permissions (automatically added):
|
||||
|
||||
- `INTERNET` - Network access
|
||||
- `ACCESS_NETWORK_STATE` - Network state detection
|
||||
- `FOREGROUND_SERVICE` - Keep Tor running in background
|
||||
- `FOREGROUND_SERVICE_SPECIAL_USE` - Android 14+ requirement
|
||||
- `POST_NOTIFICATIONS` - Android 13+ for foreground service notification
|
||||
|
||||
## Architecture
|
||||
|
||||
This plugin is a simplified version of Orbot, extracting only the core Tor + Pluggable Transport functionality:
|
||||
|
||||
- **No VPN mode** - Only SOCKS5 proxy
|
||||
- **No per-app routing** - Use the SOCKS proxy directly
|
||||
- **Foreground Service** - Keeps Tor running with a notification
|
||||
- **Pigeon Communication** - Type-safe Flutter ↔ Native communication
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Clone with submodules:
|
||||
```bash
|
||||
git clone --recursive https://github.com/yourusername/orbot
|
||||
cd orbot/flutter_tor
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
3. Generate Pigeon code:
|
||||
```bash
|
||||
flutter pub run pigeon --input pigeons/tor_api.dart
|
||||
```
|
||||
|
||||
### Run Example
|
||||
|
||||
```bash
|
||||
cd example
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **tor-android** (0.4.8.21.1) - Native Tor binaries
|
||||
- **jtorctl** (0.4.5.7) - Tor control protocol
|
||||
- **IPtProxy** (4.3.0) - Pluggable transports (obfs4, snowflake, meek, webtunnel)
|
||||
- **Pigeon** (22.6.3) - Flutter ↔ Native communication
|
||||
|
||||
## Size Impact
|
||||
|
||||
- APK size increase: ~18-22MB (Tor binaries + IPtProxy for all ABIs)
|
||||
- Supports: armeabi-v7a, arm64-v8a, x86, x86_64
|
||||
|
||||
## Limitations
|
||||
|
||||
- Android only (iOS not implemented)
|
||||
- No VPN mode (SOCKS5 proxy only)
|
||||
- No HTTP proxy (SOCKS5 only)
|
||||
- GeoIP files may not be available (country selection optional)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tor fails to start
|
||||
|
||||
- Check logStream for error messages
|
||||
- Ensure bridge lines are valid for the selected transport
|
||||
- Verify network connectivity
|
||||
- Try with TransportType.none first
|
||||
|
||||
### Country selection not working
|
||||
|
||||
- GeoIP files must be available (check logs)
|
||||
- Country codes must be ISO 3166-1 alpha-2 (e.g., "US", "DE")
|
||||
- Use strictNodes: false to allow fallback
|
||||
|
||||
### App crashes on startup
|
||||
|
||||
- Ensure all submodules are initialized: `git submodule update --init --recursive`
|
||||
- Check Android Studio build output for missing dependencies
|
||||
|
||||
## Contributing
|
||||
|
||||
This plugin is part of the Orbot project. See the main repository for contribution guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2009-2025, Nathan Freitas, The Guardian Project
|
||||
|
||||
See LICENSE file for details.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,16 @@
|
||||
# flutter_tor_example
|
||||
|
||||
Demonstrates how to use the flutter_tor plugin.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,35 @@
|
||||
# This file configures the static analysis results for your project (errors,
|
||||
# warnings, and lints).
|
||||
#
|
||||
# This enables the 'recommended' set of lints from `package:lints`.
|
||||
# This set helps identify many issues that may lead to problems when running
|
||||
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||
# style and format.
|
||||
#
|
||||
# If you want a smaller set of lints you can change this to specify
|
||||
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||
# (the recommended set includes the core lints).
|
||||
# The core lints are also what is used by pub.dev for scoring packages.
|
||||
|
||||
include: package:lint/package.yaml
|
||||
# Uncomment the following section to specify additional rules.
|
||||
|
||||
linter:
|
||||
rules:
|
||||
unawaited_futures: true
|
||||
discarded_futures: true
|
||||
collection_methods_unrelated_type: true
|
||||
|
||||
analyzer:
|
||||
plugins:
|
||||
- custom_lint
|
||||
exclude:
|
||||
- "**.g.dart"
|
||||
- "**.swagger.dart"
|
||||
- "**.freezed.dart"
|
||||
- "**.chopper.dart"
|
||||
# For more information about the core and recommended set of lints, see
|
||||
# https://dart.dev/go/core-lints
|
||||
|
||||
# For additional information about configuring this file, see
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "eu.weblibre.flutter_tor_example"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "eu.weblibre.flutter_tor_example"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="flutter_tor_example"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package eu.weblibre.flutter_tor_example
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,476 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:socks5_proxy/socks_client.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Tor Example',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const TorDemoPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TorDemoPage extends StatefulWidget {
|
||||
const TorDemoPage({super.key});
|
||||
|
||||
@override
|
||||
State<TorDemoPage> createState() => _TorDemoPageState();
|
||||
}
|
||||
|
||||
class _TorDemoPageState extends State<TorDemoPage> {
|
||||
final _tor = FlutterTor();
|
||||
final _logs = <TorLogMessage>[];
|
||||
|
||||
TransportType _selectedTransport = TransportType.none;
|
||||
int? _socksPort;
|
||||
int _bootstrapProgress = 0;
|
||||
bool _isRunning = false;
|
||||
String _entryCountries = '';
|
||||
String _exitCountries = '';
|
||||
bool _strictNodes = false;
|
||||
final _bridgeLinesController = TextEditingController();
|
||||
|
||||
// IP test state
|
||||
String? _currentIp;
|
||||
bool _isTestingIp = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Listen to logs
|
||||
_tor.logStream.listen((log) {
|
||||
setState(() {
|
||||
_logs.add(log);
|
||||
if (_logs.length > 100) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to status changes
|
||||
_tor.statusStream.listen((status) {
|
||||
print(
|
||||
'DEBUG statusStream: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}',
|
||||
);
|
||||
setState(() {
|
||||
_isRunning = status.isRunning;
|
||||
final newPort = status.socksPort?.toInt();
|
||||
if (newPort != _socksPort) {
|
||||
print('DEBUG: Port changed from $_socksPort to $newPort');
|
||||
}
|
||||
_socksPort = newPort;
|
||||
_bootstrapProgress = status.bootstrapProgress.toInt();
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to bootstrap progress
|
||||
_tor.bootstrapProgressStream.listen((progress) {
|
||||
setState(() {
|
||||
_bootstrapProgress = progress;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startTor() async {
|
||||
try {
|
||||
final bridgeLines = _bridgeLinesController.text
|
||||
.split('\n')
|
||||
.where((line) => line.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final config = TorConfiguration(
|
||||
transport: _selectedTransport,
|
||||
bridgeLines: bridgeLines,
|
||||
entryNodeCountries: _entryCountries.isEmpty ? null : _entryCountries,
|
||||
exitNodeCountries: _exitCountries.isEmpty ? null : _exitCountries,
|
||||
strictNodes: _strictNodes,
|
||||
);
|
||||
|
||||
final socksPort = await _tor.start(config);
|
||||
print('DEBUG startTor result: socksPort=${socksPort}');
|
||||
setState(() {
|
||||
_socksPort = socksPort.toInt();
|
||||
print('DEBUG: Set _socksPort to $_socksPort from start result');
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Tor started on port ${socksPort}')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start Tor: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopTor() async {
|
||||
try {
|
||||
await _tor.stop();
|
||||
setState(() {
|
||||
_socksPort = null;
|
||||
_bootstrapProgress = 0;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Tor stopped')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to stop Tor: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestNewIdentity() async {
|
||||
try {
|
||||
await _tor.requestNewIdentity();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Requested new Tor identity')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to request new identity: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testIpAddress() async {
|
||||
if (_socksPort == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tor is not running. Start Tor first.'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isTestingIp = true;
|
||||
_currentIp = null;
|
||||
});
|
||||
|
||||
// Debug: Show which port we're using
|
||||
print('DEBUG: _testIpAddress called');
|
||||
print('DEBUG: Current _socksPort value: $_socksPort');
|
||||
print('DEBUG: Attempting to connect to SOCKS5 proxy on port $_socksPort');
|
||||
|
||||
try {
|
||||
final portToUse = _socksPort!;
|
||||
print('DEBUG: About to connect via SOCKS5 proxy at 127.0.0.1:$portToUse');
|
||||
|
||||
// Create HttpClient object
|
||||
final client = HttpClient();
|
||||
|
||||
// Assign connection factory
|
||||
SocksTCPClient.assignToHttpClient(client, [
|
||||
ProxySettings(InternetAddress.loopbackIPv4, portToUse),
|
||||
]);
|
||||
|
||||
// Connect to ifconfig.me through the SOCKS5 proxy
|
||||
_currentIp = await client
|
||||
.getUrl(Uri.parse('https://icanhazip.com/'))
|
||||
.then((x) => x.close())
|
||||
.then((x) => utf8.decodeStream(x));
|
||||
|
||||
print('DEBUG: Connected to ifconfig.me through SOCKS5 proxy');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Your Tor IP: ${_currentIp}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('DEBUG: Error: $e');
|
||||
setState(() {
|
||||
_currentIp = 'Error: $e';
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to fetch IP: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isTestingIp = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Flutter Tor Example'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Status Card
|
||||
Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Status: ${_isRunning ? 'Running' : 'Stopped'}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (_socksPort != null) Text('SOCKS Port: $_socksPort'),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: _bootstrapProgress / 100),
|
||||
Text('Bootstrap: $_bootstrapProgress%'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Configuration
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<TransportType>(
|
||||
value: _selectedTransport,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Transport Type',
|
||||
),
|
||||
items: TransportType.values.map((transport) {
|
||||
return DropdownMenuItem(
|
||||
value: transport,
|
||||
child: Text(transport.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedTransport = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _bridgeLinesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bridge Lines (one per line)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Entry Countries (e.g., de,fr,nl)',
|
||||
),
|
||||
onChanged: (value) => _entryCountries = value,
|
||||
),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Exit Countries (e.g., ch,is,se)',
|
||||
),
|
||||
onChanged: (value) => _exitCountries = value,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Strict Nodes'),
|
||||
value: _strictNodes,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_strictNodes = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isRunning ? null : _startTor,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Start Tor'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isRunning ? _stopTor : null,
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('Stop Tor'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRunning) ...[
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _requestNewIdentity,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('New Identity'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isTestingIp ? null : _testIpAddress,
|
||||
icon: _isTestingIp
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.public),
|
||||
label: Text(
|
||||
_isTestingIp ? 'Testing...' : 'Test IP Address',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
if (_currentIp != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _currentIp!.startsWith('Error')
|
||||
? Colors.red.shade100
|
||||
: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _currentIp!.startsWith('Error')
|
||||
? Colors.red
|
||||
: Colors.green,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_currentIp!.startsWith('Error')
|
||||
? Icons.error_outline
|
||||
: Icons.check_circle_outline,
|
||||
color: _currentIp!.startsWith('Error')
|
||||
? Colors.red
|
||||
: Colors.green,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_currentIp!.startsWith('Error')
|
||||
? _currentIp!
|
||||
: 'Your Tor IP: $_currentIp',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _currentIp!.startsWith('Error')
|
||||
? Colors.red.shade900
|
||||
: Colors.green.shade900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
Text('Logs:', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = _logs[index];
|
||||
Color color;
|
||||
switch (log.severity) {
|
||||
case 'ERR':
|
||||
color = Colors.red;
|
||||
break;
|
||||
case 'WARN':
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case 'NOTICE':
|
||||
color = Colors.blue;
|
||||
break;
|
||||
default:
|
||||
color = Colors.black;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
'[${log.severity}] ${log.message}',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
name: flutter_tor_example
|
||||
description: "Demonstrates how to use the flutter_tor plugin."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
flutter_tor:
|
||||
# When depending on this package from a real application you should use:
|
||||
# flutter_tor: ^x.y.z
|
||||
# See https://dart.dev/tools/pub/dependencies#version-constraints
|
||||
# The example app is bundled with the plugin so we use a path dependency on
|
||||
# the parent directory to use the current plugin's version.
|
||||
path: ../
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# HTTP client for testing Tor connectivity
|
||||
http: any
|
||||
|
||||
# SOCKS5 proxy client for Tor
|
||||
socks5_proxy: any
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,27 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:flutter_tor_example/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Verify Platform version', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that platform version is retrieved.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(Widget widget) => widget is Text &&
|
||||
widget.data!.startsWith('Running on:'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
library flutter_tor;
|
||||
|
||||
export 'src/tor_api.g.dart'
|
||||
show
|
||||
TransportType,
|
||||
TorConfiguration,
|
||||
TorStartResult,
|
||||
TorStatus,
|
||||
TorLogMessage,
|
||||
IPtProxyController;
|
||||
|
||||
export 'src/flutter_tor.dart';
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_tor/src/tor_api.g.dart';
|
||||
|
||||
/// Flutter Tor implementation
|
||||
/// Provides a clean Dart API over the Pigeon-generated code
|
||||
class FlutterTor {
|
||||
FlutterTor() {
|
||||
_torLogApi = _TorLogApiImpl(
|
||||
onLog: _logController.add,
|
||||
onStatus: _statusController.add,
|
||||
onBootstrap: _bootstrapController.add,
|
||||
);
|
||||
|
||||
// Register the Flutter API handler so native can call us
|
||||
TorLogApi.setUp(_torLogApi);
|
||||
}
|
||||
|
||||
final _torApi = TorApi();
|
||||
late final _TorLogApiImpl _torLogApi;
|
||||
|
||||
final _logController = StreamController<TorLogMessage>.broadcast();
|
||||
final _statusController = StreamController<TorStatus>.broadcast();
|
||||
final _bootstrapController = StreamController<int>.broadcast();
|
||||
|
||||
/// Stream of log messages from Tor
|
||||
Stream<TorLogMessage> get logStream => _logController.stream;
|
||||
|
||||
/// Stream of status changes
|
||||
Stream<TorStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Stream of bootstrap progress updates (0-100)
|
||||
Stream<int> get bootstrapProgressStream => _bootstrapController.stream;
|
||||
|
||||
/// Start Tor with the given configuration
|
||||
Future<int> start(TorConfiguration config) async {
|
||||
return await _torApi.startTor(config);
|
||||
}
|
||||
|
||||
/// Stop Tor
|
||||
Future<void> stop() async {
|
||||
await _torApi.stopTor();
|
||||
}
|
||||
|
||||
/// Get current Tor status
|
||||
Future<TorStatus> getStatus() async {
|
||||
return await _torApi.getStatus();
|
||||
}
|
||||
|
||||
/// Request a new Tor identity (new circuit)
|
||||
Future<void> requestNewIdentity() async {
|
||||
await _torApi.requestNewIdentity();
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
// Unregister the Flutter API handler
|
||||
TorLogApi.setUp(null);
|
||||
|
||||
_logController.close();
|
||||
_statusController.close();
|
||||
_bootstrapController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation of TorLogApi for receiving callbacks from native
|
||||
class _TorLogApiImpl extends TorLogApi {
|
||||
_TorLogApiImpl({
|
||||
required this.onLog,
|
||||
required this.onStatus,
|
||||
required this.onBootstrap,
|
||||
});
|
||||
|
||||
final void Function(TorLogMessage) onLog;
|
||||
final void Function(TorStatus) onStatus;
|
||||
final void Function(int) onBootstrap;
|
||||
|
||||
@override
|
||||
void onLogMessage(TorLogMessage log) {
|
||||
onLog(log);
|
||||
}
|
||||
|
||||
@override
|
||||
void onStatusChanged(TorStatus status) {
|
||||
onStatus(status);
|
||||
}
|
||||
|
||||
@override
|
||||
void onBootstrapProgress(int progress) {
|
||||
onBootstrap(progress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||
if (empty) {
|
||||
return <Object?>[];
|
||||
}
|
||||
if (error == null) {
|
||||
return <Object?>[result];
|
||||
}
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
/// Transport types for Tor connections
|
||||
enum TransportType {
|
||||
/// Direct Tor connection (no bridges)
|
||||
none,
|
||||
/// obfs4 pluggable transport
|
||||
obfs4,
|
||||
/// Snowflake pluggable transport (default broker)
|
||||
snowflake,
|
||||
/// Snowflake via AMP cache
|
||||
snowflakeAmp,
|
||||
/// Meek pluggable transport
|
||||
meek,
|
||||
/// Meek via Azure CDN
|
||||
meekAzure,
|
||||
/// WebTunnel pluggable transport
|
||||
webtunnel,
|
||||
/// Custom bridge lines (passthrough)
|
||||
custom,
|
||||
}
|
||||
|
||||
/// Configuration for starting Tor
|
||||
class TorConfiguration {
|
||||
TorConfiguration({
|
||||
required this.transport,
|
||||
required this.bridgeLines,
|
||||
this.entryNodeCountries,
|
||||
this.exitNodeCountries,
|
||||
this.strictNodes,
|
||||
});
|
||||
|
||||
/// Transport type to use
|
||||
TransportType transport;
|
||||
|
||||
/// Bridge lines for the transport (empty for direct connection)
|
||||
List<String> bridgeLines;
|
||||
|
||||
/// Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl")
|
||||
String? entryNodeCountries;
|
||||
|
||||
/// Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se")
|
||||
String? exitNodeCountries;
|
||||
|
||||
/// If true, never use nodes outside specified countries
|
||||
bool? strictNodes;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
transport,
|
||||
bridgeLines,
|
||||
entryNodeCountries,
|
||||
exitNodeCountries,
|
||||
strictNodes,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static TorConfiguration decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return TorConfiguration(
|
||||
transport: result[0]! as TransportType,
|
||||
bridgeLines: (result[1] as List<Object?>?)!.cast<String>(),
|
||||
entryNodeCountries: result[2] as String?,
|
||||
exitNodeCountries: result[3] as String?,
|
||||
strictNodes: result[4] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TorConfiguration || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
}
|
||||
|
||||
/// Current Tor status
|
||||
class TorStatus {
|
||||
TorStatus({
|
||||
required this.isRunning,
|
||||
this.socksPort,
|
||||
required this.bootstrapProgress,
|
||||
this.currentCircuit,
|
||||
this.exitNodeCountry,
|
||||
});
|
||||
|
||||
/// Whether Tor is running
|
||||
bool isRunning;
|
||||
|
||||
/// SOCKS proxy port (if running)
|
||||
int? socksPort;
|
||||
|
||||
/// Bootstrap progress (0-100)
|
||||
int bootstrapProgress;
|
||||
|
||||
/// Current circuit ID
|
||||
String? currentCircuit;
|
||||
|
||||
/// Exit node country code
|
||||
String? exitNodeCountry;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
isRunning,
|
||||
socksPort,
|
||||
bootstrapProgress,
|
||||
currentCircuit,
|
||||
exitNodeCountry,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static TorStatus decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return TorStatus(
|
||||
isRunning: result[0]! as bool,
|
||||
socksPort: result[1] as int?,
|
||||
bootstrapProgress: result[2]! as int,
|
||||
currentCircuit: result[3] as String?,
|
||||
exitNodeCountry: result[4] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TorStatus || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
}
|
||||
|
||||
/// Log message from Tor
|
||||
class TorLogMessage {
|
||||
TorLogMessage({
|
||||
required this.severity,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
/// Log severity (NOTICE, WARN, ERR, DEBUG)
|
||||
String severity;
|
||||
|
||||
/// Log message
|
||||
String message;
|
||||
|
||||
/// Timestamp (milliseconds since epoch)
|
||||
int timestamp;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
severity,
|
||||
message,
|
||||
timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static TorLogMessage decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return TorLogMessage(
|
||||
severity: result[0]! as String,
|
||||
message: result[1]! as String,
|
||||
timestamp: result[2]! as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TorLogMessage || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is TransportType) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is TorConfiguration) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is TorStatus) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is TorLogMessage) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : TransportType.values[value];
|
||||
case 130:
|
||||
return TorConfiguration.decode(readValue(buffer)!);
|
||||
case 131:
|
||||
return TorStatus.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return TorLogMessage.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host API (Flutter -> Native)
|
||||
class TorApi {
|
||||
/// Constructor for [TorApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
/// Start Tor with the given configuration
|
||||
/// Returns a Future to avoid blocking the main thread
|
||||
Future<int> startTor(TorConfiguration config) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as int?)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop Tor
|
||||
Future<void> stopTor() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current status
|
||||
Future<TorStatus> getStatus() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as TorStatus?)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a new Tor identity (new circuit)
|
||||
Future<void> requestNewIdentity() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flutter API (Native -> Flutter)
|
||||
abstract class TorLogApi {
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
/// Called when a log message is received
|
||||
void onLogMessage(TorLogMessage log);
|
||||
|
||||
/// Called when status changes
|
||||
void onStatusChanged(TorStatus status);
|
||||
|
||||
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.');
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final TorLogMessage? arg_log = (args[0] as TorLogMessage?);
|
||||
assert(arg_log != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.');
|
||||
try {
|
||||
api.onLogMessage(arg_log!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.');
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final TorStatus? arg_status = (args[0] as TorStatus?);
|
||||
assert(arg_status != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.');
|
||||
try {
|
||||
api.onStatusChanged(arg_status!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IPtProxyController {
|
||||
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<int> start(TransportType proxyType, String proxy) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as int?)!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop(TransportType proxyType) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/src/tor_api.g.dart',
|
||||
dartOptions: DartOptions(),
|
||||
kotlinOut:
|
||||
'android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'eu.weblibre.flutter_tor.generated'),
|
||||
),
|
||||
)
|
||||
/// Transport types for Tor connections
|
||||
enum TransportType {
|
||||
/// Direct Tor connection (no bridges)
|
||||
none,
|
||||
|
||||
/// obfs4 pluggable transport
|
||||
obfs4,
|
||||
|
||||
/// Snowflake pluggable transport (default broker)
|
||||
snowflake,
|
||||
|
||||
/// Snowflake via AMP cache
|
||||
snowflakeAmp,
|
||||
|
||||
/// Meek pluggable transport
|
||||
meek,
|
||||
|
||||
/// Meek via Azure CDN
|
||||
meekAzure,
|
||||
|
||||
/// WebTunnel pluggable transport
|
||||
webtunnel,
|
||||
|
||||
/// Custom bridge lines (passthrough)
|
||||
custom,
|
||||
}
|
||||
|
||||
/// Configuration for starting Tor
|
||||
class TorConfiguration {
|
||||
TorConfiguration({
|
||||
required this.transport,
|
||||
required this.bridgeLines,
|
||||
this.entryNodeCountries,
|
||||
this.exitNodeCountries,
|
||||
this.strictNodes,
|
||||
});
|
||||
|
||||
/// Transport type to use
|
||||
final TransportType transport;
|
||||
|
||||
/// Bridge lines for the transport (empty for direct connection)
|
||||
final List<String> bridgeLines;
|
||||
|
||||
/// Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl")
|
||||
final String? entryNodeCountries;
|
||||
|
||||
/// Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se")
|
||||
final String? exitNodeCountries;
|
||||
|
||||
/// If true, never use nodes outside specified countries
|
||||
final bool? strictNodes;
|
||||
}
|
||||
|
||||
/// Current Tor status
|
||||
class TorStatus {
|
||||
TorStatus({
|
||||
required this.isRunning,
|
||||
this.socksPort,
|
||||
required this.bootstrapProgress,
|
||||
this.currentCircuit,
|
||||
this.exitNodeCountry,
|
||||
});
|
||||
|
||||
/// Whether Tor is running
|
||||
final bool isRunning;
|
||||
|
||||
/// SOCKS proxy port (if running)
|
||||
final int? socksPort;
|
||||
|
||||
/// Bootstrap progress (0-100)
|
||||
final int bootstrapProgress;
|
||||
|
||||
/// Current circuit ID
|
||||
final String? currentCircuit;
|
||||
|
||||
/// Exit node country code
|
||||
final String? exitNodeCountry;
|
||||
}
|
||||
|
||||
/// Log message from Tor
|
||||
class TorLogMessage {
|
||||
TorLogMessage({
|
||||
required this.severity,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
/// Log severity (NOTICE, WARN, ERR, DEBUG)
|
||||
final String severity;
|
||||
|
||||
/// Log message
|
||||
final String message;
|
||||
|
||||
/// Timestamp (milliseconds since epoch)
|
||||
final int timestamp;
|
||||
}
|
||||
|
||||
/// Host API (Flutter -> Native)
|
||||
@HostApi()
|
||||
abstract class TorApi {
|
||||
/// Start Tor with the given configuration
|
||||
/// Returns a Future to avoid blocking the main thread
|
||||
@async
|
||||
int startTor(TorConfiguration config);
|
||||
|
||||
/// Stop Tor
|
||||
@async
|
||||
void stopTor();
|
||||
|
||||
/// Get current status
|
||||
TorStatus getStatus();
|
||||
|
||||
/// Request a new Tor identity (new circuit)
|
||||
void requestNewIdentity();
|
||||
}
|
||||
|
||||
/// Flutter API (Native -> Flutter)
|
||||
@FlutterApi()
|
||||
abstract class TorLogApi {
|
||||
/// Called when a log message is received
|
||||
void onLogMessage(TorLogMessage log);
|
||||
|
||||
/// Called when status changes
|
||||
void onStatusChanged(TorStatus status);
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class IPtProxyController {
|
||||
int start(TransportType proxyType, String proxy);
|
||||
void stop(TransportType proxyType);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: flutter_tor
|
||||
description: "A new Flutter plugin project."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lint: ^2.8.0
|
||||
pigeon: ^26.1.5
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
# This section identifies this Flutter project as a plugin project.
|
||||
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
|
||||
# which should be registered in the plugin registry. This is required for
|
||||
# using method channels.
|
||||
# The Android 'package' specifies package in which the registered class is.
|
||||
# This is required for using method channels on Android.
|
||||
# The 'ffiPlugin' specifies that native code should be built and bundled.
|
||||
# This is required for using `dart:ffi`.
|
||||
# All these are used by the tooling to maintain consistency when
|
||||
# adding or updating assets for this project.
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: eu.weblibre.flutter_tor
|
||||
pluginClass: FlutterTorPlugin
|
||||
|
||||
# To add assets to your plugin package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your plugin package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
Reference in New Issue
Block a user