This commit is contained in:
Fabian Freund
2026-01-08 08:58:42 +01:00
parent 63712edbc7
commit 6f4654e0be
75 changed files with 654677 additions and 369 deletions
@@ -212,10 +212,6 @@
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="dataSync" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -40,7 +40,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
(previous, next) async {
await ref
.read(torProxyRepositoryProvider.notifier)
.setProxyPort(next ?? -1);
.setProxyPort(next?.socksPort ?? -1);
},
onError: (error, stackTrace) {
logger.e(
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'9ded7af1d745e25c59e22edecea582e25230e5e7';
r'e4c5e35b9aab2aae60e3f09a99472f98a7beb69e';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
+177
View File
@@ -0,0 +1,177 @@
/*
* 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/>.
*/
import 'package:json_annotation/json_annotation.dart';
part 'moat.g.dart';
enum MoatTransportType {
obfs4('obfs4'),
snowflake('snowflake'),
meek('meek'),
meekAzure('meek-azure'),
webtunnel('webtunnel');
const MoatTransportType(this.value);
final String value;
@override
String toString() => value;
static MoatTransportType? fromString(String value) {
for (final transport in MoatTransportType.values) {
if (transport.value == value) return transport;
}
return null;
}
}
@JsonSerializable()
class SettingsRequest {
final String? country;
@JsonKey(toJson: _transportsToJson, fromJson: _transportsFromJson)
final List<MoatTransportType> transports;
const SettingsRequest({
this.country,
this.transports = const [
MoatTransportType.obfs4,
MoatTransportType.snowflake,
MoatTransportType.webtunnel,
],
});
static List<String> _transportsToJson(List<MoatTransportType> transports) =>
transports.map((t) => t.value).toList();
static List<MoatTransportType> _transportsFromJson(List<dynamic> json) => json
.cast<String>()
.map((s) => MoatTransportType.fromString(s))
.where((t) => t != null)
.cast<MoatTransportType>()
.toList();
factory SettingsRequest.fromJson(Map<String, dynamic> json) =>
_$SettingsRequestFromJson(json);
Map<String, dynamic> toJson() => _$SettingsRequestToJson(this);
}
@JsonSerializable()
class SettingsResponse {
final List<Setting>? settings;
final String? country;
final List<MoatError>? errors;
const SettingsResponse({this.settings, this.country, this.errors});
factory SettingsResponse.fromJson(Map<String, dynamic> json) =>
_$SettingsResponseFromJson(json);
Map<String, dynamic> toJson() => _$SettingsResponseToJson(this);
}
@JsonSerializable()
class Setting {
@JsonKey(name: 'bridges')
final Bridge bridge;
const Setting({required this.bridge});
factory Setting.fromJson(Map<String, dynamic> json) =>
_$SettingFromJson(json);
Map<String, dynamic> toJson() => _$SettingToJson(this);
}
@JsonSerializable()
class Bridge {
@JsonKey(toJson: _transportToJson, fromJson: _transportFromJson)
final MoatTransportType type;
final String source;
@JsonKey(name: 'bridge_strings')
final List<String>? bridges;
const Bridge({required this.type, required this.source, this.bridges});
static String _transportToJson(MoatTransportType transport) =>
transport.value;
static MoatTransportType _transportFromJson(dynamic json) =>
MoatTransportType.fromString(json as String)!;
factory Bridge.fromJson(Map<String, dynamic> json) => _$BridgeFromJson(json);
Map<String, dynamic> toJson() => _$BridgeToJson(this);
}
@JsonSerializable()
class MoatError implements Exception {
final String? id;
final String? type;
final String? version;
final int? code;
final String? status;
final String? detail;
const MoatError({
this.id,
this.type,
this.version,
this.code,
this.status,
this.detail,
});
factory MoatError.fromJson(Map<String, dynamic> json) =>
_$MoatErrorFromJson(json);
Map<String, dynamic> toJson() => _$MoatErrorToJson(this);
@override
String toString() {
if (detail != null && detail!.isNotEmpty) {
return detail!;
}
return '$code $status';
}
}
@JsonSerializable()
class BuiltInBridges {
final List<String> meek;
@JsonKey(name: 'meek-azure')
final List<String> meekAzure;
final List<String> obfs4;
final List<String> snowflake;
const BuiltInBridges({
required this.meek,
required this.meekAzure,
required this.obfs4,
required this.snowflake,
});
factory BuiltInBridges.fromJson(Map<String, dynamic> json) =>
_$BuiltInBridgesFromJson(json);
Map<String, dynamic> toJson() => _$BuiltInBridgesToJson(this);
}
@@ -0,0 +1,102 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'moat.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SettingsRequest _$SettingsRequestFromJson(Map<String, dynamic> json) =>
SettingsRequest(
country: json['country'] as String?,
transports: json['transports'] == null
? const [
MoatTransportType.obfs4,
MoatTransportType.snowflake,
MoatTransportType.webtunnel,
]
: SettingsRequest._transportsFromJson(json['transports'] as List),
);
Map<String, dynamic> _$SettingsRequestToJson(SettingsRequest instance) =>
<String, dynamic>{
'country': instance.country,
'transports': SettingsRequest._transportsToJson(instance.transports),
};
SettingsResponse _$SettingsResponseFromJson(Map<String, dynamic> json) =>
SettingsResponse(
settings: (json['settings'] as List<dynamic>?)
?.map((e) => Setting.fromJson(e as Map<String, dynamic>))
.toList(),
country: json['country'] as String?,
errors: (json['errors'] as List<dynamic>?)
?.map((e) => MoatError.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SettingsResponseToJson(SettingsResponse instance) =>
<String, dynamic>{
'settings': instance.settings?.map((e) => e.toJson()).toList(),
'country': instance.country,
'errors': instance.errors?.map((e) => e.toJson()).toList(),
};
Setting _$SettingFromJson(Map<String, dynamic> json) =>
Setting(bridge: Bridge.fromJson(json['bridges'] as Map<String, dynamic>));
Map<String, dynamic> _$SettingToJson(Setting instance) => <String, dynamic>{
'bridges': instance.bridge.toJson(),
};
Bridge _$BridgeFromJson(Map<String, dynamic> json) => Bridge(
type: Bridge._transportFromJson(json['type']),
source: json['source'] as String,
bridges: (json['bridge_strings'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$BridgeToJson(Bridge instance) => <String, dynamic>{
'type': Bridge._transportToJson(instance.type),
'source': instance.source,
'bridge_strings': instance.bridges,
};
MoatError _$MoatErrorFromJson(Map<String, dynamic> json) => MoatError(
id: json['id'] as String?,
type: json['type'] as String?,
version: json['version'] as String?,
code: (json['code'] as num?)?.toInt(),
status: json['status'] as String?,
detail: json['detail'] as String?,
);
Map<String, dynamic> _$MoatErrorToJson(MoatError instance) => <String, dynamic>{
'id': instance.id,
'type': instance.type,
'version': instance.version,
'code': instance.code,
'status': instance.status,
'detail': instance.detail,
};
BuiltInBridges _$BuiltInBridgesFromJson(Map<String, dynamic> json) =>
BuiltInBridges(
meek: (json['meek'] as List<dynamic>).map((e) => e as String).toList(),
meekAzure: (json['meek-azure'] as List<dynamic>)
.map((e) => e as String)
.toList(),
obfs4: (json['obfs4'] as List<dynamic>).map((e) => e as String).toList(),
snowflake: (json['snowflake'] as List<dynamic>)
.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$BuiltInBridgesToJson(BuiltInBridges instance) =>
<String, dynamic>{
'meek': instance.meek,
'meek-azure': instance.meekAzure,
'obfs4': instance.obfs4,
'snowflake': instance.snowflake,
};
@@ -23,8 +23,8 @@ import 'dart:io';
import 'package:flutter/services.dart' show rootBundle;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/tor/data/models/moat.dart';
part 'builtin_bridges.g.dart';
@@ -0,0 +1,99 @@
/*
* 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/>.
*/
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:weblibre/features/tor/data/models/moat.dart';
class MoatApi {
static const String _baseUrl =
'https://bridges.torproject.org/moat/circumvention/';
final http.Client _client;
MoatApi(this._client);
Future<SettingsResponse> settings([SettingsRequest? request]) async {
final response = await _post(
'settings',
request ?? const SettingsRequest(),
);
return SettingsResponse.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<SettingsResponse> defaults([SettingsRequest? request]) async {
final response = await _post(
'defaults',
request ?? const SettingsRequest(),
);
return SettingsResponse.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<Map<String, SettingsResponse>> map() async {
final response = await _get('map');
final Map<String, dynamic> data =
jsonDecode(response.body) as Map<String, dynamic>;
return data.map(
(key, value) => MapEntry(
key,
SettingsResponse.fromJson(value as Map<String, dynamic>),
),
);
}
Future<BuiltInBridges> builtin() async {
final response = await _get('builtin');
return BuiltInBridges.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<List<String>> countries() async {
final response = await _get('countries');
return List<String>.from(jsonDecode(response.body) as List);
}
Future<http.Response> _get(String endpoint) async {
final uri = Uri.parse('$_baseUrl$endpoint');
return await _client
.get(uri, headers: _headers)
.timeout(const Duration(seconds: 15));
}
Future<http.Response> _post(String endpoint, Object body) async {
final uri = Uri.parse('$_baseUrl$endpoint');
return await _client
.post(uri, headers: _headers, body: jsonEncode(body))
.timeout(const Duration(seconds: 15));
}
Map<String, String> get _headers => {
'Content-Type': 'application/vnd.api+json',
'Accept': 'application/vnd.api+json',
};
void dispose() {
_client.close();
}
}
@@ -0,0 +1,184 @@
/*
* 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/>.
*/
import 'dart:io';
import 'package:flutter_tor/flutter_tor.dart';
import 'package:http/io_client.dart';
import 'package:socks5_proxy/socks_client.dart';
import 'package:weblibre/features/tor/data/models/moat.dart';
import 'package:weblibre/features/tor/data/services/moat_api.dart';
const String _meekParameters =
'url=https://1723079976.rsc.cdn77.org;front=www.phpmyadmin.net';
const defaultBridges = [MoatTransportType.obfs4, MoatTransportType.snowflake];
/// Manages MOAT API connections with persistent proxy setup.
class MoatService {
MoatApi? _api;
/// Initializes the MeekLite proxy and MOAT API connection.
/// Must be called before using other methods.
Future<void> initialize() async {
if (_api != null) return;
final port = await IPtProxyController().start(TransportType.meek, "");
final httpClient = HttpClient();
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(
InternetAddress.loopbackIPv4,
port,
username: _meekParameters,
password: '\u0000',
),
]);
_api = MoatApi(IOClient(httpClient));
}
/// Disposes the API connection and stops the MeekLite proxy.
/// Should be called when done using the client.
Future<void> dispose() async {
if (_api == null) return;
_api!.dispose();
await IPtProxyController().stop(TransportType.meek);
_api = null;
}
/// Ensures the client is initialized before API calls.
void _ensureInitialized() {
if (_api == null) {
throw StateError(
'MoatClient must be initialized before use. Call initialize() first.',
);
}
}
/// Handles MOAT API errors and determines if PT is required.
bool _handleMoatErrors(List<MoatError>? errors) {
if (errors == null || errors.isEmpty) return false;
final error = errors.first;
if (error.code == 404 || error.code == 406) {
// 404: Needs transport, but not the available ones
// 406: No country from IP address
return true;
}
throw error;
}
/// Converts BuiltInBridges to Settings for requested transports.
static List<Setting> convertBuiltinToSettings(
BuiltInBridges builtinBridges, {
List<MoatTransportType> transports = defaultBridges,
}) {
final settings = <Setting>[];
for (final transport in transports) {
final bridgeStrings = switch (transport) {
MoatTransportType.obfs4 => builtinBridges.obfs4,
MoatTransportType.snowflake => builtinBridges.snowflake,
MoatTransportType.meek => builtinBridges.meek,
MoatTransportType.meekAzure => builtinBridges.meekAzure,
MoatTransportType.webtunnel => <String>[], // Not available in builtin
};
if (bridgeStrings.isNotEmpty) {
settings.add(
Setting(
bridge: Bridge(
type: transport,
source: 'builtin',
bridges: bridgeStrings,
),
),
);
}
}
return settings;
}
/// Gets built-in bridges from the MOAT service endpoint.
Future<BuiltInBridges> getBuiltinBridges({
List<MoatTransportType> transports = defaultBridges,
}) async {
_ensureInitialized();
final builtinBridges = await _api!.builtin();
return builtinBridges;
}
/// Tries to automatically configure Pluggable Transports.
Future<List<Setting>?> autoConf({
String? country,
bool cannotConnectWithoutPt = false,
List<MoatTransportType> transports = defaultBridges,
}) async {
_ensureInitialized();
bool localCannotConnectWithoutPt = cannotConnectWithoutPt;
final request = SettingsRequest(country: country, transports: transports);
var response = await _api!.settings(request);
if (_handleMoatErrors(response.errors)) {
localCannotConnectWithoutPt = true;
}
final hasSettings = response.settings?.isNotEmpty ?? false;
if (!hasSettings && !localCannotConnectWithoutPt) {
return null;
}
if (hasSettings) {
return response.settings;
}
response = await _api!.defaults(SettingsRequest(transports: transports));
return response.settings;
}
Future<List<Setting>?> getDefaultBridges({
List<MoatTransportType> transports = defaultBridges,
}) async {
_ensureInitialized();
final response = await _api!.defaults(
SettingsRequest(transports: transports),
);
return response.settings;
}
/// Gets the list of supported countries from the MOAT service.
Future<List<String>> getCountries() async {
_ensureInitialized();
return await _api!.countries();
}
/// Gets the country-to-settings map from the MOAT service.
Future<Map<String, SettingsResponse>> getMap() async {
_ensureInitialized();
return await _api!.map();
}
}
@@ -17,10 +17,11 @@
* 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/>.
*/
import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/tor/data/models/moat.dart';
import 'package:weblibre/features/tor/data/services/builtin_bridges.dart';
import 'package:weblibre/features/tor/data/services/moat_service.dart';
part 'builtin_bridges.g.dart';
@@ -18,107 +18,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_tor/flutter_tor.dart';
import 'package:nullability/nullability.dart';
import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers/lifecycle.dart';
import 'package:weblibre/features/tor/data/models/moat.dart';
import 'package:weblibre/features/tor/data/services/moat_service.dart';
import 'package:weblibre/features/tor/domain/repositories/builtin_bridges.dart';
import 'package:weblibre/features/tor/utils/tor_entrypoint.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
part 'tor_proxy.g.dart';
class _TorService {
final service = FlutterBackgroundService();
final _portSubject = BehaviorSubject<int?>.seeded(null);
ValueStream<int?> get portStream => _portSubject.stream;
Future<void> startOrReconfigure({
ProxyType? proxyType,
String? bridgeLines,
}) async {
await service.startService();
service.invoke('startOrReconfigure', {
if (proxyType != null) 'proxyType': proxyType.name,
if (bridgeLines != null) 'bridgeLines': bridgeLines,
});
}
Future<void> reconfigure({ProxyType? proxyType, String? bridgeLines}) async {
service.invoke('reconfigure', {
if (proxyType != null) 'proxyType': proxyType.name,
if (bridgeLines != null) 'bridgeLines': bridgeLines,
});
}
Future<void> requestSync() async {
service.invoke("sync");
}
Future<void> sendHeartbeat() async {
service.invoke("heartbeat");
}
Future<void> stop() async {
service.invoke("stop");
}
Future<void> initializeService() async {
await service.configure(
iosConfiguration: IosConfiguration(autoStart: false),
androidConfiguration: AndroidConfiguration(
autoStart: false,
onStart: onStart,
isForegroundMode: true,
autoStartOnBoot: false,
initialNotificationTitle: 'Tor Service',
initialNotificationContent: 'Running in background',
),
);
service.on('portUpdate').listen((data) {
final port = data!['port'] as int;
_portSubject.add((port > -1) ? port : null);
});
}
Future<void> dispose() async {
await stop();
await _portSubject.close();
}
}
@Riverpod(keepAlive: true)
class TorProxyService extends _$TorProxyService {
final _tor = _TorService();
final _tor = FlutterTor();
late StreamController<TorStatus> _statusSyncController;
Timer? _heartbeatUpdate;
//This is managed by widget state changes to resume a timer
bool _timerPaused = false;
Future<TorStatus> startOrReconfigure({
required bool reconfigureIfRunning,
}) async {
final currentStatus = await _tor.getStatus();
void _enableHeartbeatTimer() {
_heartbeatUpdate?.cancel();
_timerPaused = false;
_heartbeatUpdate = Timer.periodic(const Duration(seconds: 30), (
timer,
) async {
await _tor.sendHeartbeat();
});
}
Future<void> startOrReconfigure({bool forceReconnect = false}) async {
final currentPort = await requestSync();
if (currentPort == null || forceReconnect) {
if (!currentStatus.isRunning ||
currentStatus.socksPort == null ||
reconfigureIfRunning) {
state = const AsyncLoading();
final torSettings = await ref
@@ -153,10 +80,10 @@ class TorProxyService extends _$TorProxyService {
setting = config.mapNotNull(
(config) =>
config.firstWhereOrNull(
(setting) => setting.bridge.type == TransportType.obfs4,
(setting) => setting.bridge.type == MoatTransportType.obfs4,
) ??
config.firstWhereOrNull(
(setting) => setting.bridge.type == TransportType.snowflake,
(setting) => setting.bridge.type == MoatTransportType.snowflake,
),
);
} else if (torSettings.config != TorConnectionConfig.direct) {
@@ -186,8 +113,9 @@ class TorProxyService extends _$TorProxyService {
switch (torSettings.config) {
TorConnectionConfig.auto => throw UnimplementedError(),
TorConnectionConfig.direct => throw UnimplementedError(),
TorConnectionConfig.obfs4 => TransportType.obfs4,
TorConnectionConfig.snowflake => TransportType.snowflake,
TorConnectionConfig.obfs4 => MoatTransportType.obfs4,
TorConnectionConfig.snowflake =>
MoatTransportType.snowflake,
},
),
);
@@ -198,96 +126,44 @@ class TorProxyService extends _$TorProxyService {
}
}
if (setting != null) {
switch (setting.bridge.type) {
case TransportType.obfs4:
await _tor.startOrReconfigure(
proxyType: ProxyType.obfs4,
bridgeLines: setting.bridge.bridges?.join('\n'),
);
case TransportType.snowflake:
await _tor.startOrReconfigure(
proxyType: ProxyType.snowflake,
bridgeLines: setting.bridge.bridges?.join('\n'),
);
case TransportType.meek:
case TransportType.meekAzure:
case TransportType.webtunnel:
throw UnimplementedError();
}
} else {
await _tor.startOrReconfigure();
}
final config = TorConfiguration(
transport: switch (setting?.bridge.type) {
MoatTransportType.obfs4 => TransportType.obfs4,
MoatTransportType.snowflake => TransportType.snowflake,
MoatTransportType.meek => TransportType.meek,
MoatTransportType.meekAzure => TransportType.meekAzure,
MoatTransportType.webtunnel => TransportType.webtunnel,
null => TransportType.none,
},
bridgeLines: setting?.bridge.bridges ?? [],
);
_enableHeartbeatTimer();
await _tor.start(config);
return _tor.getStatus();
}
return currentStatus;
}
Future<int?> requestSync() async {
final nextPortUpdate = _tor.portStream.first;
await _tor.requestSync();
return nextPortUpdate;
Future<TorStatus> requestSync() async {
final status = await _tor.getStatus();
_statusSyncController.add(status);
return status;
}
Future<void> disconnect() async {
state = const AsyncLoading();
_heartbeatUpdate?.cancel();
_heartbeatUpdate = null;
_timerPaused = false;
await _tor.stop();
}
@override
Future<int?> build() async {
final portSub = _tor.portStream.distinct().listen((port) {
state = AsyncData(port);
});
await _tor.initializeService();
if (!ref.mounted) return null;
ref.listen(
fireImmediately: true,
browserViewLifecycleProvider,
(previous, next) {
switch (next) {
case AppLifecycleState.resumed:
if (_timerPaused) {
_enableHeartbeatTimer();
}
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case null:
if (_heartbeatUpdate?.isActive == true) {
_heartbeatUpdate?.cancel();
_timerPaused = true;
}
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to browserViewLifecycleProvider',
error: error,
stackTrace: stackTrace,
);
},
);
Stream<TorStatus> build() {
_statusSyncController = StreamController();
ref.onDispose(() async {
_heartbeatUpdate?.cancel();
_heartbeatUpdate = null;
_timerPaused = false;
await portSub.cancel();
await _tor.dispose();
await _statusSyncController.close();
await _tor.stop();
});
return _tor.portStream.valueOrNull;
return MergeStream([_tor.statusStream, _statusSyncController.stream]);
}
}
@@ -13,7 +13,7 @@ part of 'tor_proxy.dart';
final torProxyServiceProvider = TorProxyServiceProvider._();
final class TorProxyServiceProvider
extends $AsyncNotifierProvider<TorProxyService, int?> {
extends $StreamNotifierProvider<TorProxyService, TorStatus> {
TorProxyServiceProvider._()
: super(
from: null,
@@ -33,19 +33,19 @@ final class TorProxyServiceProvider
TorProxyService create() => TorProxyService();
}
String _$torProxyServiceHash() => r'c63adf96d7b0a865917b8d902071a0b3ae4dfcc6';
String _$torProxyServiceHash() => r'be69327bdef8feaa2a43bd83f20168988dcdd228';
abstract class _$TorProxyService extends $AsyncNotifier<int?> {
FutureOr<int?> build();
abstract class _$TorProxyService extends $StreamNotifier<TorStatus> {
Stream<TorStatus> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<int?>, int?>;
final ref = this.ref as $Ref<AsyncValue<TorStatus>, TorStatus>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<int?>, int?>,
AsyncValue<int?>,
AnyNotifier<AsyncValue<TorStatus>, TorStatus>,
AsyncValue<TorStatus>,
Object?,
Object?
>;
@@ -31,11 +31,11 @@ class StartProxyController extends _$StartProxyController {
// ignore: document_ignores is used for dialog
// ignore: avoid_build_context_in_providers
Future<void> maybeStartProxy(BuildContext context) async {
final torProxyRunning = await ref
final currentStatus = await ref
.read(torProxyServiceProvider.notifier)
.requestSync();
if (torProxyRunning == null) {
if (!currentStatus.isRunning) {
if (context.mounted) {
final result = await showDialog<bool>(
context: context,
@@ -47,7 +47,7 @@ class StartProxyController extends _$StartProxyController {
if (result == true) {
final connection = ref
.read(torProxyServiceProvider.notifier)
.startOrReconfigure();
.startOrReconfigure(reconfigureIfRunning: false);
ref
.read(overlayControllerProvider.notifier)
@@ -59,9 +59,6 @@ class StartProxyController extends _$StartProxyController {
await connection;
}
}
} else {
//Reconfigure
await ref.read(torProxyServiceProvider.notifier).startOrReconfigure();
}
}
@@ -42,7 +42,7 @@ final class StartProxyControllerProvider
}
String _$startProxyControllerHash() =>
r'86a015a0c257ea18ad23aa28bdd43d289b6bfc64';
r'13c6ad9611a2f9525689d370a0511274aa9c0f03';
abstract class _$StartProxyController extends $Notifier<void> {
void build();
@@ -19,6 +19,7 @@
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
@@ -34,7 +35,32 @@ class TorProxyScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final torProxyPort = ref.watch(torProxyServiceProvider);
final bootstrapProgress = ref.watch(
torProxyServiceProvider.select(
(value) => value.value?.bootstrapProgress ?? 0,
),
);
final torPendingRequest = useState<bool?>(null);
ref.listen(torProxyServiceProvider, (previous, next) {
if (next.hasValue && torPendingRequest.value != null) {
if (next.requireValue.isRunning != previous?.value?.isRunning ||
next.requireValue.bootstrapProgress !=
previous?.value?.bootstrapProgress) {
torPendingRequest.value = null;
}
}
});
final torIsRunning = ref.watch(
torProxyServiceProvider.select(
(value) => value.value?.isRunning ?? false,
),
);
final torIsBusy =
torPendingRequest.value != null ||
bootstrapProgress > 0 && bootstrapProgress < 100;
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
useOnInitialization(() async {
@@ -43,9 +69,10 @@ class TorProxyScreen extends HookConsumerWidget {
ref.listen(torSettingsRepositoryProvider, (previous, next) async {
final torService = ref.read(torProxyServiceProvider.notifier);
final currentStatus = await torService.requestSync();
if (await torService.requestSync() != null) {
await torService.startOrReconfigure();
if (currentStatus.isRunning) {
await torService.startOrReconfigure(reconfigureIfRunning: true);
}
});
@@ -110,17 +137,23 @@ class TorProxyScreen extends HookConsumerWidget {
}
return null; // Use the default color.
}),
value: torProxyPort.value != null,
value: torPendingRequest.value ?? torIsRunning,
title: const Text('Tor™ Proxy'),
secondary: const Icon(MdiIcons.power),
onChanged: torProxyPort.isLoading
onChanged: torIsBusy
? null
: (value) async {
if (value) {
torPendingRequest.value = true;
await ref
.read(torProxyServiceProvider.notifier)
.startOrReconfigure();
.startOrReconfigure(
reconfigureIfRunning: false,
);
} else {
torPendingRequest.value = false;
await ref
.read(torProxyServiceProvider.notifier)
.disconnect();
@@ -259,7 +292,7 @@ class TorProxyScreen extends HookConsumerWidget {
secondary: const Icon(
MdiIcons.arrowDecisionAuto,
),
onChanged: torProxyPort.isLoading
onChanged: torIsBusy
? null
: (value) async {
await ref
@@ -291,7 +324,7 @@ class TorProxyScreen extends HookConsumerWidget {
left: 56,
right: 24,
),
onChanged: torProxyPort.isLoading
onChanged: torIsBusy
? null
: (value) async {
await ref
@@ -330,7 +363,7 @@ class TorProxyScreen extends HookConsumerWidget {
children: [
RadioListTile.adaptive(
value: TorConnectionConfig.direct,
enabled: !torProxyPort.isLoading,
enabled: !torIsBusy,
contentPadding: const EdgeInsets.only(
left: 56,
right: 24,
@@ -342,7 +375,7 @@ class TorProxyScreen extends HookConsumerWidget {
),
RadioListTile.adaptive(
value: TorConnectionConfig.obfs4,
enabled: !torProxyPort.isLoading,
enabled: !torIsBusy,
contentPadding: const EdgeInsets.only(
left: 56,
right: 24,
@@ -354,7 +387,7 @@ class TorProxyScreen extends HookConsumerWidget {
),
RadioListTile.adaptive(
value: TorConnectionConfig.snowflake,
enabled: !torProxyPort.isLoading,
enabled: !torIsBusy,
contentPadding: const EdgeInsets.only(
left: 56,
right: 24,
@@ -378,7 +411,7 @@ class TorProxyScreen extends HookConsumerWidget {
left: 56,
right: 24,
),
onChanged: torProxyPort.isLoading
onChanged: torIsBusy
? null
: (value) async {
if (value != null) {
@@ -413,23 +446,24 @@ class TorProxyScreen extends HookConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (torProxyPort.isLoading)
const Column(
if (torIsBusy)
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 8),
const SizedBox(height: 8),
LinearProgressIndicator(
backgroundColor: AppColors.torBackgroundGrey,
color: AppColors.torActiveGreen,
value: bootstrapProgress / 100,
),
SizedBox(height: 8),
Text(
const SizedBox(height: 8),
const Text(
'Establishing connection...',
style: TextStyle(color: Colors.white),
),
],
),
if (torProxyPort.value != null)
)
else if (torIsRunning)
Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Row(
@@ -1,156 +0,0 @@
/*
* 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/>.
*/
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart';
import 'package:tor/tor.dart';
@pragma('vm:entry-point')
Future<void> onStart(ServiceInstance service) async {
Timer? timeout;
final startedProxyType = <ProxyType>{};
Tor.init();
final portSub = Tor.instance.events.stream.listen((port) {
service.invoke('portUpdate', {'port': port});
});
Future<void> stopProxies() async {
for (final proxyType in startedProxyType) {
await IPtProxyController().stop(proxyType);
}
startedProxyType.clear();
}
Future<void> stopService() async {
timeout?.cancel();
timeout = null;
Tor.instance.stop();
await stopProxies();
service.invoke('portUpdate', {'port': -1});
await portSub.cancel();
await service.stopSelf();
}
void addHeartbeat() {
timeout?.cancel();
//Kill service after 1 hour of inactivity
timeout = Timer(const Duration(hours: 1), () async {
// logger.i('Terminating tor due to timeout');
await stopService();
});
}
Future<void> startService(ProxyType? proxyType, String? bridgeLines) async {
if (proxyType != null) {
final port = await IPtProxyController().start(proxyType, "");
startedProxyType.add(proxyType);
switch (proxyType) {
case ProxyType.obfs4:
await Tor.instance.start(obfs4Port: port, bridgeLines: bridgeLines);
case ProxyType.meekLite:
throw UnimplementedError();
case ProxyType.webtunnel:
throw UnimplementedError();
case ProxyType.snowflake:
await Tor.instance.start(
snowflakePort: port,
bridgeLines: bridgeLines,
);
}
} else {
await Tor.instance.start();
}
}
Future<void> reconfigureService(
ProxyType? proxyType,
String? bridgeLines,
) async {
if (Tor.instance.hasClient) {
await stopProxies();
if (proxyType != null) {
final port = await IPtProxyController().start(proxyType, "");
startedProxyType.add(proxyType);
switch (proxyType) {
case ProxyType.obfs4:
await Tor.instance.reconfigure(
obfs4Port: port,
bridgeLines: bridgeLines,
);
case ProxyType.meekLite:
throw UnimplementedError();
case ProxyType.webtunnel:
throw UnimplementedError();
case ProxyType.snowflake:
await Tor.instance.reconfigure(
snowflakePort: port,
bridgeLines: bridgeLines,
);
}
} else {
await Tor.instance.reconfigure();
}
}
}
service.on("startOrReconfigure").listen((event) async {
final proxyType = ProxyType.values.firstWhereOrNull(
(x) => x.name == event?['proxyType'],
);
await reconfigureService(proxyType, event?['bridgeLines'] as String?);
if (!Tor.instance.hasClient) {
await startService(proxyType, event?['bridgeLines'] as String?);
}
});
service.on("reconfigure").listen((event) async {
final proxyType = ProxyType.values.firstWhereOrNull(
(x) => x.name == event?['proxyType'],
);
await reconfigureService(proxyType, event?['bridgeLines'] as String?);
});
service.on("heartbeat").listen((event) {
// logger.d('Received tor heartbeat');
addHeartbeat();
});
service.on("sync").listen((event) {
service.invoke('portUpdate', {'port': Tor.instance.port});
});
service.on("stop").listen((event) async {
await stopService();
});
}
@@ -93,7 +93,9 @@ Future<WebPageInfo> pageInfo(
(tabState.isPrivate == false &&
torSettings.proxyRegularTabsMode == TorRegularTabProxyMode.all) ||
(tabState.isPrivate == true && torSettings.proxyPrivateTabsTor)) {
proxyPort = await ref.read(torProxyServiceProvider.future);
proxyPort = await ref.read(
torProxyServiceProvider.selectAsync((value) => value.socksPort),
);
if (proxyPort == null) {
throw Exception('Could not proxy request');
@@ -162,7 +162,7 @@ final class PageInfoProvider
}
}
String _$pageInfoHash() => r'89b1cd86bb7ed7cfe507650cca33d6b6b72ca770';
String _$pageInfoHash() => r'19116a520df214240254f95989818c1ac3bde6f6';
final class PageInfoFamily extends $Family
with
+2 -5
View File
@@ -26,7 +26,6 @@ dependencies:
flutter:
sdk: flutter
flutter_auto_size_text: ^4.1.0
flutter_background_service: ^5.1.0
flutter_hooks: ^0.21.3+1
flutter_markdown: ^0.7.7+1
flutter_material_design_icons: ^1.1.7447
@@ -35,6 +34,8 @@ dependencies:
flutter_reorderable_grid_view: ^5.5.2
flutter_slidable: ^4.0.3
flutter_svg: ^2.2.3
flutter_tor:
path: ../packages/flutter_tor
go_router: ^17.0.1
google_fonts: ^6.3.3
graphview: ^1.5.1
@@ -58,8 +59,6 @@ dependencies:
path: ^1.9.1
path_provider: ^2.1.5
permission_handler: ^12.0.1
pluggable_transports_proxy:
path: ../packages/pluggable_transports_proxy
pretty_qr_code: ^3.5.0
quick_actions: ^1.1.0
riverpod: ^3.1.0
@@ -85,8 +84,6 @@ dependencies:
synchronized: ^3.4.0
text_scroll: ^0.2.1
timeago: ^3.7.1
tor:
path: ../packages/tor
uri_to_file:
git:
url: https://github.com/FaFre/uri-to-file.git
+33
View File
@@ -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/
+30
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+215
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
+80
View File
@@ -0,0 +1,80 @@
group = "eu.weblibre.flutter_tor"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.2.20"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "eu.weblibre.flutter_tor"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 24
}
dependencies {
// Tor core libraries
implementation("info.guardianproject:tor-android:0.4.8.21.1")
implementation("info.guardianproject:jtorctl:0.4.5.7")
// Pluggable transports
implementation("com.netzarchitekten:IPtProxy:4.3.0")
// Coroutines for async operations
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// Testing
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
@@ -0,0 +1 @@
rootProject.name = 'flutter_tor'
@@ -0,0 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.weblibre.flutter_tor">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Native TorService from tor-android library -->
<service
android:name="org.torproject.jni.TorService"
android:enabled="true"
android:exported="false" />
<!-- TorService - Foreground service for running Tor -->
<service
android:name=".TorService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Tor proxy service for anonymous networking" />
</service>
</application>
</manifest>
@@ -0,0 +1 @@
# GeoIP files will be extracted from tor-android library at runtime
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
package eu.weblibre.flutter_tor
/**
* Parses and validates bridge lines
*/
object BridgeParser {
/**
* Parse a bridge line and extract transport type
* Examples:
* "obfs4 192.0.2.4:443 cert=..."
* "snowflake 192.0.2.3:1 fingerprint=..."
* "webtunnel [2001:db8::1]:443 url=..."
*
* @param bridgeLine Bridge line to parse
* @return Transport type or null if invalid
*/
fun extractTransportType(bridgeLine: String): String? {
val trimmed = bridgeLine.trim()
if (trimmed.isEmpty()) return null
// Bridge line format: <transport> <address:port> [<key=value>...]
val parts = trimmed.split("\\s+".toRegex(), limit = 2)
if (parts.isEmpty()) return null
return parts[0].lowercase()
}
/**
* Validate if a bridge line is properly formatted
* @param bridgeLine Bridge line to validate
* @return true if valid
*/
fun isValid(bridgeLine: String): Boolean {
val trimmed = bridgeLine.trim()
if (trimmed.isEmpty()) return false
// Must have at least transport and address:port
val parts = trimmed.split("\\s+".toRegex())
if (parts.size < 2) return false
// Second part should contain a colon (address:port)
return parts[1].contains(":")
}
/**
* Normalize bridge lines (trim, remove empty lines)
* @param bridgeLines List of bridge lines
* @return Normalized list
*/
fun normalize(bridgeLines: List<String>): List<String> {
return bridgeLines
.map { it.trim() }
.filter { it.isNotEmpty() }
.filter { !it.startsWith("#") } // Remove comments
}
}
@@ -0,0 +1,200 @@
package eu.weblibre.flutter_tor
import IPtProxy.Controller
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorApi
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.embedding.engine.plugins.FlutterPlugin
import kotlinx.coroutines.*
import java.io.File
/**
* FlutterTorPlugin - Main plugin class
* Implements Pigeon-generated TorApi and manages TorService
*/
class FlutterTorPlugin : FlutterPlugin, TorApi {
companion object {
private const val TAG = "FlutterTorPlugin"
private const val SERVICE_CONNECTION_TIMEOUT_MS = 10000L
}
private var context: Context? = null
private var torService: TorService? = null
private var serviceConnection: ServiceConnection? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Service connection state
private var serviceConnected = CompletableDeferred<Unit>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onAttachedToEngine")
context = flutterPluginBinding.applicationContext
// Setup Pigeon API
TorApi.setUp(flutterPluginBinding.binaryMessenger, this)
// Bind to TorService
bindTorService(flutterPluginBinding)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "onDetachedFromEngine")
// Cleanup Pigeon API
TorApi.setUp(binding.binaryMessenger, null)
// Unbind service
unbindTorService()
// Cancel coroutines
scope.cancel()
context = null
}
/**
* Bind to TorService
*/
private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
val ctx = context ?: return
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "TorService connected")
val binder = service as? TorService.LocalBinder
torService = binder?.getService()
torService?.initialize(flutterPluginBinding.binaryMessenger)
// Signal that service is connected
serviceConnected.complete(Unit)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
torService = null
// Reset connection deferred for potential reconnection
serviceConnected = CompletableDeferred()
}
}
serviceConnection = connection
val intent = Intent(ctx, TorService::class.java)
intent.action = TorService.ACTION_START
ctx.startService(intent)
ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
/**
* Unbind from TorService
*/
private fun unbindTorService() {
serviceConnection?.let { conn ->
try {
context?.unbindService(conn)
} catch (e: Exception) {
Log.w(TAG, "Error unbinding service", e)
}
}
serviceConnection = null
torService = null
}
/**
* Wait for service to be connected
*/
private suspend fun waitForService(): TorService {
return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) {
serviceConnected.await()
torService
} ?: throw Exception("TorService connection timeout")
}
// ========== Pigeon TorApi Implementation ==========
// Note: These methods are now async with callbacks to avoid blocking the main thread
override fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit) {
Log.d(TAG, "startTor called with transport: ${config.transport}")
scope.launch {
try {
// Wait for service to be connected
val service = waitForService()
val socksPort = withContext(Dispatchers.IO) {
service.startTor(config)
}
val result = socksPort.toLong()
Log.d(TAG, "Returning SOCKS port to Flutter: $socksPort")
callback(Result.success(result))
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
callback(Result.failure(e))
}
}
}
override fun stopTor(callback: (Result<Unit>) -> Unit) {
Log.d(TAG, "stopTor called")
val service = torService
if (service == null) {
callback(Result.success(Unit))
return
}
scope.launch {
try {
withContext(Dispatchers.IO) {
service.stopTor()
}
callback(Result.success(Unit))
} catch (e: Exception) {
Log.e(TAG, "Failed to stop Tor", e)
callback(Result.failure(e))
}
}
}
override fun getStatus(): TorStatus {
val service = torService
?: return TorStatus(
isRunning = false,
socksPort = null,
bootstrapProgress = 0,
currentCircuit = null,
exitNodeCountry = null
)
return try {
service.getStatus()
} catch (e: Exception) {
Log.e(TAG, "Failed to get status", e)
throw e
}
}
override fun requestNewIdentity() {
Log.d(TAG, "requestNewIdentity called")
val service = torService
?: throw Exception("TorService not initialized")
try {
service.requestNewIdentity()
} catch (e: Exception) {
Log.e(TAG, "Failed to request new identity", e)
throw e
}
}
}
@@ -0,0 +1,76 @@
package eu.weblibre.flutter_tor
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileOutputStream
/**
* Manages GeoIP database files for country-based node selection
* GeoIP files are provided by the tor-android library
*/
class GeoIpManager(private val context: Context) {
companion object {
private const val TAG = "GeoIpManager"
private const val GEOIP_FILE = "geoip"
private const val GEOIP6_FILE = "geoip6"
}
/**
* Get the GeoIP file path, extracting from assets if necessary
* @param installDir Directory to install GeoIP files
* @return GeoIP file or null if not available
*/
fun getGeoIpFile(installDir: File): File? {
val geoipFile = File(installDir, GEOIP_FILE)
if (!geoipFile.exists()) {
extractAsset(GEOIP_FILE, geoipFile)
}
return if (geoipFile.exists()) geoipFile else null
}
/**
* Get the GeoIP6 file path, extracting from assets if necessary
* @param installDir Directory to install GeoIP files
* @return GeoIP6 file or null if not available
*/
fun getGeoIp6File(installDir: File): File? {
val geoip6File = File(installDir, GEOIP6_FILE)
if (!geoip6File.exists()) {
extractAsset(GEOIP6_FILE, geoip6File)
}
return if (geoip6File.exists()) geoip6File else null
}
/**
* Extract asset file to destination
* Note: tor-android library should provide these files in its assets
*/
private fun extractAsset(assetName: String, destFile: File) {
try {
context.assets.open(assetName).use { input ->
destFile.parentFile?.mkdirs()
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "Extracted $assetName to ${destFile.absolutePath}")
} catch (e: Exception) {
Log.w(TAG, "Could not extract $assetName from assets: ${e.message}")
// GeoIP files are optional - Tor will work without them
// but country-based node selection won't be available
}
}
/**
* Check if GeoIP files are available
* @param installDir Directory where GeoIP files should be
* @return true if both geoip and geoip6 exist
*/
fun areGeoIpFilesAvailable(installDir: File): Boolean {
val geoip = File(installDir, GEOIP_FILE)
val geoip6 = File(installDir, GEOIP6_FILE)
return geoip.exists() && geoip6.exists()
}
}
@@ -0,0 +1,112 @@
package eu.weblibre.flutter_tor
import android.os.Handler
import android.os.Looper
import android.util.Log
import eu.weblibre.flutter_tor.generated.TorLogApi
import eu.weblibre.flutter_tor.generated.TorLogMessage
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.plugin.common.BinaryMessenger
/**
* Handles streaming logs and status updates from Tor to Flutter
* All Flutter API calls are posted to the main thread to avoid threading issues
*/
class LogStreamHandler(messenger: BinaryMessenger) {
companion object {
private const val TAG = "LogStreamHandler"
}
private val torLogApi = TorLogApi(messenger)
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Send a log message to Flutter
* @param severity Log severity (NOTICE, WARN, ERR, DEBUG)
* @param message Log message
*/
fun sendLog(severity: String, message: String) {
mainHandler.post {
try {
val logMessage = TorLogMessage(
severity = severity,
message = message,
timestamp = System.currentTimeMillis()
)
torLogApi.onLogMessage(logMessage) { }
} catch (e: Exception) {
Log.e(TAG, "Error sending log to Flutter: ${e.message}", e)
}
}
}
/**
* Send status change to Flutter
* @param status Current Tor status
*/
fun sendStatusChange(status: TorStatus) {
Log.d(TorManager.Companion.TAG, "sendStatusChange() returning: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}")
mainHandler.post {
try {
torLogApi.onStatusChanged(status) { }
} catch (e: Exception) {
Log.e(TAG, "Error sending status to Flutter: ${e.message}", e)
}
}
}
/**
* Parse and send Tor control port event
* @param eventType Event type from TorControlConnection (e.g., "NOTICE", "WARN", "ERR", "CIRC", "BW")
* @param eventData Event data
*/
fun handleTorEvent(eventType: String, eventData: String) {
when (eventType) {
"NOTICE" -> sendLog("NOTICE", eventData)
"WARN" -> sendLog("WARN", eventData)
"ERR" -> sendLog("ERR", eventData)
"DEBUG" -> sendLog("DEBUG", eventData)
"INFO" -> sendLog("INFO", eventData)
// Don't log circuit/bandwidth events to UI, they're too verbose
"CIRC", "ORCONN", "BW", "STREAM", "ADDRMAP", "NEWDESC" -> {
// These are logged to logcat by TorManager for debugging,
// but not sent to Flutter UI
}
else -> {
// Unknown event types, log for debugging
sendLog("DEBUG", "$eventType: $eventData")
}
}
}
/**
* Helper to send notice logs
*/
fun notice(message: String) {
sendLog("NOTICE", message)
}
/**
* Helper to send warning logs
*/
fun warn(message: String) {
sendLog("WARN", message)
}
/**
* Helper to send error logs
*/
fun error(message: String) {
sendLog("ERR", message)
}
/**
* Helper to send debug logs
*/
fun debug(message: String) {
sendLog("DEBUG", message)
}
}
@@ -0,0 +1,200 @@
package eu.weblibre.flutter_tor
import android.content.Context
import android.util.Log
import IPtProxy.Controller
import IPtProxy.IPtProxy
import IPtProxy.OnTransportStopped
import java.io.File
/**
* Manages pluggable transports via IPtProxy
* Supports: obfs4, snowflake, meek, webtunnel
*/
class PluggableTransportManager(private val context: Context) {
companion object {
private const val TAG = "PTManager"
// Snowflake configuration
private const val SNOWFLAKE_BROKER = "https://snowflake-broker.torproject.net/"
private const val SNOWFLAKE_BROKER_AMP = "https://snowflake-broker.torproject.net.global.prod.fastly.net/"
private const val SNOWFLAKE_AMP_CACHE = "https://cdn.ampproject.org/"
private val SNOWFLAKE_FRONTS = listOf("foursquare.com", "github.githubassets.com")
private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com")
private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478"
}
private val stateDir = File(context.cacheDir, "iptproxy")
private val activeTransports = mutableSetOf<String>()
private val statusCallback = object : OnTransportStopped {
override fun stopped(name: String?, exception: Exception?) {
if (name != null) {
activeTransports.remove(name)
if (exception != null) {
Log.e(TAG, "$name stopped with error: ${exception.message}", exception)
} else {
Log.d(TAG, "$name stopped normally")
}
}
}
}
// Lazy singleton controller (like Orbot does)
val controller: Controller by lazy {
Controller(
stateDir.absolutePath,
true, // enableLogging
false, // unsafeLogging
"INFO", // logLevel
statusCallback
)
}
init {
stateDir.mkdirs()
}
/**
* Start pluggable transport for the given type
* @param type Transport type
* @return Map of transport name to port (e.g., {"obfs4": 12345})
*/
fun startTransport(type: TransportType): Map<String, Int> {
Log.d(TAG, "Starting transport: $type")
// Stop any currently running transports before starting new ones
stopAll()
val ports = mutableMapOf<String, Int>()
try {
when (type) {
TransportType.OBFS4 -> {
val transportName = IPtProxy.Obfs4
controller.start(transportName, null) // null = no proxy
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
}
TransportType.SNOWFLAKE -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = false)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
}
TransportType.SNOWFLAKE_AMP -> {
val transportName = IPtProxy.Snowflake
configureSnowflake(useAmp = true)
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName (AMP) started on port $port")
}
}
TransportType.MEEK, TransportType.MEEK_AZURE -> {
val transportName = IPtProxy.MeekLite
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
}
TransportType.WEBTUNNEL -> {
val transportName = IPtProxy.Webtunnel
controller.start(transportName, null)
activeTransports.add(transportName)
val port = controller.port(transportName)
if (port > 0) {
ports[transportName] = port.toInt()
Log.d(TAG, "$transportName started on port $port")
}
}
TransportType.NONE, TransportType.CUSTOM -> {
// No pluggable transport needed
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start transport $type: ${e.message}", e)
}
return ports
}
/**
* Configure Snowflake-specific settings
*/
private fun configureSnowflake(useAmp: Boolean) {
controller.snowflakeIceServers = SNOWFLAKE_ICE_SERVERS
if (useAmp) {
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER_AMP
controller.snowflakeFrontDomains = SNOWFLAKE_AMP_FRONTS.joinToString(",")
controller.snowflakeAmpCacheUrl = SNOWFLAKE_AMP_CACHE
} else {
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER
controller.snowflakeFrontDomains = SNOWFLAKE_FRONTS.joinToString(",")
controller.snowflakeAmpCacheUrl = ""
}
controller.snowflakeSqsUrl = ""
controller.snowflakeSqsCreds = ""
Log.d(TAG, "Configured Snowflake: broker=${controller.snowflakeBrokerUrl}, amp=$useAmp")
}
/**
* Stop all running pluggable transports
*/
fun stopAll() {
Log.d(TAG, "Stopping all transports")
// Stop each active transport
activeTransports.toList().forEach { transportName ->
try {
controller.stop(transportName)
Log.d(TAG, "Stopped transport: $transportName")
} catch (e: Exception) {
Log.w(TAG, "Error stopping $transportName: ${e.message}")
}
}
activeTransports.clear()
Log.d(TAG, "All transports stopped")
}
/**
* Get the port for a specific transport
* @param transportName Transport name (e.g., "obfs4", "snowflake")
* @return Port number or null
*/
fun getPort(transportName: String): Int? {
val port = controller.port(transportName)
return if (port > 0) port.toInt() else null
}
/**
* Check if a transport is currently running
*/
fun isRunning(): Boolean {
return activeTransports.isNotEmpty()
}
}
@@ -0,0 +1,32 @@
package eu.weblibre.flutter_tor
import java.net.ServerSocket
/**
* Manages random port allocation for Tor and pluggable transports
*/
object PortManager {
/**
* Find an available random port by binding to port 0
* @return Available port number
*/
fun findAvailablePort(): Int {
return ServerSocket(0).use { socket ->
socket.localPort
}
}
/**
* Check if a specific port is available
* @param port Port to check
* @return true if port is available
*/
fun isPortAvailable(port: Int): Boolean {
return try {
ServerSocket(port).use { true }
} catch (e: Exception) {
false
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2025 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.weblibre.flutter_tor
import IPtProxy.IPtProxy
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TransportType
class ProxyImpl(val controller: IPtProxy.Controller) : IPtProxyController {
override fun start(proxyType: TransportType, proxy: String): Long {
val type = when (proxyType) {
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
TransportType.MEEK -> IPtProxy.MeekLite
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
TransportType.OBFS4 -> IPtProxy.Obfs4
TransportType.NONE -> null
else -> {
throw Exception("Unsupported transport type")
}
}
controller.start(type, proxy)
return controller.port(type)
}
override fun stop(proxyType: TransportType) {
val type = when (proxyType) {
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
TransportType.MEEK -> IPtProxy.MeekLite
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
TransportType.OBFS4 -> IPtProxy.Obfs4
TransportType.NONE -> null
else -> {
throw Exception("Unsupported transport type")
}
}
controller.stop(type)
}
}
@@ -0,0 +1,207 @@
package eu.weblibre.flutter_tor
import eu.weblibre.flutter_tor.generated.TorConfiguration
import IPtProxy.IPtProxy
import java.io.File
/**
* Generates Tor configuration (torrc) based on user settings
*/
class TorConfig(private val config: TorConfiguration) {
/**
* Generate torrc file content
* @param socksPort SOCKS proxy port
* @param dataDir Tor data directory
* @param geoipFile GeoIP file (optional, for country selection)
* @param geoip6File GeoIP6 file (optional, for IPv6 country selection)
* @param transportPorts Map of transport name to port (from PluggableTransportManager)
* @return torrc content as string
*
* Note: ControlPort is NOT set in torrc. The tor-android library automatically
* uses ControlSocket (Unix domain socket) which is more secure than TCP ControlPort.
* See SECURITY_CONTROL_PORT.md for details.
*/
fun generateTorrc(
socksPort: Int,
dataDir: File,
geoipFile: File?,
geoip6File: File?,
transportPorts: Map<String, Int>
): String = buildString {
// Core Tor settings
append("# Generated torrc for flutter_tor\n")
append("SocksPort 127.0.0.1:$socksPort\n")
// ControlPort is NOT set - tor-android uses ControlSocket (Unix domain socket)
// This is more secure as it uses file permissions instead of TCP authentication
append("DataDirectory ${dataDir.absolutePath}\n")
append("\n")
// GeoIP files for country-based node selection
if (geoipFile != null && geoipFile.exists()) {
append("GeoIPFile ${geoipFile.absolutePath}\n")
}
if (geoip6File != null && geoip6File.exists()) {
append("GeoIPv6File ${geoip6File.absolutePath}\n")
}
append("\n")
// Entry node countries
config.entryNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("EntryNodes $formatted\n")
}
}
// Exit node countries
config.exitNodeCountries?.let { countries ->
if (countries.isNotBlank()) {
val formatted = formatCountries(countries)
append("ExitNodes $formatted\n")
}
}
// Strict nodes (only use specified countries)
if (config.strictNodes == true) {
append("StrictNodes 1\n")
}
append("\n")
// Pluggable transport configuration
val transport = TransportType.fromPigeon(config.transport)
when (transport) {
TransportType.OBFS4 -> {
transportPorts[IPtProxy.Obfs4]?.let { port ->
// Validate port is valid (like Orbot does)
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Obfs4} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.SNOWFLAKE, TransportType.SNOWFLAKE_AMP -> {
transportPorts[IPtProxy.Snowflake]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Snowflake} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.MEEK, TransportType.MEEK_AZURE -> {
transportPorts[IPtProxy.MeekLite]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.MeekLite} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.WEBTUNNEL -> {
transportPorts[IPtProxy.Webtunnel]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin ${IPtProxy.Webtunnel} socks5 127.0.0.1:$port\n")
}
}
}
TransportType.CUSTOM -> {
// Custom bridges - transport plugin defined in bridge line
// We'll try to detect and configure based on bridge lines
configureCustomTransports(transportPorts)
}
TransportType.NONE -> {
// Direct connection, no pluggable transports
}
}
append("\n")
// Bridge configuration
if (transport != TransportType.NONE) {
val normalizedBridges = BridgeParser.normalize(config.bridgeLines)
if (normalizedBridges.isNotEmpty()) {
append("UseBridges 1\n")
normalizedBridges.forEach { bridge ->
append("Bridge $bridge\n")
}
append("\n")
}
}
// Additional Tor settings (matching Orbot's configuration)
append("# Additional settings\n")
append("RunAsDaemon 1\n")
append("AvoidDiskWrites 1\n")
append("SafeSocks 0\n")
append("TestSocks 0\n")
append("VirtualAddrNetwork 10.192.0.0/10\n")
append("AutomapHostsOnResolve 1\n")
append("DormantClientTimeout 10 minutes\n")
append("DormantCanceledByStartup 1\n")
// Note: DisableNetwork is set to 1 in defaults.torrc
// It will be enabled via control port after setup completes (matching Orbot)
// We DON'T set it here to avoid overriding the defaults.torrc setting
append("DisableNetwork 0\n")
append("Log notice stdout\n") // Log to stdout for capture
append("\n")
}
/**
* Format country codes for Tor configuration
* Input: "de,fr,nl" or "{de},{fr},{nl}" or "de, fr, nl"
* Output: "{de},{fr},{nl}"
*/
private fun formatCountries(countries: String): String {
val codes = countries
.replace("{", "")
.replace("}", "")
.split(",")
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.filter { it.length == 2 } // ISO 3166-1 alpha-2 codes
return codes.joinToString(",") { "{$it}" }
}
/**
* Configure custom transports based on bridge lines
* Detects transport type from bridge lines and configures accordingly
*/
private fun StringBuilder.configureCustomTransports(transportPorts: Map<String, Int>) {
val bridgeTransports = config.bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
.distinct()
bridgeTransports.forEach { transportName ->
when (transportName) {
"obfs4" -> transportPorts[IPtProxy.Obfs4]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin obfs4 socks5 127.0.0.1:$port\n")
}
}
"snowflake" -> transportPorts[IPtProxy.Snowflake]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin snowflake socks5 127.0.0.1:$port\n")
}
}
"meek_lite" -> transportPorts[IPtProxy.MeekLite]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin meek_lite socks5 127.0.0.1:$port\n")
}
}
"webtunnel" -> transportPorts[IPtProxy.Webtunnel]?.let { port ->
if (port > 0) {
append("ClientTransportPlugin webtunnel socks5 127.0.0.1:$port\n")
}
}
}
}
}
/**
* Write torrc to file
* @param torrcFile File to write to
* @param content torrc content
*/
fun writeTorrc(torrcFile: File, content: String) {
torrcFile.parentFile?.mkdirs()
torrcFile.writeText(content)
}
}
@@ -0,0 +1,392 @@
package eu.weblibre.flutter_tor
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import net.freehaven.tor.control.RawEventListener
import net.freehaven.tor.control.TorControlCommands
import net.freehaven.tor.control.TorControlConnection
import org.torproject.jni.TorService
import java.io.File
/**
* Core Tor lifecycle manager
* Handles starting/stopping Tor, control port connection, and event listening
*/
class TorManager(
private val context: Context,
private val logHandler: LogStreamHandler
) {
companion object {
const val TAG = "TorManager"
}
private val dataDir = File(context.filesDir, "tor_data")
private val installDir = File(context.filesDir, "tor_install")
private var torServiceConnection: ServiceConnection? = null
private var controlConnection: TorControlConnection? = null
private var torService: TorService? = null
val pluggableTransportManager = PluggableTransportManager(context)
private val geoIpManager = GeoIpManager(context)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var socksPort: Int = -1
private set
// Note: No controlPort - tor-android uses ControlSocket (Unix domain socket) instead
// This is more secure than TCP ControlPort as it uses file permissions for access control
private var isRunning = false
private var bootstrapProgress = 0
/**
* Start Tor with the given configuration
* @param config Tor configuration from Flutter
* @return SOCKS port
*/
suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) {
if (isRunning) {
Log.w(TAG, "Tor already running")
return@withContext socksPort
}
try {
logHandler.notice("Starting Tor...")
// Create directories
dataDir.mkdirs()
installDir.mkdirs()
// Allocate random SOCKS port
// Note: We don't allocate a control port - tor-android uses ControlSocket instead
socksPort = PortManager.findAvailablePort()
Log.d(TAG, "Allocated SOCKS port: $socksPort")
Log.d(TAG, "Control connection will use ControlSocket (Unix domain socket)")
logHandler.notice("SOCKS port: $socksPort")
// Start pluggable transports if needed
val transport = TransportType.fromPigeon(config.transport)
val transportPorts = if (transport != TransportType.NONE && transport != TransportType.CUSTOM) {
logHandler.notice("Starting pluggable transport: $transport")
pluggableTransportManager.startTransport(transport)
} else if (transport == TransportType.CUSTOM) {
// For custom, we need to detect and start appropriate transports
startCustomTransports(config.bridgeLines)
} else {
emptyMap()
}
// Generate torrc
val geoipFile = geoIpManager.getGeoIpFile(installDir)
val geoip6File = geoIpManager.getGeoIp6File(installDir)
val torConfig = TorConfig(config)
val torrcContent = torConfig.generateTorrc(
socksPort = socksPort,
// controlPort removed - tor-android uses ControlSocket (Unix domain socket) for security
dataDir = dataDir,
geoipFile = geoipFile,
geoip6File = geoip6File,
transportPorts = transportPorts
)
// Write torrc to the correct location (like Orbot does)
// CRITICAL: Must use TorService.getTorrc() so TorService can find it!
val torrcFile = TorService.getTorrc(context)
torConfig.writeTorrc(torrcFile, torrcContent)
Log.d(TAG, "Generated torrc at ${torrcFile.absolutePath}:\n$torrcContent")
// Write defaults torrc (required by tor-android)
// Set DisableNetwork 1 initially like Orbot does, will be enabled via control port
// Also disable DNSPort and TransPort (matching Orbot)
val defaultsTorrcFile = TorService.getDefaultsTorrc(context)
defaultsTorrcFile.writeText("""
DNSPort 0
TransPort 0
DisableNetwork 1
""".trimIndent())
// Start TorService
// Note: torrcFile is now written to the correct location via TorService.getTorrc()
// so TorService will automatically find and use it
startTorService()
isRunning = true
logHandler.notice("Tor started successfully")
sendStatusUpdate()
socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
logHandler.error("Failed to start Tor: ${e.message}")
cleanup()
throw e
}
}
/**
* Start custom transports based on bridge lines
*/
private fun startCustomTransports(bridgeLines: List<String>): Map<String, Int> {
val transports = bridgeLines
.mapNotNull { BridgeParser.extractTransportType(it) }
.distinct()
val ports = mutableMapOf<String, Int>()
transports.forEach { transportName ->
val transportType = when (transportName) {
"obfs4" -> TransportType.OBFS4
"snowflake" -> TransportType.SNOWFLAKE
"meek_lite" -> TransportType.MEEK
"webtunnel" -> TransportType.WEBTUNNEL
else -> null
}
transportType?.let { type ->
ports.putAll(pluggableTransportManager.startTransport(type))
}
}
return ports
}
/**
* Start the native TorService and bind to it
* TorService will automatically use the torrc written to TorService.getTorrc(context)
*/
private suspend fun startTorService() = suspendCancellableCoroutine<Unit> { continuation ->
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "TorService connected")
val binder = service as? TorService.LocalBinder
torService = binder?.service
// Wait for control connection to be available
scope.launch {
var conn: TorControlConnection? = null
var attempts = 0
while (conn == null && attempts < 60) { // 30 seconds timeout
delay(500)
conn = torService?.torControlConnection
attempts++
}
if (conn != null) {
// Wait an additional second before setting up event listener
// This matches Orbot's behavior and ensures Tor is fully initialized
delay(1000)
controlConnection = conn
setupControlConnection(conn)
if (continuation.isActive) {
continuation.resume(Unit) {}
}
} else {
val error = Exception("Failed to get control connection after 30 seconds")
if (continuation.isActive) {
continuation.resumeWithException(error)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "TorService disconnected")
torService = null
controlConnection = null
}
}
torServiceConnection = connection
val intent = Intent(context, org.torproject.jni.TorService::class.java)
try {
// Start the service first (like Orbot does) before binding
context.startService(intent)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
/**
* Setup control connection and event listeners
*/
private fun setupControlConnection(conn: TorControlConnection) {
try {
// Add event listener
conn.addRawEventListener(TorEventListener())
// Subscribe to events (matching Orbot's event subscriptions)
conn.setEvents(listOf(
TorControlCommands.EVENT_OR_CONN_STATUS,
TorControlCommands.EVENT_CIRCUIT_STATUS,
TorControlCommands.EVENT_NOTICE_MSG,
TorControlCommands.EVENT_WARN_MSG,
TorControlCommands.EVENT_ERR_MSG,
TorControlCommands.EVENT_BANDWIDTH_USED,
TorControlCommands.EVENT_NEW_DESC,
TorControlCommands.EVENT_ADDRMAP
))
// Enable network now that configuration is complete (like Orbot does)
conn.setConf("DisableNetwork", "0")
Log.d(TAG, "Control connection setup complete")
logHandler.notice("Connected to Tor control port")
} catch (e: Exception) {
Log.e(TAG, "Failed to setup control connection", e)
logHandler.error("Control connection error: ${e.message}")
}
}
/**
* Stop Tor and cleanup
*/
suspend fun stop() = withContext(Dispatchers.IO) {
Log.d(TAG, "Stopping Tor")
logHandler.notice("Stopping Tor...")
try {
// Shutdown Tor gracefully
controlConnection?.shutdownTor("SHUTDOWN")
delay(1000) // Give Tor time to shutdown
cleanup()
logHandler.notice("Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Tor", e)
cleanup()
}
}
/**
* Cleanup resources
*/
private fun cleanup() {
isRunning = false
bootstrapProgress = 0
socksPort = -1
try {
controlConnection?.let {
// Don't shutdown again, just close
}
controlConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error closing control connection", e)
}
try {
torServiceConnection?.let {
context.unbindService(it)
}
torServiceConnection = null
} catch (e: Exception) {
Log.w(TAG, "Error unbinding TorService", e)
}
torService = null
pluggableTransportManager.stopAll()
sendStatusUpdate()
}
/**
* Request a new Tor identity (new circuit)
*/
fun requestNewIdentity() {
scope.launch {
try {
controlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
logHandler.notice("Requested new Tor identity")
} catch (e: Exception) {
Log.e(TAG, "Failed to request new identity", e)
logHandler.error("Failed to request new identity: ${e.message}")
}
}
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
val status = TorStatus(
isRunning = isRunning,
socksPort = if (isRunning) socksPort.toLong() else null,
bootstrapProgress = bootstrapProgress.toLong(),
currentCircuit = null, // TODO: track current circuit
exitNodeCountry = null // TODO: track exit node country
)
Log.d(TAG, "getStatus() returning: isRunning=$isRunning, socksPort=$socksPort, bootstrap=$bootstrapProgress")
// logHandler.sendStatusChange(status)
return status
}
/**
* Send status update to Flutter
*/
private fun sendStatusUpdate() {
logHandler.sendStatusChange(getStatus())
}
/**
* Event listener for Tor control port events
*/
private inner class TorEventListener : RawEventListener {
override fun onEvent(eventType: String, eventData: String) {
Log.d(TAG, "Tor event: $eventType - $eventData")
// Handle bootstrap progress (comes in NOTICE events)
if (eventData.contains("Bootstrapped")) {
val progress = extractBootstrapProgress(eventData)
if (progress >= 0) {
bootstrapProgress = progress
sendStatusUpdate()
if (progress == 100) {
logHandler.notice("Tor is ready!")
}
}
}
// Forward to log handler
logHandler.handleTorEvent(eventType, eventData)
}
private fun extractBootstrapProgress(eventData: String): Int {
// Extract from format like "Bootstrapped 85% (loading_descriptors): ..."
val regex = "Bootstrapped\\s+(\\d+)%".toRegex()
return regex.find(eventData)?.groupValues?.get(1)?.toIntOrNull() ?: -1
}
}
/**
* Cleanup when manager is destroyed
*/
fun destroy() {
scope.cancel()
runBlocking {
if (isRunning) {
stop()
}
}
}
}
@@ -0,0 +1,202 @@
package eu.weblibre.flutter_tor
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TorConfiguration
import eu.weblibre.flutter_tor.generated.TorStatus
import io.flutter.plugin.common.BinaryMessenger
import kotlinx.coroutines.*
/**
* Foreground service for running Tor in the background
* Keeps Tor running even when the app is backgrounded
*/
class TorService : Service() {
companion object {
private const val TAG = "TorService"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "flutter_tor_service"
const val ACTION_START = "eu.weblibre.flutter_tor.START"
const val ACTION_STOP = "eu.weblibre.flutter_tor.STOP"
const val EXTRA_CONFIG = "config"
}
private val binder = LocalBinder()
private var torManager: TorManager? = null
private var logHandler: LogStreamHandler? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
inner class LocalBinder : Binder() {
fun getService(): TorService = this@TorService
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: ${intent?.action}")
when (intent?.action) {
ACTION_START -> {
// Start in foreground immediately
startForeground(NOTIFICATION_ID, createNotification("Starting Tor..."))
// Actual start will be handled via binder methods
}
ACTION_STOP -> {
scope.launch {
stopTor()
stopSelf()
}
}
}
return START_STICKY
}
/**
* Initialize the service with Flutter messenger for log streaming
*/
fun initialize(messenger: BinaryMessenger) {
if (logHandler == null) {
logHandler = LogStreamHandler(messenger)
torManager = TorManager(applicationContext, logHandler!!)
IPtProxyController.setUp(
messenger,
ProxyImpl(controller = torManager!!.pluggableTransportManager.controller)
)
Log.d(TAG, "Service initialized with messenger")
}
}
/**
* Start Tor with configuration
*/
suspend fun startTor(config: TorConfiguration): Int {
Log.d(TAG, "Starting Tor...")
updateNotification("Starting Tor...")
val manager = torManager ?: throw IllegalStateException("Service not initialized")
try {
val socksPort = manager.start(config)
updateNotification("Tor is running (SOCKS: $socksPort)")
return socksPort
} catch (e: Exception) {
Log.e(TAG, "Failed to start Tor", e)
updateNotification("Failed to start Tor")
throw e
}
}
/**
* Stop Tor
*/
suspend fun stopTor() {
Log.d(TAG, "Stopping Tor...")
updateNotification("Stopping Tor...")
torManager?.stop()
updateNotification("Tor stopped")
}
/**
* Get current Tor status
*/
fun getStatus(): TorStatus {
return torManager?.getStatus() ?: TorStatus(
isRunning = false,
socksPort = null,
bootstrapProgress = 0,
currentCircuit = null,
exitNodeCountry = null
)
}
/**
* Request new Tor identity
*/
fun requestNewIdentity() {
torManager?.requestNewIdentity()
}
/**
* Update notification text
*/
private fun updateNotification(text: String) {
val notification = createNotification(text)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Create notification for foreground service
*/
private fun createNotification(text: String): Notification {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Tor Service")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Use custom icon
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
/**
* Create notification channel (required for Android 8+)
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Tor Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps Tor running in the background"
setShowBadge(false)
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
scope.launch {
torManager?.destroy()
}
scope.cancel()
}
}
@@ -0,0 +1,34 @@
package eu.weblibre.flutter_tor
/**
* Transport types for Tor connections
* Maps to Pigeon-generated enum
*/
enum class TransportType {
NONE, // Direct Tor connection (no bridges)
OBFS4, // obfs4 pluggable transport
SNOWFLAKE, // Snowflake (default broker)
SNOWFLAKE_AMP, // Snowflake via AMP cache
MEEK, // Meek pluggable transport
MEEK_AZURE, // Meek via Azure CDN
WEBTUNNEL, // WebTunnel pluggable transport
CUSTOM; // Custom bridge lines (passthrough)
companion object {
/**
* Convert from Pigeon-generated enum
*/
fun fromPigeon(pigeon: eu.weblibre.flutter_tor.generated.TransportType): TransportType {
return when (pigeon) {
eu.weblibre.flutter_tor.generated.TransportType.NONE -> NONE
eu.weblibre.flutter_tor.generated.TransportType.OBFS4 -> OBFS4
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE -> SNOWFLAKE
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE_AMP -> SNOWFLAKE_AMP
eu.weblibre.flutter_tor.generated.TransportType.MEEK -> MEEK
eu.weblibre.flutter_tor.generated.TransportType.MEEK_AZURE -> MEEK_AZURE
eu.weblibre.flutter_tor.generated.TransportType.WEBTUNNEL -> WEBTUNNEL
eu.weblibre.flutter_tor.generated.TransportType.CUSTOM -> CUSTOM
}
}
}
}
@@ -0,0 +1,497 @@
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package eu.weblibre.flutter_tor.generated
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object TorApiPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).contains(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Transport types for Tor connections */
enum class TransportType(val raw: Int) {
/** Direct Tor connection (no bridges) */
NONE(0),
/** obfs4 pluggable transport */
OBFS4(1),
/** Snowflake pluggable transport (default broker) */
SNOWFLAKE(2),
/** Snowflake via AMP cache */
SNOWFLAKE_AMP(3),
/** Meek pluggable transport */
MEEK(4),
/** Meek via Azure CDN */
MEEK_AZURE(5),
/** WebTunnel pluggable transport */
WEBTUNNEL(6),
/** Custom bridge lines (passthrough) */
CUSTOM(7);
companion object {
fun ofRaw(raw: Int): TransportType? {
return values().firstOrNull { it.raw == raw }
}
}
}
/**
* Configuration for starting Tor
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorConfiguration (
/** Transport type to use */
val transport: TransportType,
/** Bridge lines for the transport (empty for direct connection) */
val bridgeLines: List<String>,
/** Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl") */
val entryNodeCountries: String? = null,
/** Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se") */
val exitNodeCountries: String? = null,
/** If true, never use nodes outside specified countries */
val strictNodes: Boolean? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorConfiguration {
val transport = pigeonVar_list[0] as TransportType
val bridgeLines = pigeonVar_list[1] as List<String>
val entryNodeCountries = pigeonVar_list[2] as String?
val exitNodeCountries = pigeonVar_list[3] as String?
val strictNodes = pigeonVar_list[4] as Boolean?
return TorConfiguration(transport, bridgeLines, entryNodeCountries, exitNodeCountries, strictNodes)
}
}
fun toList(): List<Any?> {
return listOf(
transport,
bridgeLines,
entryNodeCountries,
exitNodeCountries,
strictNodes,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorConfiguration) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Current Tor status
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorStatus (
/** Whether Tor is running */
val isRunning: Boolean,
/** SOCKS proxy port (if running) */
val socksPort: Long? = null,
/** Bootstrap progress (0-100) */
val bootstrapProgress: Long,
/** Current circuit ID */
val currentCircuit: String? = null,
/** Exit node country code */
val exitNodeCountry: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorStatus {
val isRunning = pigeonVar_list[0] as Boolean
val socksPort = pigeonVar_list[1] as Long?
val bootstrapProgress = pigeonVar_list[2] as Long
val currentCircuit = pigeonVar_list[3] as String?
val exitNodeCountry = pigeonVar_list[4] as String?
return TorStatus(isRunning, socksPort, bootstrapProgress, currentCircuit, exitNodeCountry)
}
}
fun toList(): List<Any?> {
return listOf(
isRunning,
socksPort,
bootstrapProgress,
currentCircuit,
exitNodeCountry,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorStatus) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Log message from Tor
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class TorLogMessage (
/** Log severity (NOTICE, WARN, ERR, DEBUG) */
val severity: String,
/** Log message */
val message: String,
/** Timestamp (milliseconds since epoch) */
val timestamp: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): TorLogMessage {
val severity = pigeonVar_list[0] as String
val message = pigeonVar_list[1] as String
val timestamp = pigeonVar_list[2] as Long
return TorLogMessage(severity, message, timestamp)
}
}
fun toList(): List<Any?> {
return listOf(
severity,
message,
timestamp,
)
}
override fun equals(other: Any?): Boolean {
if (other !is TorLogMessage) {
return false
}
if (this === other) {
return true
}
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class TorApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
TransportType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorConfiguration.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorStatus.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TorLogMessage.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is TransportType -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is TorConfiguration -> {
stream.write(130)
writeValue(stream, value.toList())
}
is TorStatus -> {
stream.write(131)
writeValue(stream, value.toList())
}
is TorLogMessage -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/**
* Host API (Flutter -> Native)
*
* Generated interface from Pigeon that represents a handler of messages from Flutter.
*/
interface TorApi {
/**
* Start Tor with the given configuration
* Returns a Future to avoid blocking the main thread
*/
fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit)
/** Stop Tor */
fun stopTor(callback: (Result<Unit>) -> Unit)
/** Get current status */
fun getStatus(): TorStatus
/** Request a new Tor identity (new circuit) */
fun requestNewIdentity()
companion object {
/** The codec used by TorApi. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
/** Sets up an instance of `TorApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: TorApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.startTor$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val configArg = args[0] as TorConfiguration
api.startTor(configArg) { result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(TorApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(TorApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.stopTor$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.stopTor{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(TorApiPigeonUtils.wrapError(error))
} else {
reply.reply(TorApiPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.getStatus$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getStatus())
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.requestNewIdentity()
listOf(null)
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/**
* Flutter API (Native -> Flutter)
*
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
*/
class TorLogApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by TorLogApi. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
}
/** Called when a log message is received */
fun onLogMessage(logArg: TorLogMessage, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(logArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
}
}
}
/** Called when status changes */
fun onStatusChanged(statusArg: TorStatus, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(statusArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
}
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface IPtProxyController {
fun start(proxyType: TransportType, proxy: String): Long
fun stop(proxyType: TransportType)
companion object {
/** The codec used by IPtProxyController. */
val codec: MessageCodec<Any?> by lazy {
TorApiPigeonCodec()
}
/** Sets up an instance of `IPtProxyController` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: IPtProxyController?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.start$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyTypeArg = args[0] as TransportType
val proxyArg = args[1] as String
val wrapped: List<Any?> = try {
listOf(api.start(proxyTypeArg, proxyArg))
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyTypeArg = args[0] as TransportType
val wrapped: List<Any?> = try {
api.stop(proxyTypeArg)
listOf(null)
} catch (exception: Throwable) {
TorApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,27 @@
package eu.weblibre.flutter_tor
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito
import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class FlutterTorPluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = FlutterTorPlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}
+45
View File
@@ -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
+16
View File
@@ -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>
@@ -0,0 +1,5 @@
package eu.weblibre.flutter_tor_example
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -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")
+476
View File
@@ -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',
),
),
);
},
),
),
],
),
),
),
],
),
);
}
}
+92
View File
@@ -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,
);
});
}
+12
View File
@@ -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);
}
}
+538
View File
@@ -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;
}
}
}
+142
View File
@@ -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);
}
+71
View File
@@ -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
+2
View File
@@ -6,6 +6,8 @@ workspace:
- app/
- packages/flutter_mozilla_components
- packages/flutter_mozilla_components/example
- packages/flutter_tor
- packages/flutter_tor/example
- packages/simple_intent_receiver
- packages/simple_intent_receiver/example
- packages/locale_resolver