initial
This commit is contained in:
@@ -212,10 +212,6 @@
|
|||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name="id.flutter.flutter_background_service.BackgroundService"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(torProxyRepositoryProvider.notifier)
|
||||||
.setProxyPort(next ?? -1);
|
.setProxyPort(next?.socksPort ?? -1);
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
logger.e(
|
logger.e(
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$proxySettingsReplicationHash() =>
|
String _$proxySettingsReplicationHash() =>
|
||||||
r'9ded7af1d745e25c59e22edecea582e25230e5e7';
|
r'e4c5e35b9aab2aae60e3f09a99472f98a7beb69e';
|
||||||
|
|
||||||
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -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:flutter/services.dart' show rootBundle;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart' as path_provider;
|
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:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/tor/data/models/moat.dart';
|
||||||
|
|
||||||
part 'builtin_bridges.g.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
|
* 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/>.
|
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.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/builtin_bridges.dart';
|
||||||
|
import 'package:weblibre/features/tor/data/services/moat_service.dart';
|
||||||
|
|
||||||
part 'builtin_bridges.g.dart';
|
part 'builtin_bridges.g.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -18,107 +18,34 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
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:nullability/nullability.dart';
|
||||||
import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
import 'package:weblibre/core/logger.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/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/data/models/tor_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||||
|
|
||||||
part 'tor_proxy.g.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)
|
@Riverpod(keepAlive: true)
|
||||||
class TorProxyService extends _$TorProxyService {
|
class TorProxyService extends _$TorProxyService {
|
||||||
final _tor = _TorService();
|
final _tor = FlutterTor();
|
||||||
|
late StreamController<TorStatus> _statusSyncController;
|
||||||
|
|
||||||
Timer? _heartbeatUpdate;
|
Future<TorStatus> startOrReconfigure({
|
||||||
//This is managed by widget state changes to resume a timer
|
required bool reconfigureIfRunning,
|
||||||
bool _timerPaused = false;
|
}) async {
|
||||||
|
final currentStatus = await _tor.getStatus();
|
||||||
|
|
||||||
void _enableHeartbeatTimer() {
|
if (!currentStatus.isRunning ||
|
||||||
_heartbeatUpdate?.cancel();
|
currentStatus.socksPort == null ||
|
||||||
_timerPaused = false;
|
reconfigureIfRunning) {
|
||||||
_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) {
|
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
|
|
||||||
final torSettings = await ref
|
final torSettings = await ref
|
||||||
@@ -153,10 +80,10 @@ class TorProxyService extends _$TorProxyService {
|
|||||||
setting = config.mapNotNull(
|
setting = config.mapNotNull(
|
||||||
(config) =>
|
(config) =>
|
||||||
config.firstWhereOrNull(
|
config.firstWhereOrNull(
|
||||||
(setting) => setting.bridge.type == TransportType.obfs4,
|
(setting) => setting.bridge.type == MoatTransportType.obfs4,
|
||||||
) ??
|
) ??
|
||||||
config.firstWhereOrNull(
|
config.firstWhereOrNull(
|
||||||
(setting) => setting.bridge.type == TransportType.snowflake,
|
(setting) => setting.bridge.type == MoatTransportType.snowflake,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (torSettings.config != TorConnectionConfig.direct) {
|
} else if (torSettings.config != TorConnectionConfig.direct) {
|
||||||
@@ -186,8 +113,9 @@ class TorProxyService extends _$TorProxyService {
|
|||||||
switch (torSettings.config) {
|
switch (torSettings.config) {
|
||||||
TorConnectionConfig.auto => throw UnimplementedError(),
|
TorConnectionConfig.auto => throw UnimplementedError(),
|
||||||
TorConnectionConfig.direct => throw UnimplementedError(),
|
TorConnectionConfig.direct => throw UnimplementedError(),
|
||||||
TorConnectionConfig.obfs4 => TransportType.obfs4,
|
TorConnectionConfig.obfs4 => MoatTransportType.obfs4,
|
||||||
TorConnectionConfig.snowflake => TransportType.snowflake,
|
TorConnectionConfig.snowflake =>
|
||||||
|
MoatTransportType.snowflake,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -198,96 +126,44 @@ class TorProxyService extends _$TorProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setting != null) {
|
final config = TorConfiguration(
|
||||||
switch (setting.bridge.type) {
|
transport: switch (setting?.bridge.type) {
|
||||||
case TransportType.obfs4:
|
MoatTransportType.obfs4 => TransportType.obfs4,
|
||||||
await _tor.startOrReconfigure(
|
MoatTransportType.snowflake => TransportType.snowflake,
|
||||||
proxyType: ProxyType.obfs4,
|
MoatTransportType.meek => TransportType.meek,
|
||||||
bridgeLines: setting.bridge.bridges?.join('\n'),
|
MoatTransportType.meekAzure => TransportType.meekAzure,
|
||||||
);
|
MoatTransportType.webtunnel => TransportType.webtunnel,
|
||||||
case TransportType.snowflake:
|
null => TransportType.none,
|
||||||
await _tor.startOrReconfigure(
|
},
|
||||||
proxyType: ProxyType.snowflake,
|
bridgeLines: setting?.bridge.bridges ?? [],
|
||||||
bridgeLines: setting.bridge.bridges?.join('\n'),
|
);
|
||||||
);
|
|
||||||
case TransportType.meek:
|
|
||||||
case TransportType.meekAzure:
|
|
||||||
case TransportType.webtunnel:
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await _tor.startOrReconfigure();
|
|
||||||
}
|
|
||||||
|
|
||||||
_enableHeartbeatTimer();
|
await _tor.start(config);
|
||||||
|
return _tor.getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return currentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> requestSync() async {
|
Future<TorStatus> requestSync() async {
|
||||||
final nextPortUpdate = _tor.portStream.first;
|
final status = await _tor.getStatus();
|
||||||
|
_statusSyncController.add(status);
|
||||||
await _tor.requestSync();
|
return status;
|
||||||
return nextPortUpdate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
state = const AsyncLoading();
|
|
||||||
|
|
||||||
_heartbeatUpdate?.cancel();
|
|
||||||
_heartbeatUpdate = null;
|
|
||||||
_timerPaused = false;
|
|
||||||
|
|
||||||
await _tor.stop();
|
await _tor.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<int?> build() async {
|
Stream<TorStatus> build() {
|
||||||
final portSub = _tor.portStream.distinct().listen((port) {
|
_statusSyncController = StreamController();
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.onDispose(() async {
|
ref.onDispose(() async {
|
||||||
_heartbeatUpdate?.cancel();
|
await _statusSyncController.close();
|
||||||
_heartbeatUpdate = null;
|
await _tor.stop();
|
||||||
_timerPaused = false;
|
|
||||||
|
|
||||||
await portSub.cancel();
|
|
||||||
await _tor.dispose();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return _tor.portStream.valueOrNull;
|
return MergeStream([_tor.statusStream, _statusSyncController.stream]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ part of 'tor_proxy.dart';
|
|||||||
final torProxyServiceProvider = TorProxyServiceProvider._();
|
final torProxyServiceProvider = TorProxyServiceProvider._();
|
||||||
|
|
||||||
final class TorProxyServiceProvider
|
final class TorProxyServiceProvider
|
||||||
extends $AsyncNotifierProvider<TorProxyService, int?> {
|
extends $StreamNotifierProvider<TorProxyService, TorStatus> {
|
||||||
TorProxyServiceProvider._()
|
TorProxyServiceProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
@@ -33,19 +33,19 @@ final class TorProxyServiceProvider
|
|||||||
TorProxyService create() => TorProxyService();
|
TorProxyService create() => TorProxyService();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$torProxyServiceHash() => r'c63adf96d7b0a865917b8d902071a0b3ae4dfcc6';
|
String _$torProxyServiceHash() => r'be69327bdef8feaa2a43bd83f20168988dcdd228';
|
||||||
|
|
||||||
abstract class _$TorProxyService extends $AsyncNotifier<int?> {
|
abstract class _$TorProxyService extends $StreamNotifier<TorStatus> {
|
||||||
FutureOr<int?> build();
|
Stream<TorStatus> build();
|
||||||
@$mustCallSuper
|
@$mustCallSuper
|
||||||
@override
|
@override
|
||||||
void runBuild() {
|
void runBuild() {
|
||||||
final ref = this.ref as $Ref<AsyncValue<int?>, int?>;
|
final ref = this.ref as $Ref<AsyncValue<TorStatus>, TorStatus>;
|
||||||
final element =
|
final element =
|
||||||
ref.element
|
ref.element
|
||||||
as $ClassProviderElement<
|
as $ClassProviderElement<
|
||||||
AnyNotifier<AsyncValue<int?>, int?>,
|
AnyNotifier<AsyncValue<TorStatus>, TorStatus>,
|
||||||
AsyncValue<int?>,
|
AsyncValue<TorStatus>,
|
||||||
Object?,
|
Object?,
|
||||||
Object?
|
Object?
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ class StartProxyController extends _$StartProxyController {
|
|||||||
// ignore: document_ignores is used for dialog
|
// ignore: document_ignores is used for dialog
|
||||||
// ignore: avoid_build_context_in_providers
|
// ignore: avoid_build_context_in_providers
|
||||||
Future<void> maybeStartProxy(BuildContext context) async {
|
Future<void> maybeStartProxy(BuildContext context) async {
|
||||||
final torProxyRunning = await ref
|
final currentStatus = await ref
|
||||||
.read(torProxyServiceProvider.notifier)
|
.read(torProxyServiceProvider.notifier)
|
||||||
.requestSync();
|
.requestSync();
|
||||||
|
|
||||||
if (torProxyRunning == null) {
|
if (!currentStatus.isRunning) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -47,7 +47,7 @@ class StartProxyController extends _$StartProxyController {
|
|||||||
if (result == true) {
|
if (result == true) {
|
||||||
final connection = ref
|
final connection = ref
|
||||||
.read(torProxyServiceProvider.notifier)
|
.read(torProxyServiceProvider.notifier)
|
||||||
.startOrReconfigure();
|
.startOrReconfigure(reconfigureIfRunning: false);
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(overlayControllerProvider.notifier)
|
.read(overlayControllerProvider.notifier)
|
||||||
@@ -59,9 +59,6 @@ class StartProxyController extends _$StartProxyController {
|
|||||||
await connection;
|
await connection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
//Reconfigure
|
|
||||||
await ref.read(torProxyServiceProvider.notifier).startOrReconfigure();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ final class StartProxyControllerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$startProxyControllerHash() =>
|
String _$startProxyControllerHash() =>
|
||||||
r'86a015a0c257ea18ad23aa28bdd43d289b6bfc64';
|
r'13c6ad9611a2f9525689d370a0511274aa9c0f03';
|
||||||
|
|
||||||
abstract class _$StartProxyController extends $Notifier<void> {
|
abstract class _$StartProxyController extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
import 'package:fading_scroll/fading_scroll.dart';
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:flutter/material.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:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
@@ -34,7 +35,32 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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);
|
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
||||||
|
|
||||||
useOnInitialization(() async {
|
useOnInitialization(() async {
|
||||||
@@ -43,9 +69,10 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
ref.listen(torSettingsRepositoryProvider, (previous, next) async {
|
ref.listen(torSettingsRepositoryProvider, (previous, next) async {
|
||||||
final torService = ref.read(torProxyServiceProvider.notifier);
|
final torService = ref.read(torProxyServiceProvider.notifier);
|
||||||
|
final currentStatus = await torService.requestSync();
|
||||||
|
|
||||||
if (await torService.requestSync() != null) {
|
if (currentStatus.isRunning) {
|
||||||
await torService.startOrReconfigure();
|
await torService.startOrReconfigure(reconfigureIfRunning: true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,17 +137,23 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
return null; // Use the default color.
|
return null; // Use the default color.
|
||||||
}),
|
}),
|
||||||
value: torProxyPort.value != null,
|
value: torPendingRequest.value ?? torIsRunning,
|
||||||
title: const Text('Tor™ Proxy'),
|
title: const Text('Tor™ Proxy'),
|
||||||
secondary: const Icon(MdiIcons.power),
|
secondary: const Icon(MdiIcons.power),
|
||||||
onChanged: torProxyPort.isLoading
|
onChanged: torIsBusy
|
||||||
? null
|
? null
|
||||||
: (value) async {
|
: (value) async {
|
||||||
if (value) {
|
if (value) {
|
||||||
|
torPendingRequest.value = true;
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyServiceProvider.notifier)
|
.read(torProxyServiceProvider.notifier)
|
||||||
.startOrReconfigure();
|
.startOrReconfigure(
|
||||||
|
reconfigureIfRunning: false,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
torPendingRequest.value = false;
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyServiceProvider.notifier)
|
.read(torProxyServiceProvider.notifier)
|
||||||
.disconnect();
|
.disconnect();
|
||||||
@@ -259,7 +292,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
secondary: const Icon(
|
secondary: const Icon(
|
||||||
MdiIcons.arrowDecisionAuto,
|
MdiIcons.arrowDecisionAuto,
|
||||||
),
|
),
|
||||||
onChanged: torProxyPort.isLoading
|
onChanged: torIsBusy
|
||||||
? null
|
? null
|
||||||
: (value) async {
|
: (value) async {
|
||||||
await ref
|
await ref
|
||||||
@@ -291,7 +324,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
left: 56,
|
left: 56,
|
||||||
right: 24,
|
right: 24,
|
||||||
),
|
),
|
||||||
onChanged: torProxyPort.isLoading
|
onChanged: torIsBusy
|
||||||
? null
|
? null
|
||||||
: (value) async {
|
: (value) async {
|
||||||
await ref
|
await ref
|
||||||
@@ -330,7 +363,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
RadioListTile.adaptive(
|
RadioListTile.adaptive(
|
||||||
value: TorConnectionConfig.direct,
|
value: TorConnectionConfig.direct,
|
||||||
enabled: !torProxyPort.isLoading,
|
enabled: !torIsBusy,
|
||||||
contentPadding: const EdgeInsets.only(
|
contentPadding: const EdgeInsets.only(
|
||||||
left: 56,
|
left: 56,
|
||||||
right: 24,
|
right: 24,
|
||||||
@@ -342,7 +375,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
RadioListTile.adaptive(
|
RadioListTile.adaptive(
|
||||||
value: TorConnectionConfig.obfs4,
|
value: TorConnectionConfig.obfs4,
|
||||||
enabled: !torProxyPort.isLoading,
|
enabled: !torIsBusy,
|
||||||
contentPadding: const EdgeInsets.only(
|
contentPadding: const EdgeInsets.only(
|
||||||
left: 56,
|
left: 56,
|
||||||
right: 24,
|
right: 24,
|
||||||
@@ -354,7 +387,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
RadioListTile.adaptive(
|
RadioListTile.adaptive(
|
||||||
value: TorConnectionConfig.snowflake,
|
value: TorConnectionConfig.snowflake,
|
||||||
enabled: !torProxyPort.isLoading,
|
enabled: !torIsBusy,
|
||||||
contentPadding: const EdgeInsets.only(
|
contentPadding: const EdgeInsets.only(
|
||||||
left: 56,
|
left: 56,
|
||||||
right: 24,
|
right: 24,
|
||||||
@@ -378,7 +411,7 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
left: 56,
|
left: 56,
|
||||||
right: 24,
|
right: 24,
|
||||||
),
|
),
|
||||||
onChanged: torProxyPort.isLoading
|
onChanged: torIsBusy
|
||||||
? null
|
? null
|
||||||
: (value) async {
|
: (value) async {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -413,23 +446,24 @@ class TorProxyScreen extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (torProxyPort.isLoading)
|
if (torIsBusy)
|
||||||
const Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
backgroundColor: AppColors.torBackgroundGrey,
|
backgroundColor: AppColors.torBackgroundGrey,
|
||||||
color: AppColors.torActiveGreen,
|
color: AppColors.torActiveGreen,
|
||||||
|
value: bootstrapProgress / 100,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
const Text(
|
||||||
'Establishing connection...',
|
'Establishing connection...',
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
if (torProxyPort.value != null)
|
else if (torIsRunning)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 24.0),
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
child: Row(
|
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 &&
|
(tabState.isPrivate == false &&
|
||||||
torSettings.proxyRegularTabsMode == TorRegularTabProxyMode.all) ||
|
torSettings.proxyRegularTabsMode == TorRegularTabProxyMode.all) ||
|
||||||
(tabState.isPrivate == true && torSettings.proxyPrivateTabsTor)) {
|
(tabState.isPrivate == true && torSettings.proxyPrivateTabsTor)) {
|
||||||
proxyPort = await ref.read(torProxyServiceProvider.future);
|
proxyPort = await ref.read(
|
||||||
|
torProxyServiceProvider.selectAsync((value) => value.socksPort),
|
||||||
|
);
|
||||||
|
|
||||||
if (proxyPort == null) {
|
if (proxyPort == null) {
|
||||||
throw Exception('Could not proxy request');
|
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
|
final class PageInfoFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
+2
-5
@@ -26,7 +26,6 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_auto_size_text: ^4.1.0
|
flutter_auto_size_text: ^4.1.0
|
||||||
flutter_background_service: ^5.1.0
|
|
||||||
flutter_hooks: ^0.21.3+1
|
flutter_hooks: ^0.21.3+1
|
||||||
flutter_markdown: ^0.7.7+1
|
flutter_markdown: ^0.7.7+1
|
||||||
flutter_material_design_icons: ^1.1.7447
|
flutter_material_design_icons: ^1.1.7447
|
||||||
@@ -35,6 +34,8 @@ dependencies:
|
|||||||
flutter_reorderable_grid_view: ^5.5.2
|
flutter_reorderable_grid_view: ^5.5.2
|
||||||
flutter_slidable: ^4.0.3
|
flutter_slidable: ^4.0.3
|
||||||
flutter_svg: ^2.2.3
|
flutter_svg: ^2.2.3
|
||||||
|
flutter_tor:
|
||||||
|
path: ../packages/flutter_tor
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
google_fonts: ^6.3.3
|
google_fonts: ^6.3.3
|
||||||
graphview: ^1.5.1
|
graphview: ^1.5.1
|
||||||
@@ -58,8 +59,6 @@ dependencies:
|
|||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
pluggable_transports_proxy:
|
|
||||||
path: ../packages/pluggable_transports_proxy
|
|
||||||
pretty_qr_code: ^3.5.0
|
pretty_qr_code: ^3.5.0
|
||||||
quick_actions: ^1.1.0
|
quick_actions: ^1.1.0
|
||||||
riverpod: ^3.1.0
|
riverpod: ^3.1.0
|
||||||
@@ -85,8 +84,6 @@ dependencies:
|
|||||||
synchronized: ^3.4.0
|
synchronized: ^3.4.0
|
||||||
text_scroll: ^0.2.1
|
text_scroll: ^0.2.1
|
||||||
timeago: ^3.7.1
|
timeago: ^3.7.1
|
||||||
tor:
|
|
||||||
path: ../packages/tor
|
|
||||||
uri_to_file:
|
uri_to_file:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/FaFre/uri-to-file.git
|
url: https://github.com/FaFre/uri-to-file.git
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||||
|
/pubspec.lock
|
||||||
|
**/doc/api/
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: plugin
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||||
|
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||||
|
- platform: android
|
||||||
|
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||||
|
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
* TODO: Describe initial release.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
TODO: Add your license here.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# flutter_tor
|
||||||
|
|
||||||
|
A Flutter plugin for running Tor with pluggable transports on Android. This plugin provides a simple SOCKS5 proxy interface to the Tor network with support for multiple transport types and country-based node selection.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multiple Transport Types**: Direct connection, obfs4, Snowflake, Meek, WebTunnel, and custom bridges
|
||||||
|
- **SOCKS5 Proxy**: Returns a random local port for SOCKS5 connections
|
||||||
|
- **Country Selection**: Configure entry and exit node countries
|
||||||
|
- **Background Service**: Keeps Tor running even when app is backgrounded
|
||||||
|
- **Log Streaming**: Real-time log messages from Tor
|
||||||
|
- **Bootstrap Progress**: Track connection progress (0-100%)
|
||||||
|
- **New Identity**: Request new Tor circuits on demand
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
- ✅ Android
|
||||||
|
- ❌ iOS (not yet implemented)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your `pubspec.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter_tor:
|
||||||
|
path: ../flutter_tor # Update path as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_tor/flutter_tor.dart';
|
||||||
|
|
||||||
|
final tor = FlutterTor();
|
||||||
|
|
||||||
|
// Start Tor with direct connection
|
||||||
|
final result = await tor.start(TorConfiguration(
|
||||||
|
transport: TransportType.none,
|
||||||
|
bridgeLines: [],
|
||||||
|
));
|
||||||
|
|
||||||
|
print('SOCKS proxy running on port ${result.socksPort}');
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Snowflake Bridge
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final result = await tor.start(TorConfiguration(
|
||||||
|
transport: TransportType.snowflake,
|
||||||
|
bridgeLines: [
|
||||||
|
'snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F5...',
|
||||||
|
],
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Country Selection
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final result = await tor.start(TorConfiguration(
|
||||||
|
transport: TransportType.obfs4,
|
||||||
|
bridgeLines: ['obfs4 ...'],
|
||||||
|
entryNodeCountries: 'de,fr,nl', // Entry via Germany, France, or Netherlands
|
||||||
|
exitNodeCountries: 'ch,is', // Exit via Switzerland or Iceland
|
||||||
|
strictNodes: false, // Allow fallback if specified countries unavailable
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listening to Logs
|
||||||
|
|
||||||
|
```dart
|
||||||
|
tor.logStream.listen((log) {
|
||||||
|
print('[${log.severity}] ${log.message}');
|
||||||
|
});
|
||||||
|
|
||||||
|
tor.bootstrapProgressStream.listen((progress) {
|
||||||
|
print('Bootstrap: $progress%');
|
||||||
|
});
|
||||||
|
|
||||||
|
tor.statusStream.listen((status) {
|
||||||
|
print('Tor running: ${status.isRunning}');
|
||||||
|
print('SOCKS port: ${status.socksPort}');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop/Restart with Different Config
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Stop Tor
|
||||||
|
await tor.stop();
|
||||||
|
|
||||||
|
// Start again with different config
|
||||||
|
await tor.start(TorConfiguration(
|
||||||
|
transport: TransportType.meek,
|
||||||
|
bridgeLines: ['meek_lite ...'],
|
||||||
|
exitNodeCountries: 'se,no',
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request New Identity
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Get a new Tor circuit
|
||||||
|
await tor.requestNewIdentity();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transport Types
|
||||||
|
|
||||||
|
| Transport | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `none` | Direct Tor connection (no bridges) |
|
||||||
|
| `obfs4` | obfs4 pluggable transport |
|
||||||
|
| `snowflake` | Snowflake (default broker) |
|
||||||
|
| `snowflakeAmp` | Snowflake via AMP cache |
|
||||||
|
| `meek` | Meek pluggable transport |
|
||||||
|
| `meekAzure` | Meek via Azure CDN |
|
||||||
|
| `webtunnel` | WebTunnel pluggable transport |
|
||||||
|
| `custom` | Custom bridge lines (auto-detected) |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The plugin requires the following Android permissions (automatically added):
|
||||||
|
|
||||||
|
- `INTERNET` - Network access
|
||||||
|
- `ACCESS_NETWORK_STATE` - Network state detection
|
||||||
|
- `FOREGROUND_SERVICE` - Keep Tor running in background
|
||||||
|
- `FOREGROUND_SERVICE_SPECIAL_USE` - Android 14+ requirement
|
||||||
|
- `POST_NOTIFICATIONS` - Android 13+ for foreground service notification
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This plugin is a simplified version of Orbot, extracting only the core Tor + Pluggable Transport functionality:
|
||||||
|
|
||||||
|
- **No VPN mode** - Only SOCKS5 proxy
|
||||||
|
- **No per-app routing** - Use the SOCKS proxy directly
|
||||||
|
- **Foreground Service** - Keeps Tor running with a notification
|
||||||
|
- **Pigeon Communication** - Type-safe Flutter ↔ Native communication
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. Clone with submodules:
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://github.com/yourusername/orbot
|
||||||
|
cd orbot/flutter_tor
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Generate Pigeon code:
|
||||||
|
```bash
|
||||||
|
flutter pub run pigeon --input pigeons/tor_api.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd example
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **tor-android** (0.4.8.21.1) - Native Tor binaries
|
||||||
|
- **jtorctl** (0.4.5.7) - Tor control protocol
|
||||||
|
- **IPtProxy** (4.3.0) - Pluggable transports (obfs4, snowflake, meek, webtunnel)
|
||||||
|
- **Pigeon** (22.6.3) - Flutter ↔ Native communication
|
||||||
|
|
||||||
|
## Size Impact
|
||||||
|
|
||||||
|
- APK size increase: ~18-22MB (Tor binaries + IPtProxy for all ABIs)
|
||||||
|
- Supports: armeabi-v7a, arm64-v8a, x86, x86_64
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Android only (iOS not implemented)
|
||||||
|
- No VPN mode (SOCKS5 proxy only)
|
||||||
|
- No HTTP proxy (SOCKS5 only)
|
||||||
|
- GeoIP files may not be available (country selection optional)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tor fails to start
|
||||||
|
|
||||||
|
- Check logStream for error messages
|
||||||
|
- Ensure bridge lines are valid for the selected transport
|
||||||
|
- Verify network connectivity
|
||||||
|
- Try with TransportType.none first
|
||||||
|
|
||||||
|
### Country selection not working
|
||||||
|
|
||||||
|
- GeoIP files must be available (check logs)
|
||||||
|
- Country codes must be ISO 3166-1 alpha-2 (e.g., "US", "DE")
|
||||||
|
- Use strictNodes: false to allow fallback
|
||||||
|
|
||||||
|
### App crashes on startup
|
||||||
|
|
||||||
|
- Ensure all submodules are initialized: `git submodule update --init --recursive`
|
||||||
|
- Check Android Studio build output for missing dependencies
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This plugin is part of the Orbot project. See the main repository for contribution guidelines.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright © 2009-2025, Nathan Freitas, The Guardian Project
|
||||||
|
|
||||||
|
See LICENSE file for details.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/libraries
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.cxx
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
group = "eu.weblibre.flutter_tor"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
ext.kotlin_version = "2.2.20"
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:8.11.1")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
|
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: "com.android.library"
|
||||||
|
apply plugin: "kotlin-android"
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "eu.weblibre.flutter_tor"
|
||||||
|
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += "src/main/kotlin"
|
||||||
|
test.java.srcDirs += "src/test/kotlin"
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Tor core libraries
|
||||||
|
implementation("info.guardianproject:tor-android:0.4.8.21.1")
|
||||||
|
implementation("info.guardianproject:jtorctl:0.4.5.7")
|
||||||
|
|
||||||
|
// Pluggable transports
|
||||||
|
implementation("com.netzarchitekten:IPtProxy:4.3.0")
|
||||||
|
|
||||||
|
// Coroutines for async operations
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||||
|
testImplementation("org.mockito:mockito-core:5.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
useJUnitPlatform()
|
||||||
|
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||||
|
outputs.upToDateWhen {false}
|
||||||
|
showStandardStreams = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'flutter_tor'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="eu.weblibre.flutter_tor">
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Native TorService from tor-android library -->
|
||||||
|
<service
|
||||||
|
android:name="org.torproject.jni.TorService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- TorService - Foreground service for running Tor -->
|
||||||
|
<service
|
||||||
|
android:name=".TorService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Tor proxy service for anonymous networking" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# GeoIP files will be extracted from tor-android library at runtime
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates bridge lines
|
||||||
|
*/
|
||||||
|
object BridgeParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a bridge line and extract transport type
|
||||||
|
* Examples:
|
||||||
|
* "obfs4 192.0.2.4:443 cert=..."
|
||||||
|
* "snowflake 192.0.2.3:1 fingerprint=..."
|
||||||
|
* "webtunnel [2001:db8::1]:443 url=..."
|
||||||
|
*
|
||||||
|
* @param bridgeLine Bridge line to parse
|
||||||
|
* @return Transport type or null if invalid
|
||||||
|
*/
|
||||||
|
fun extractTransportType(bridgeLine: String): String? {
|
||||||
|
val trimmed = bridgeLine.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
|
||||||
|
// Bridge line format: <transport> <address:port> [<key=value>...]
|
||||||
|
val parts = trimmed.split("\\s+".toRegex(), limit = 2)
|
||||||
|
if (parts.isEmpty()) return null
|
||||||
|
|
||||||
|
return parts[0].lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a bridge line is properly formatted
|
||||||
|
* @param bridgeLine Bridge line to validate
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
fun isValid(bridgeLine: String): Boolean {
|
||||||
|
val trimmed = bridgeLine.trim()
|
||||||
|
if (trimmed.isEmpty()) return false
|
||||||
|
|
||||||
|
// Must have at least transport and address:port
|
||||||
|
val parts = trimmed.split("\\s+".toRegex())
|
||||||
|
if (parts.size < 2) return false
|
||||||
|
|
||||||
|
// Second part should contain a colon (address:port)
|
||||||
|
return parts[1].contains(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize bridge lines (trim, remove empty lines)
|
||||||
|
* @param bridgeLines List of bridge lines
|
||||||
|
* @return Normalized list
|
||||||
|
*/
|
||||||
|
fun normalize(bridgeLines: List<String>): List<String> {
|
||||||
|
return bridgeLines
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.filter { !it.startsWith("#") } // Remove comments
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import IPtProxy.Controller
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import eu.weblibre.flutter_tor.generated.IPtProxyController
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorApi
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorConfiguration
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorStatus
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlutterTorPlugin - Main plugin class
|
||||||
|
* Implements Pigeon-generated TorApi and manages TorService
|
||||||
|
*/
|
||||||
|
class FlutterTorPlugin : FlutterPlugin, TorApi {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FlutterTorPlugin"
|
||||||
|
private const val SERVICE_CONNECTION_TIMEOUT_MS = 10000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private var context: Context? = null
|
||||||
|
private var torService: TorService? = null
|
||||||
|
private var serviceConnection: ServiceConnection? = null
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
// Service connection state
|
||||||
|
private var serviceConnected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
Log.d(TAG, "onAttachedToEngine")
|
||||||
|
context = flutterPluginBinding.applicationContext
|
||||||
|
|
||||||
|
// Setup Pigeon API
|
||||||
|
TorApi.setUp(flutterPluginBinding.binaryMessenger, this)
|
||||||
|
|
||||||
|
// Bind to TorService
|
||||||
|
bindTorService(flutterPluginBinding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
Log.d(TAG, "onDetachedFromEngine")
|
||||||
|
|
||||||
|
// Cleanup Pigeon API
|
||||||
|
TorApi.setUp(binding.binaryMessenger, null)
|
||||||
|
|
||||||
|
// Unbind service
|
||||||
|
unbindTorService()
|
||||||
|
|
||||||
|
// Cancel coroutines
|
||||||
|
scope.cancel()
|
||||||
|
|
||||||
|
context = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind to TorService
|
||||||
|
*/
|
||||||
|
private fun bindTorService(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
val ctx = context ?: return
|
||||||
|
|
||||||
|
val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
Log.d(TAG, "TorService connected")
|
||||||
|
val binder = service as? TorService.LocalBinder
|
||||||
|
torService = binder?.getService()
|
||||||
|
torService?.initialize(flutterPluginBinding.binaryMessenger)
|
||||||
|
|
||||||
|
// Signal that service is connected
|
||||||
|
serviceConnected.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
Log.w(TAG, "TorService disconnected")
|
||||||
|
torService = null
|
||||||
|
|
||||||
|
// Reset connection deferred for potential reconnection
|
||||||
|
serviceConnected = CompletableDeferred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceConnection = connection
|
||||||
|
|
||||||
|
val intent = Intent(ctx, TorService::class.java)
|
||||||
|
intent.action = TorService.ACTION_START
|
||||||
|
ctx.startService(intent)
|
||||||
|
ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unbind from TorService
|
||||||
|
*/
|
||||||
|
private fun unbindTorService() {
|
||||||
|
serviceConnection?.let { conn ->
|
||||||
|
try {
|
||||||
|
context?.unbindService(conn)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error unbinding service", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serviceConnection = null
|
||||||
|
torService = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for service to be connected
|
||||||
|
*/
|
||||||
|
private suspend fun waitForService(): TorService {
|
||||||
|
return withTimeoutOrNull(SERVICE_CONNECTION_TIMEOUT_MS) {
|
||||||
|
serviceConnected.await()
|
||||||
|
torService
|
||||||
|
} ?: throw Exception("TorService connection timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Pigeon TorApi Implementation ==========
|
||||||
|
// Note: These methods are now async with callbacks to avoid blocking the main thread
|
||||||
|
|
||||||
|
override fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit) {
|
||||||
|
Log.d(TAG, "startTor called with transport: ${config.transport}")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Wait for service to be connected
|
||||||
|
val service = waitForService()
|
||||||
|
|
||||||
|
val socksPort = withContext(Dispatchers.IO) {
|
||||||
|
service.startTor(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = socksPort.toLong()
|
||||||
|
Log.d(TAG, "Returning SOCKS port to Flutter: $socksPort")
|
||||||
|
callback(Result.success(result))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start Tor", e)
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopTor(callback: (Result<Unit>) -> Unit) {
|
||||||
|
Log.d(TAG, "stopTor called")
|
||||||
|
|
||||||
|
val service = torService
|
||||||
|
if (service == null) {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
service.stopTor()
|
||||||
|
}
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to stop Tor", e)
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStatus(): TorStatus {
|
||||||
|
val service = torService
|
||||||
|
?: return TorStatus(
|
||||||
|
isRunning = false,
|
||||||
|
socksPort = null,
|
||||||
|
bootstrapProgress = 0,
|
||||||
|
currentCircuit = null,
|
||||||
|
exitNodeCountry = null
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
service.getStatus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get status", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestNewIdentity() {
|
||||||
|
Log.d(TAG, "requestNewIdentity called")
|
||||||
|
|
||||||
|
val service = torService
|
||||||
|
?: throw Exception("TorService not initialized")
|
||||||
|
|
||||||
|
try {
|
||||||
|
service.requestNewIdentity()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to request new identity", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages GeoIP database files for country-based node selection
|
||||||
|
* GeoIP files are provided by the tor-android library
|
||||||
|
*/
|
||||||
|
class GeoIpManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "GeoIpManager"
|
||||||
|
private const val GEOIP_FILE = "geoip"
|
||||||
|
private const val GEOIP6_FILE = "geoip6"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GeoIP file path, extracting from assets if necessary
|
||||||
|
* @param installDir Directory to install GeoIP files
|
||||||
|
* @return GeoIP file or null if not available
|
||||||
|
*/
|
||||||
|
fun getGeoIpFile(installDir: File): File? {
|
||||||
|
val geoipFile = File(installDir, GEOIP_FILE)
|
||||||
|
if (!geoipFile.exists()) {
|
||||||
|
extractAsset(GEOIP_FILE, geoipFile)
|
||||||
|
}
|
||||||
|
return if (geoipFile.exists()) geoipFile else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GeoIP6 file path, extracting from assets if necessary
|
||||||
|
* @param installDir Directory to install GeoIP files
|
||||||
|
* @return GeoIP6 file or null if not available
|
||||||
|
*/
|
||||||
|
fun getGeoIp6File(installDir: File): File? {
|
||||||
|
val geoip6File = File(installDir, GEOIP6_FILE)
|
||||||
|
if (!geoip6File.exists()) {
|
||||||
|
extractAsset(GEOIP6_FILE, geoip6File)
|
||||||
|
}
|
||||||
|
return if (geoip6File.exists()) geoip6File else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract asset file to destination
|
||||||
|
* Note: tor-android library should provide these files in its assets
|
||||||
|
*/
|
||||||
|
private fun extractAsset(assetName: String, destFile: File) {
|
||||||
|
try {
|
||||||
|
context.assets.open(assetName).use { input ->
|
||||||
|
destFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(destFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Extracted $assetName to ${destFile.absolutePath}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not extract $assetName from assets: ${e.message}")
|
||||||
|
// GeoIP files are optional - Tor will work without them
|
||||||
|
// but country-based node selection won't be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if GeoIP files are available
|
||||||
|
* @param installDir Directory where GeoIP files should be
|
||||||
|
* @return true if both geoip and geoip6 exist
|
||||||
|
*/
|
||||||
|
fun areGeoIpFilesAvailable(installDir: File): Boolean {
|
||||||
|
val geoip = File(installDir, GEOIP_FILE)
|
||||||
|
val geoip6 = File(installDir, GEOIP6_FILE)
|
||||||
|
return geoip.exists() && geoip6.exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorLogApi
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorLogMessage
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorStatus
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles streaming logs and status updates from Tor to Flutter
|
||||||
|
* All Flutter API calls are posted to the main thread to avoid threading issues
|
||||||
|
*/
|
||||||
|
class LogStreamHandler(messenger: BinaryMessenger) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LogStreamHandler"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val torLogApi = TorLogApi(messenger)
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a log message to Flutter
|
||||||
|
* @param severity Log severity (NOTICE, WARN, ERR, DEBUG)
|
||||||
|
* @param message Log message
|
||||||
|
*/
|
||||||
|
fun sendLog(severity: String, message: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
try {
|
||||||
|
val logMessage = TorLogMessage(
|
||||||
|
severity = severity,
|
||||||
|
message = message,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
torLogApi.onLogMessage(logMessage) { }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error sending log to Flutter: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send status change to Flutter
|
||||||
|
* @param status Current Tor status
|
||||||
|
*/
|
||||||
|
fun sendStatusChange(status: TorStatus) {
|
||||||
|
Log.d(TorManager.Companion.TAG, "sendStatusChange() returning: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}")
|
||||||
|
|
||||||
|
mainHandler.post {
|
||||||
|
try {
|
||||||
|
torLogApi.onStatusChanged(status) { }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error sending status to Flutter: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and send Tor control port event
|
||||||
|
* @param eventType Event type from TorControlConnection (e.g., "NOTICE", "WARN", "ERR", "CIRC", "BW")
|
||||||
|
* @param eventData Event data
|
||||||
|
*/
|
||||||
|
fun handleTorEvent(eventType: String, eventData: String) {
|
||||||
|
when (eventType) {
|
||||||
|
"NOTICE" -> sendLog("NOTICE", eventData)
|
||||||
|
"WARN" -> sendLog("WARN", eventData)
|
||||||
|
"ERR" -> sendLog("ERR", eventData)
|
||||||
|
"DEBUG" -> sendLog("DEBUG", eventData)
|
||||||
|
"INFO" -> sendLog("INFO", eventData)
|
||||||
|
// Don't log circuit/bandwidth events to UI, they're too verbose
|
||||||
|
"CIRC", "ORCONN", "BW", "STREAM", "ADDRMAP", "NEWDESC" -> {
|
||||||
|
// These are logged to logcat by TorManager for debugging,
|
||||||
|
// but not sent to Flutter UI
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Unknown event types, log for debugging
|
||||||
|
sendLog("DEBUG", "$eventType: $eventData")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to send notice logs
|
||||||
|
*/
|
||||||
|
fun notice(message: String) {
|
||||||
|
sendLog("NOTICE", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to send warning logs
|
||||||
|
*/
|
||||||
|
fun warn(message: String) {
|
||||||
|
sendLog("WARN", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to send error logs
|
||||||
|
*/
|
||||||
|
fun error(message: String) {
|
||||||
|
sendLog("ERR", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to send debug logs
|
||||||
|
*/
|
||||||
|
fun debug(message: String) {
|
||||||
|
sendLog("DEBUG", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import IPtProxy.Controller
|
||||||
|
import IPtProxy.IPtProxy
|
||||||
|
import IPtProxy.OnTransportStopped
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages pluggable transports via IPtProxy
|
||||||
|
* Supports: obfs4, snowflake, meek, webtunnel
|
||||||
|
*/
|
||||||
|
class PluggableTransportManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PTManager"
|
||||||
|
|
||||||
|
// Snowflake configuration
|
||||||
|
private const val SNOWFLAKE_BROKER = "https://snowflake-broker.torproject.net/"
|
||||||
|
private const val SNOWFLAKE_BROKER_AMP = "https://snowflake-broker.torproject.net.global.prod.fastly.net/"
|
||||||
|
private const val SNOWFLAKE_AMP_CACHE = "https://cdn.ampproject.org/"
|
||||||
|
private val SNOWFLAKE_FRONTS = listOf("foursquare.com", "github.githubassets.com")
|
||||||
|
private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com")
|
||||||
|
private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateDir = File(context.cacheDir, "iptproxy")
|
||||||
|
private val activeTransports = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private val statusCallback = object : OnTransportStopped {
|
||||||
|
override fun stopped(name: String?, exception: Exception?) {
|
||||||
|
if (name != null) {
|
||||||
|
activeTransports.remove(name)
|
||||||
|
if (exception != null) {
|
||||||
|
Log.e(TAG, "$name stopped with error: ${exception.message}", exception)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "$name stopped normally")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy singleton controller (like Orbot does)
|
||||||
|
val controller: Controller by lazy {
|
||||||
|
Controller(
|
||||||
|
stateDir.absolutePath,
|
||||||
|
true, // enableLogging
|
||||||
|
false, // unsafeLogging
|
||||||
|
"INFO", // logLevel
|
||||||
|
statusCallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
stateDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start pluggable transport for the given type
|
||||||
|
* @param type Transport type
|
||||||
|
* @return Map of transport name to port (e.g., {"obfs4": 12345})
|
||||||
|
*/
|
||||||
|
fun startTransport(type: TransportType): Map<String, Int> {
|
||||||
|
Log.d(TAG, "Starting transport: $type")
|
||||||
|
|
||||||
|
// Stop any currently running transports before starting new ones
|
||||||
|
stopAll()
|
||||||
|
|
||||||
|
val ports = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
when (type) {
|
||||||
|
TransportType.OBFS4 -> {
|
||||||
|
val transportName = IPtProxy.Obfs4
|
||||||
|
controller.start(transportName, null) // null = no proxy
|
||||||
|
activeTransports.add(transportName)
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
if (port > 0) {
|
||||||
|
ports[transportName] = port.toInt()
|
||||||
|
Log.d(TAG, "$transportName started on port $port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportType.SNOWFLAKE -> {
|
||||||
|
val transportName = IPtProxy.Snowflake
|
||||||
|
configureSnowflake(useAmp = false)
|
||||||
|
controller.start(transportName, null)
|
||||||
|
activeTransports.add(transportName)
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
if (port > 0) {
|
||||||
|
ports[transportName] = port.toInt()
|
||||||
|
Log.d(TAG, "$transportName started on port $port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportType.SNOWFLAKE_AMP -> {
|
||||||
|
val transportName = IPtProxy.Snowflake
|
||||||
|
configureSnowflake(useAmp = true)
|
||||||
|
controller.start(transportName, null)
|
||||||
|
activeTransports.add(transportName)
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
if (port > 0) {
|
||||||
|
ports[transportName] = port.toInt()
|
||||||
|
Log.d(TAG, "$transportName (AMP) started on port $port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportType.MEEK, TransportType.MEEK_AZURE -> {
|
||||||
|
val transportName = IPtProxy.MeekLite
|
||||||
|
controller.start(transportName, null)
|
||||||
|
activeTransports.add(transportName)
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
if (port > 0) {
|
||||||
|
ports[transportName] = port.toInt()
|
||||||
|
Log.d(TAG, "$transportName started on port $port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportType.WEBTUNNEL -> {
|
||||||
|
val transportName = IPtProxy.Webtunnel
|
||||||
|
controller.start(transportName, null)
|
||||||
|
activeTransports.add(transportName)
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
if (port > 0) {
|
||||||
|
ports[transportName] = port.toInt()
|
||||||
|
Log.d(TAG, "$transportName started on port $port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportType.NONE, TransportType.CUSTOM -> {
|
||||||
|
// No pluggable transport needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start transport $type: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Snowflake-specific settings
|
||||||
|
*/
|
||||||
|
private fun configureSnowflake(useAmp: Boolean) {
|
||||||
|
controller.snowflakeIceServers = SNOWFLAKE_ICE_SERVERS
|
||||||
|
|
||||||
|
if (useAmp) {
|
||||||
|
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER_AMP
|
||||||
|
controller.snowflakeFrontDomains = SNOWFLAKE_AMP_FRONTS.joinToString(",")
|
||||||
|
controller.snowflakeAmpCacheUrl = SNOWFLAKE_AMP_CACHE
|
||||||
|
} else {
|
||||||
|
controller.snowflakeBrokerUrl = SNOWFLAKE_BROKER
|
||||||
|
controller.snowflakeFrontDomains = SNOWFLAKE_FRONTS.joinToString(",")
|
||||||
|
controller.snowflakeAmpCacheUrl = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.snowflakeSqsUrl = ""
|
||||||
|
controller.snowflakeSqsCreds = ""
|
||||||
|
|
||||||
|
Log.d(TAG, "Configured Snowflake: broker=${controller.snowflakeBrokerUrl}, amp=$useAmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all running pluggable transports
|
||||||
|
*/
|
||||||
|
fun stopAll() {
|
||||||
|
Log.d(TAG, "Stopping all transports")
|
||||||
|
|
||||||
|
// Stop each active transport
|
||||||
|
activeTransports.toList().forEach { transportName ->
|
||||||
|
try {
|
||||||
|
controller.stop(transportName)
|
||||||
|
Log.d(TAG, "Stopped transport: $transportName")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error stopping $transportName: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTransports.clear()
|
||||||
|
|
||||||
|
Log.d(TAG, "All transports stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the port for a specific transport
|
||||||
|
* @param transportName Transport name (e.g., "obfs4", "snowflake")
|
||||||
|
* @return Port number or null
|
||||||
|
*/
|
||||||
|
fun getPort(transportName: String): Int? {
|
||||||
|
val port = controller.port(transportName)
|
||||||
|
return if (port > 0) port.toInt() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a transport is currently running
|
||||||
|
*/
|
||||||
|
fun isRunning(): Boolean {
|
||||||
|
return activeTransports.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages random port allocation for Tor and pluggable transports
|
||||||
|
*/
|
||||||
|
object PortManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an available random port by binding to port 0
|
||||||
|
* @return Available port number
|
||||||
|
*/
|
||||||
|
fun findAvailablePort(): Int {
|
||||||
|
return ServerSocket(0).use { socket ->
|
||||||
|
socket.localPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific port is available
|
||||||
|
* @param port Port to check
|
||||||
|
* @return true if port is available
|
||||||
|
*/
|
||||||
|
fun isPortAvailable(port: Int): Boolean {
|
||||||
|
return try {
|
||||||
|
ServerSocket(port).use { true }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2025 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import IPtProxy.IPtProxy
|
||||||
|
import eu.weblibre.flutter_tor.generated.IPtProxyController
|
||||||
|
import eu.weblibre.flutter_tor.generated.TransportType
|
||||||
|
|
||||||
|
class ProxyImpl(val controller: IPtProxy.Controller) : IPtProxyController {
|
||||||
|
override fun start(proxyType: TransportType, proxy: String): Long {
|
||||||
|
val type = when (proxyType) {
|
||||||
|
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
|
||||||
|
TransportType.MEEK -> IPtProxy.MeekLite
|
||||||
|
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
|
||||||
|
TransportType.OBFS4 -> IPtProxy.Obfs4
|
||||||
|
TransportType.NONE -> null
|
||||||
|
else -> {
|
||||||
|
throw Exception("Unsupported transport type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.start(type, proxy)
|
||||||
|
|
||||||
|
return controller.port(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop(proxyType: TransportType) {
|
||||||
|
val type = when (proxyType) {
|
||||||
|
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
|
||||||
|
TransportType.MEEK -> IPtProxy.MeekLite
|
||||||
|
TransportType.WEBTUNNEL -> IPtProxy.Webtunnel
|
||||||
|
TransportType.OBFS4 -> IPtProxy.Obfs4
|
||||||
|
TransportType.NONE -> null
|
||||||
|
else -> {
|
||||||
|
throw Exception("Unsupported transport type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.stop(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorConfiguration
|
||||||
|
import IPtProxy.IPtProxy
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates Tor configuration (torrc) based on user settings
|
||||||
|
*/
|
||||||
|
class TorConfig(private val config: TorConfiguration) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate torrc file content
|
||||||
|
* @param socksPort SOCKS proxy port
|
||||||
|
* @param dataDir Tor data directory
|
||||||
|
* @param geoipFile GeoIP file (optional, for country selection)
|
||||||
|
* @param geoip6File GeoIP6 file (optional, for IPv6 country selection)
|
||||||
|
* @param transportPorts Map of transport name to port (from PluggableTransportManager)
|
||||||
|
* @return torrc content as string
|
||||||
|
*
|
||||||
|
* Note: ControlPort is NOT set in torrc. The tor-android library automatically
|
||||||
|
* uses ControlSocket (Unix domain socket) which is more secure than TCP ControlPort.
|
||||||
|
* See SECURITY_CONTROL_PORT.md for details.
|
||||||
|
*/
|
||||||
|
fun generateTorrc(
|
||||||
|
socksPort: Int,
|
||||||
|
dataDir: File,
|
||||||
|
geoipFile: File?,
|
||||||
|
geoip6File: File?,
|
||||||
|
transportPorts: Map<String, Int>
|
||||||
|
): String = buildString {
|
||||||
|
// Core Tor settings
|
||||||
|
append("# Generated torrc for flutter_tor\n")
|
||||||
|
append("SocksPort 127.0.0.1:$socksPort\n")
|
||||||
|
// ControlPort is NOT set - tor-android uses ControlSocket (Unix domain socket)
|
||||||
|
// This is more secure as it uses file permissions instead of TCP authentication
|
||||||
|
append("DataDirectory ${dataDir.absolutePath}\n")
|
||||||
|
append("\n")
|
||||||
|
|
||||||
|
// GeoIP files for country-based node selection
|
||||||
|
if (geoipFile != null && geoipFile.exists()) {
|
||||||
|
append("GeoIPFile ${geoipFile.absolutePath}\n")
|
||||||
|
}
|
||||||
|
if (geoip6File != null && geoip6File.exists()) {
|
||||||
|
append("GeoIPv6File ${geoip6File.absolutePath}\n")
|
||||||
|
}
|
||||||
|
append("\n")
|
||||||
|
|
||||||
|
// Entry node countries
|
||||||
|
config.entryNodeCountries?.let { countries ->
|
||||||
|
if (countries.isNotBlank()) {
|
||||||
|
val formatted = formatCountries(countries)
|
||||||
|
append("EntryNodes $formatted\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit node countries
|
||||||
|
config.exitNodeCountries?.let { countries ->
|
||||||
|
if (countries.isNotBlank()) {
|
||||||
|
val formatted = formatCountries(countries)
|
||||||
|
append("ExitNodes $formatted\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict nodes (only use specified countries)
|
||||||
|
if (config.strictNodes == true) {
|
||||||
|
append("StrictNodes 1\n")
|
||||||
|
}
|
||||||
|
append("\n")
|
||||||
|
|
||||||
|
// Pluggable transport configuration
|
||||||
|
val transport = TransportType.fromPigeon(config.transport)
|
||||||
|
when (transport) {
|
||||||
|
TransportType.OBFS4 -> {
|
||||||
|
transportPorts[IPtProxy.Obfs4]?.let { port ->
|
||||||
|
// Validate port is valid (like Orbot does)
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin ${IPtProxy.Obfs4} socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransportType.SNOWFLAKE, TransportType.SNOWFLAKE_AMP -> {
|
||||||
|
transportPorts[IPtProxy.Snowflake]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin ${IPtProxy.Snowflake} socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransportType.MEEK, TransportType.MEEK_AZURE -> {
|
||||||
|
transportPorts[IPtProxy.MeekLite]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin ${IPtProxy.MeekLite} socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransportType.WEBTUNNEL -> {
|
||||||
|
transportPorts[IPtProxy.Webtunnel]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin ${IPtProxy.Webtunnel} socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TransportType.CUSTOM -> {
|
||||||
|
// Custom bridges - transport plugin defined in bridge line
|
||||||
|
// We'll try to detect and configure based on bridge lines
|
||||||
|
configureCustomTransports(transportPorts)
|
||||||
|
}
|
||||||
|
TransportType.NONE -> {
|
||||||
|
// Direct connection, no pluggable transports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append("\n")
|
||||||
|
|
||||||
|
// Bridge configuration
|
||||||
|
if (transport != TransportType.NONE) {
|
||||||
|
val normalizedBridges = BridgeParser.normalize(config.bridgeLines)
|
||||||
|
if (normalizedBridges.isNotEmpty()) {
|
||||||
|
append("UseBridges 1\n")
|
||||||
|
normalizedBridges.forEach { bridge ->
|
||||||
|
append("Bridge $bridge\n")
|
||||||
|
}
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional Tor settings (matching Orbot's configuration)
|
||||||
|
append("# Additional settings\n")
|
||||||
|
append("RunAsDaemon 1\n")
|
||||||
|
append("AvoidDiskWrites 1\n")
|
||||||
|
append("SafeSocks 0\n")
|
||||||
|
append("TestSocks 0\n")
|
||||||
|
append("VirtualAddrNetwork 10.192.0.0/10\n")
|
||||||
|
append("AutomapHostsOnResolve 1\n")
|
||||||
|
append("DormantClientTimeout 10 minutes\n")
|
||||||
|
append("DormantCanceledByStartup 1\n")
|
||||||
|
|
||||||
|
// Note: DisableNetwork is set to 1 in defaults.torrc
|
||||||
|
// It will be enabled via control port after setup completes (matching Orbot)
|
||||||
|
// We DON'T set it here to avoid overriding the defaults.torrc setting
|
||||||
|
append("DisableNetwork 0\n")
|
||||||
|
|
||||||
|
append("Log notice stdout\n") // Log to stdout for capture
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format country codes for Tor configuration
|
||||||
|
* Input: "de,fr,nl" or "{de},{fr},{nl}" or "de, fr, nl"
|
||||||
|
* Output: "{de},{fr},{nl}"
|
||||||
|
*/
|
||||||
|
private fun formatCountries(countries: String): String {
|
||||||
|
val codes = countries
|
||||||
|
.replace("{", "")
|
||||||
|
.replace("}", "")
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim().uppercase() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.filter { it.length == 2 } // ISO 3166-1 alpha-2 codes
|
||||||
|
|
||||||
|
return codes.joinToString(",") { "{$it}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure custom transports based on bridge lines
|
||||||
|
* Detects transport type from bridge lines and configures accordingly
|
||||||
|
*/
|
||||||
|
private fun StringBuilder.configureCustomTransports(transportPorts: Map<String, Int>) {
|
||||||
|
val bridgeTransports = config.bridgeLines
|
||||||
|
.mapNotNull { BridgeParser.extractTransportType(it) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
bridgeTransports.forEach { transportName ->
|
||||||
|
when (transportName) {
|
||||||
|
"obfs4" -> transportPorts[IPtProxy.Obfs4]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin obfs4 socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"snowflake" -> transportPorts[IPtProxy.Snowflake]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin snowflake socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"meek_lite" -> transportPorts[IPtProxy.MeekLite]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin meek_lite socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"webtunnel" -> transportPorts[IPtProxy.Webtunnel]?.let { port ->
|
||||||
|
if (port > 0) {
|
||||||
|
append("ClientTransportPlugin webtunnel socks5 127.0.0.1:$port\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write torrc to file
|
||||||
|
* @param torrcFile File to write to
|
||||||
|
* @param content torrc content
|
||||||
|
*/
|
||||||
|
fun writeTorrc(torrcFile: File, content: String) {
|
||||||
|
torrcFile.parentFile?.mkdirs()
|
||||||
|
torrcFile.writeText(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorConfiguration
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorStatus
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import net.freehaven.tor.control.RawEventListener
|
||||||
|
import net.freehaven.tor.control.TorControlCommands
|
||||||
|
import net.freehaven.tor.control.TorControlConnection
|
||||||
|
import org.torproject.jni.TorService
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core Tor lifecycle manager
|
||||||
|
* Handles starting/stopping Tor, control port connection, and event listening
|
||||||
|
*/
|
||||||
|
class TorManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val logHandler: LogStreamHandler
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "TorManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dataDir = File(context.filesDir, "tor_data")
|
||||||
|
private val installDir = File(context.filesDir, "tor_install")
|
||||||
|
private var torServiceConnection: ServiceConnection? = null
|
||||||
|
private var controlConnection: TorControlConnection? = null
|
||||||
|
private var torService: TorService? = null
|
||||||
|
|
||||||
|
val pluggableTransportManager = PluggableTransportManager(context)
|
||||||
|
private val geoIpManager = GeoIpManager(context)
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
var socksPort: Int = -1
|
||||||
|
private set
|
||||||
|
// Note: No controlPort - tor-android uses ControlSocket (Unix domain socket) instead
|
||||||
|
// This is more secure than TCP ControlPort as it uses file permissions for access control
|
||||||
|
private var isRunning = false
|
||||||
|
private var bootstrapProgress = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Tor with the given configuration
|
||||||
|
* @param config Tor configuration from Flutter
|
||||||
|
* @return SOCKS port
|
||||||
|
*/
|
||||||
|
suspend fun start(config: TorConfiguration): Int = withContext(Dispatchers.IO) {
|
||||||
|
if (isRunning) {
|
||||||
|
Log.w(TAG, "Tor already running")
|
||||||
|
return@withContext socksPort
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logHandler.notice("Starting Tor...")
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
dataDir.mkdirs()
|
||||||
|
installDir.mkdirs()
|
||||||
|
|
||||||
|
// Allocate random SOCKS port
|
||||||
|
// Note: We don't allocate a control port - tor-android uses ControlSocket instead
|
||||||
|
socksPort = PortManager.findAvailablePort()
|
||||||
|
|
||||||
|
Log.d(TAG, "Allocated SOCKS port: $socksPort")
|
||||||
|
Log.d(TAG, "Control connection will use ControlSocket (Unix domain socket)")
|
||||||
|
logHandler.notice("SOCKS port: $socksPort")
|
||||||
|
|
||||||
|
// Start pluggable transports if needed
|
||||||
|
val transport = TransportType.fromPigeon(config.transport)
|
||||||
|
val transportPorts = if (transport != TransportType.NONE && transport != TransportType.CUSTOM) {
|
||||||
|
logHandler.notice("Starting pluggable transport: $transport")
|
||||||
|
pluggableTransportManager.startTransport(transport)
|
||||||
|
} else if (transport == TransportType.CUSTOM) {
|
||||||
|
// For custom, we need to detect and start appropriate transports
|
||||||
|
startCustomTransports(config.bridgeLines)
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate torrc
|
||||||
|
val geoipFile = geoIpManager.getGeoIpFile(installDir)
|
||||||
|
val geoip6File = geoIpManager.getGeoIp6File(installDir)
|
||||||
|
|
||||||
|
val torConfig = TorConfig(config)
|
||||||
|
val torrcContent = torConfig.generateTorrc(
|
||||||
|
socksPort = socksPort,
|
||||||
|
// controlPort removed - tor-android uses ControlSocket (Unix domain socket) for security
|
||||||
|
dataDir = dataDir,
|
||||||
|
geoipFile = geoipFile,
|
||||||
|
geoip6File = geoip6File,
|
||||||
|
transportPorts = transportPorts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Write torrc to the correct location (like Orbot does)
|
||||||
|
// CRITICAL: Must use TorService.getTorrc() so TorService can find it!
|
||||||
|
val torrcFile = TorService.getTorrc(context)
|
||||||
|
torConfig.writeTorrc(torrcFile, torrcContent)
|
||||||
|
|
||||||
|
Log.d(TAG, "Generated torrc at ${torrcFile.absolutePath}:\n$torrcContent")
|
||||||
|
|
||||||
|
// Write defaults torrc (required by tor-android)
|
||||||
|
// Set DisableNetwork 1 initially like Orbot does, will be enabled via control port
|
||||||
|
// Also disable DNSPort and TransPort (matching Orbot)
|
||||||
|
val defaultsTorrcFile = TorService.getDefaultsTorrc(context)
|
||||||
|
defaultsTorrcFile.writeText("""
|
||||||
|
DNSPort 0
|
||||||
|
TransPort 0
|
||||||
|
DisableNetwork 1
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
// Start TorService
|
||||||
|
// Note: torrcFile is now written to the correct location via TorService.getTorrc()
|
||||||
|
// so TorService will automatically find and use it
|
||||||
|
startTorService()
|
||||||
|
|
||||||
|
isRunning = true
|
||||||
|
logHandler.notice("Tor started successfully")
|
||||||
|
sendStatusUpdate()
|
||||||
|
|
||||||
|
socksPort
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start Tor", e)
|
||||||
|
logHandler.error("Failed to start Tor: ${e.message}")
|
||||||
|
cleanup()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start custom transports based on bridge lines
|
||||||
|
*/
|
||||||
|
private fun startCustomTransports(bridgeLines: List<String>): Map<String, Int> {
|
||||||
|
val transports = bridgeLines
|
||||||
|
.mapNotNull { BridgeParser.extractTransportType(it) }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val ports = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
transports.forEach { transportName ->
|
||||||
|
val transportType = when (transportName) {
|
||||||
|
"obfs4" -> TransportType.OBFS4
|
||||||
|
"snowflake" -> TransportType.SNOWFLAKE
|
||||||
|
"meek_lite" -> TransportType.MEEK
|
||||||
|
"webtunnel" -> TransportType.WEBTUNNEL
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
transportType?.let { type ->
|
||||||
|
ports.putAll(pluggableTransportManager.startTransport(type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the native TorService and bind to it
|
||||||
|
* TorService will automatically use the torrc written to TorService.getTorrc(context)
|
||||||
|
*/
|
||||||
|
private suspend fun startTorService() = suspendCancellableCoroutine<Unit> { continuation ->
|
||||||
|
val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
Log.d(TAG, "TorService connected")
|
||||||
|
val binder = service as? TorService.LocalBinder
|
||||||
|
torService = binder?.service
|
||||||
|
|
||||||
|
// Wait for control connection to be available
|
||||||
|
scope.launch {
|
||||||
|
var conn: TorControlConnection? = null
|
||||||
|
var attempts = 0
|
||||||
|
while (conn == null && attempts < 60) { // 30 seconds timeout
|
||||||
|
delay(500)
|
||||||
|
conn = torService?.torControlConnection
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conn != null) {
|
||||||
|
// Wait an additional second before setting up event listener
|
||||||
|
// This matches Orbot's behavior and ensures Tor is fully initialized
|
||||||
|
delay(1000)
|
||||||
|
|
||||||
|
controlConnection = conn
|
||||||
|
setupControlConnection(conn)
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resume(Unit) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val error = Exception("Failed to get control connection after 30 seconds")
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resumeWithException(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
Log.w(TAG, "TorService disconnected")
|
||||||
|
torService = null
|
||||||
|
controlConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torServiceConnection = connection
|
||||||
|
|
||||||
|
val intent = Intent(context, org.torproject.jni.TorService::class.java)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start the service first (like Orbot does) before binding
|
||||||
|
context.startService(intent)
|
||||||
|
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup control connection and event listeners
|
||||||
|
*/
|
||||||
|
private fun setupControlConnection(conn: TorControlConnection) {
|
||||||
|
try {
|
||||||
|
// Add event listener
|
||||||
|
conn.addRawEventListener(TorEventListener())
|
||||||
|
|
||||||
|
// Subscribe to events (matching Orbot's event subscriptions)
|
||||||
|
conn.setEvents(listOf(
|
||||||
|
TorControlCommands.EVENT_OR_CONN_STATUS,
|
||||||
|
TorControlCommands.EVENT_CIRCUIT_STATUS,
|
||||||
|
TorControlCommands.EVENT_NOTICE_MSG,
|
||||||
|
TorControlCommands.EVENT_WARN_MSG,
|
||||||
|
TorControlCommands.EVENT_ERR_MSG,
|
||||||
|
TorControlCommands.EVENT_BANDWIDTH_USED,
|
||||||
|
TorControlCommands.EVENT_NEW_DESC,
|
||||||
|
TorControlCommands.EVENT_ADDRMAP
|
||||||
|
))
|
||||||
|
|
||||||
|
// Enable network now that configuration is complete (like Orbot does)
|
||||||
|
conn.setConf("DisableNetwork", "0")
|
||||||
|
|
||||||
|
Log.d(TAG, "Control connection setup complete")
|
||||||
|
logHandler.notice("Connected to Tor control port")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to setup control connection", e)
|
||||||
|
logHandler.error("Control connection error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Tor and cleanup
|
||||||
|
*/
|
||||||
|
suspend fun stop() = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "Stopping Tor")
|
||||||
|
logHandler.notice("Stopping Tor...")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Shutdown Tor gracefully
|
||||||
|
controlConnection?.shutdownTor("SHUTDOWN")
|
||||||
|
delay(1000) // Give Tor time to shutdown
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
logHandler.notice("Tor stopped")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping Tor", e)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
private fun cleanup() {
|
||||||
|
isRunning = false
|
||||||
|
bootstrapProgress = 0
|
||||||
|
socksPort = -1
|
||||||
|
|
||||||
|
try {
|
||||||
|
controlConnection?.let {
|
||||||
|
// Don't shutdown again, just close
|
||||||
|
}
|
||||||
|
controlConnection = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error closing control connection", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
torServiceConnection?.let {
|
||||||
|
context.unbindService(it)
|
||||||
|
}
|
||||||
|
torServiceConnection = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error unbinding TorService", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
torService = null
|
||||||
|
|
||||||
|
pluggableTransportManager.stopAll()
|
||||||
|
|
||||||
|
sendStatusUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a new Tor identity (new circuit)
|
||||||
|
*/
|
||||||
|
fun requestNewIdentity() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
controlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
|
||||||
|
logHandler.notice("Requested new Tor identity")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to request new identity", e)
|
||||||
|
logHandler.error("Failed to request new identity: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current Tor status
|
||||||
|
*/
|
||||||
|
fun getStatus(): TorStatus {
|
||||||
|
val status = TorStatus(
|
||||||
|
isRunning = isRunning,
|
||||||
|
socksPort = if (isRunning) socksPort.toLong() else null,
|
||||||
|
bootstrapProgress = bootstrapProgress.toLong(),
|
||||||
|
currentCircuit = null, // TODO: track current circuit
|
||||||
|
exitNodeCountry = null // TODO: track exit node country
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "getStatus() returning: isRunning=$isRunning, socksPort=$socksPort, bootstrap=$bootstrapProgress")
|
||||||
|
// logHandler.sendStatusChange(status)
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send status update to Flutter
|
||||||
|
*/
|
||||||
|
private fun sendStatusUpdate() {
|
||||||
|
logHandler.sendStatusChange(getStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for Tor control port events
|
||||||
|
*/
|
||||||
|
private inner class TorEventListener : RawEventListener {
|
||||||
|
override fun onEvent(eventType: String, eventData: String) {
|
||||||
|
Log.d(TAG, "Tor event: $eventType - $eventData")
|
||||||
|
|
||||||
|
// Handle bootstrap progress (comes in NOTICE events)
|
||||||
|
if (eventData.contains("Bootstrapped")) {
|
||||||
|
val progress = extractBootstrapProgress(eventData)
|
||||||
|
if (progress >= 0) {
|
||||||
|
bootstrapProgress = progress
|
||||||
|
sendStatusUpdate()
|
||||||
|
|
||||||
|
if (progress == 100) {
|
||||||
|
logHandler.notice("Tor is ready!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to log handler
|
||||||
|
logHandler.handleTorEvent(eventType, eventData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractBootstrapProgress(eventData: String): Int {
|
||||||
|
// Extract from format like "Bootstrapped 85% (loading_descriptors): ..."
|
||||||
|
val regex = "Bootstrapped\\s+(\\d+)%".toRegex()
|
||||||
|
return regex.find(eventData)?.groupValues?.get(1)?.toIntOrNull() ?: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup when manager is destroyed
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
scope.cancel()
|
||||||
|
runBlocking {
|
||||||
|
if (isRunning) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import eu.weblibre.flutter_tor.generated.IPtProxyController
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorConfiguration
|
||||||
|
import eu.weblibre.flutter_tor.generated.TorStatus
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service for running Tor in the background
|
||||||
|
* Keeps Tor running even when the app is backgrounded
|
||||||
|
*/
|
||||||
|
class TorService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TorService"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val CHANNEL_ID = "flutter_tor_service"
|
||||||
|
const val ACTION_START = "eu.weblibre.flutter_tor.START"
|
||||||
|
const val ACTION_STOP = "eu.weblibre.flutter_tor.STOP"
|
||||||
|
const val EXTRA_CONFIG = "config"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = LocalBinder()
|
||||||
|
private var torManager: TorManager? = null
|
||||||
|
private var logHandler: LogStreamHandler? = null
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
inner class LocalBinder : Binder() {
|
||||||
|
fun getService(): TorService = this@TorService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "Service created")
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(TAG, "onStartCommand: ${intent?.action}")
|
||||||
|
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START -> {
|
||||||
|
// Start in foreground immediately
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification("Starting Tor..."))
|
||||||
|
// Actual start will be handled via binder methods
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_STOP -> {
|
||||||
|
scope.launch {
|
||||||
|
stopTor()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service with Flutter messenger for log streaming
|
||||||
|
*/
|
||||||
|
fun initialize(messenger: BinaryMessenger) {
|
||||||
|
if (logHandler == null) {
|
||||||
|
logHandler = LogStreamHandler(messenger)
|
||||||
|
torManager = TorManager(applicationContext, logHandler!!)
|
||||||
|
|
||||||
|
IPtProxyController.setUp(
|
||||||
|
messenger,
|
||||||
|
ProxyImpl(controller = torManager!!.pluggableTransportManager.controller)
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Service initialized with messenger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Tor with configuration
|
||||||
|
*/
|
||||||
|
suspend fun startTor(config: TorConfiguration): Int {
|
||||||
|
Log.d(TAG, "Starting Tor...")
|
||||||
|
updateNotification("Starting Tor...")
|
||||||
|
|
||||||
|
val manager = torManager ?: throw IllegalStateException("Service not initialized")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val socksPort = manager.start(config)
|
||||||
|
updateNotification("Tor is running (SOCKS: $socksPort)")
|
||||||
|
return socksPort
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start Tor", e)
|
||||||
|
updateNotification("Failed to start Tor")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Tor
|
||||||
|
*/
|
||||||
|
suspend fun stopTor() {
|
||||||
|
Log.d(TAG, "Stopping Tor...")
|
||||||
|
updateNotification("Stopping Tor...")
|
||||||
|
|
||||||
|
torManager?.stop()
|
||||||
|
updateNotification("Tor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current Tor status
|
||||||
|
*/
|
||||||
|
fun getStatus(): TorStatus {
|
||||||
|
return torManager?.getStatus() ?: TorStatus(
|
||||||
|
isRunning = false,
|
||||||
|
socksPort = null,
|
||||||
|
bootstrapProgress = 0,
|
||||||
|
currentCircuit = null,
|
||||||
|
exitNodeCountry = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request new Tor identity
|
||||||
|
*/
|
||||||
|
fun requestNewIdentity() {
|
||||||
|
torManager?.requestNewIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification text
|
||||||
|
*/
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
val notification = createNotification(text)
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification for foreground service
|
||||||
|
*/
|
||||||
|
private fun createNotification(text: String): Notification {
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Tor Service")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Use custom icon
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification channel (required for Android 8+)
|
||||||
|
*/
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Tor Service",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Keeps Tor running in the background"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
Log.d(TAG, "Service destroyed")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
torManager?.destroy()
|
||||||
|
}
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport types for Tor connections
|
||||||
|
* Maps to Pigeon-generated enum
|
||||||
|
*/
|
||||||
|
enum class TransportType {
|
||||||
|
NONE, // Direct Tor connection (no bridges)
|
||||||
|
OBFS4, // obfs4 pluggable transport
|
||||||
|
SNOWFLAKE, // Snowflake (default broker)
|
||||||
|
SNOWFLAKE_AMP, // Snowflake via AMP cache
|
||||||
|
MEEK, // Meek pluggable transport
|
||||||
|
MEEK_AZURE, // Meek via Azure CDN
|
||||||
|
WEBTUNNEL, // WebTunnel pluggable transport
|
||||||
|
CUSTOM; // Custom bridge lines (passthrough)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Convert from Pigeon-generated enum
|
||||||
|
*/
|
||||||
|
fun fromPigeon(pigeon: eu.weblibre.flutter_tor.generated.TransportType): TransportType {
|
||||||
|
return when (pigeon) {
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.NONE -> NONE
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.OBFS4 -> OBFS4
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE -> SNOWFLAKE
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.SNOWFLAKE_AMP -> SNOWFLAKE_AMP
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.MEEK -> MEEK
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.MEEK_AZURE -> MEEK_AZURE
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.WEBTUNNEL -> WEBTUNNEL
|
||||||
|
eu.weblibre.flutter_tor.generated.TransportType.CUSTOM -> CUSTOM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+497
@@ -0,0 +1,497 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_tor.generated
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object TorApiPigeonUtils {
|
||||||
|
|
||||||
|
fun createConnectionError(channelName: String): FlutterError {
|
||||||
|
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||||
|
if (a is ByteArray && b is ByteArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is IntArray && b is IntArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is LongArray && b is LongArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is DoubleArray && b is DoubleArray) {
|
||||||
|
return a.contentEquals(b)
|
||||||
|
}
|
||||||
|
if (a is Array<*> && b is Array<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is List<*> && b is List<*>) {
|
||||||
|
return a.size == b.size &&
|
||||||
|
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||||
|
}
|
||||||
|
if (a is Map<*, *> && b is Map<*, *>) {
|
||||||
|
return a.size == b.size && a.all {
|
||||||
|
(b as Map<Any?, Any?>).contains(it.key) &&
|
||||||
|
deepEquals(it.value, b[it.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||||
|
* @property code The error code.
|
||||||
|
* @property message The error message.
|
||||||
|
* @property details The error details. Must be a datatype supported by the api codec.
|
||||||
|
*/
|
||||||
|
class FlutterError (
|
||||||
|
val code: String,
|
||||||
|
override val message: String? = null,
|
||||||
|
val details: Any? = null
|
||||||
|
) : Throwable()
|
||||||
|
|
||||||
|
/** Transport types for Tor connections */
|
||||||
|
enum class TransportType(val raw: Int) {
|
||||||
|
/** Direct Tor connection (no bridges) */
|
||||||
|
NONE(0),
|
||||||
|
/** obfs4 pluggable transport */
|
||||||
|
OBFS4(1),
|
||||||
|
/** Snowflake pluggable transport (default broker) */
|
||||||
|
SNOWFLAKE(2),
|
||||||
|
/** Snowflake via AMP cache */
|
||||||
|
SNOWFLAKE_AMP(3),
|
||||||
|
/** Meek pluggable transport */
|
||||||
|
MEEK(4),
|
||||||
|
/** Meek via Azure CDN */
|
||||||
|
MEEK_AZURE(5),
|
||||||
|
/** WebTunnel pluggable transport */
|
||||||
|
WEBTUNNEL(6),
|
||||||
|
/** Custom bridge lines (passthrough) */
|
||||||
|
CUSTOM(7);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun ofRaw(raw: Int): TransportType? {
|
||||||
|
return values().firstOrNull { it.raw == raw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for starting Tor
|
||||||
|
*
|
||||||
|
* Generated class from Pigeon that represents data sent in messages.
|
||||||
|
*/
|
||||||
|
data class TorConfiguration (
|
||||||
|
/** Transport type to use */
|
||||||
|
val transport: TransportType,
|
||||||
|
/** Bridge lines for the transport (empty for direct connection) */
|
||||||
|
val bridgeLines: List<String>,
|
||||||
|
/** Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl") */
|
||||||
|
val entryNodeCountries: String? = null,
|
||||||
|
/** Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se") */
|
||||||
|
val exitNodeCountries: String? = null,
|
||||||
|
/** If true, never use nodes outside specified countries */
|
||||||
|
val strictNodes: Boolean? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): TorConfiguration {
|
||||||
|
val transport = pigeonVar_list[0] as TransportType
|
||||||
|
val bridgeLines = pigeonVar_list[1] as List<String>
|
||||||
|
val entryNodeCountries = pigeonVar_list[2] as String?
|
||||||
|
val exitNodeCountries = pigeonVar_list[3] as String?
|
||||||
|
val strictNodes = pigeonVar_list[4] as Boolean?
|
||||||
|
return TorConfiguration(transport, bridgeLines, entryNodeCountries, exitNodeCountries, strictNodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
transport,
|
||||||
|
bridgeLines,
|
||||||
|
entryNodeCountries,
|
||||||
|
exitNodeCountries,
|
||||||
|
strictNodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is TorConfiguration) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current Tor status
|
||||||
|
*
|
||||||
|
* Generated class from Pigeon that represents data sent in messages.
|
||||||
|
*/
|
||||||
|
data class TorStatus (
|
||||||
|
/** Whether Tor is running */
|
||||||
|
val isRunning: Boolean,
|
||||||
|
/** SOCKS proxy port (if running) */
|
||||||
|
val socksPort: Long? = null,
|
||||||
|
/** Bootstrap progress (0-100) */
|
||||||
|
val bootstrapProgress: Long,
|
||||||
|
/** Current circuit ID */
|
||||||
|
val currentCircuit: String? = null,
|
||||||
|
/** Exit node country code */
|
||||||
|
val exitNodeCountry: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): TorStatus {
|
||||||
|
val isRunning = pigeonVar_list[0] as Boolean
|
||||||
|
val socksPort = pigeonVar_list[1] as Long?
|
||||||
|
val bootstrapProgress = pigeonVar_list[2] as Long
|
||||||
|
val currentCircuit = pigeonVar_list[3] as String?
|
||||||
|
val exitNodeCountry = pigeonVar_list[4] as String?
|
||||||
|
return TorStatus(isRunning, socksPort, bootstrapProgress, currentCircuit, exitNodeCountry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
isRunning,
|
||||||
|
socksPort,
|
||||||
|
bootstrapProgress,
|
||||||
|
currentCircuit,
|
||||||
|
exitNodeCountry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is TorStatus) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log message from Tor
|
||||||
|
*
|
||||||
|
* Generated class from Pigeon that represents data sent in messages.
|
||||||
|
*/
|
||||||
|
data class TorLogMessage (
|
||||||
|
/** Log severity (NOTICE, WARN, ERR, DEBUG) */
|
||||||
|
val severity: String,
|
||||||
|
/** Log message */
|
||||||
|
val message: String,
|
||||||
|
/** Timestamp (milliseconds since epoch) */
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): TorLogMessage {
|
||||||
|
val severity = pigeonVar_list[0] as String
|
||||||
|
val message = pigeonVar_list[1] as String
|
||||||
|
val timestamp = pigeonVar_list[2] as Long
|
||||||
|
return TorLogMessage(severity, message, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is TorLogMessage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return TorApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
|
private open class TorApiPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return when (type) {
|
||||||
|
129.toByte() -> {
|
||||||
|
return (readValue(buffer) as Long?)?.let {
|
||||||
|
TransportType.ofRaw(it.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
130.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
TorConfiguration.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
131.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
TorStatus.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
132.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
TorLogMessage.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
when (value) {
|
||||||
|
is TransportType -> {
|
||||||
|
stream.write(129)
|
||||||
|
writeValue(stream, value.raw.toLong())
|
||||||
|
}
|
||||||
|
is TorConfiguration -> {
|
||||||
|
stream.write(130)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is TorStatus -> {
|
||||||
|
stream.write(131)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
is TorLogMessage -> {
|
||||||
|
stream.write(132)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
|
else -> super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host API (Flutter -> Native)
|
||||||
|
*
|
||||||
|
* Generated interface from Pigeon that represents a handler of messages from Flutter.
|
||||||
|
*/
|
||||||
|
interface TorApi {
|
||||||
|
/**
|
||||||
|
* Start Tor with the given configuration
|
||||||
|
* Returns a Future to avoid blocking the main thread
|
||||||
|
*/
|
||||||
|
fun startTor(config: TorConfiguration, callback: (Result<Long>) -> Unit)
|
||||||
|
/** Stop Tor */
|
||||||
|
fun stopTor(callback: (Result<Unit>) -> Unit)
|
||||||
|
/** Get current status */
|
||||||
|
fun getStatus(): TorStatus
|
||||||
|
/** Request a new Tor identity (new circuit) */
|
||||||
|
fun requestNewIdentity()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by TorApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
TorApiPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `TorApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: TorApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.startTor$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val configArg = args[0] as TorConfiguration
|
||||||
|
api.startTor(configArg) { result: Result<Long> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(TorApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(TorApiPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.stopTor$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.stopTor{ result: Result<Unit> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(TorApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
reply.reply(TorApiPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.getStatus$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.getStatus())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
TorApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.requestNewIdentity()
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
TorApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Flutter API (Native -> Flutter)
|
||||||
|
*
|
||||||
|
* Generated class from Pigeon that represents Flutter messages that can be called from Kotlin.
|
||||||
|
*/
|
||||||
|
class TorLogApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
|
||||||
|
companion object {
|
||||||
|
/** The codec used by TorLogApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
TorApiPigeonCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Called when a log message is received */
|
||||||
|
fun onLogMessage(logArg: TorLogMessage, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(logArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Called when status changes */
|
||||||
|
fun onStatusChanged(statusArg: TorStatus, callback: (Result<Unit>) -> Unit)
|
||||||
|
{
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
val channelName = "dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$separatedMessageChannelSuffix"
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
|
channel.send(listOf(statusArg)) {
|
||||||
|
if (it is List<*>) {
|
||||||
|
if (it.size > 1) {
|
||||||
|
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
||||||
|
} else {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(TorApiPigeonUtils.createConnectionError(channelName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface IPtProxyController {
|
||||||
|
fun start(proxyType: TransportType, proxy: String): Long
|
||||||
|
fun stop(proxyType: TransportType)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by IPtProxyController. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
TorApiPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `IPtProxyController` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: IPtProxyController?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.start$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val proxyTypeArg = args[0] as TransportType
|
||||||
|
val proxyArg = args[1] as String
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.start(proxyTypeArg, proxyArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
TorApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val proxyTypeArg = args[0] as TransportType
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.stop(proxyTypeArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
TorApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package eu.weblibre.flutter_tor
|
||||||
|
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
|
||||||
|
*
|
||||||
|
* Once you have built the plugin's example app, you can run these tests from the command
|
||||||
|
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
|
||||||
|
* you can run them directly from IDEs that support JUnit such as Android Studio.
|
||||||
|
*/
|
||||||
|
|
||||||
|
internal class FlutterTorPluginTest {
|
||||||
|
@Test
|
||||||
|
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
|
||||||
|
val plugin = FlutterTorPlugin()
|
||||||
|
|
||||||
|
val call = MethodCall("getPlatformVersion", null)
|
||||||
|
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
|
||||||
|
plugin.onMethodCall(call, mockResult)
|
||||||
|
|
||||||
|
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# flutter_tor_example
|
||||||
|
|
||||||
|
Demonstrates how to use the flutter_tor plugin.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# This file configures the static analysis results for your project (errors,
|
||||||
|
# warnings, and lints).
|
||||||
|
#
|
||||||
|
# This enables the 'recommended' set of lints from `package:lints`.
|
||||||
|
# This set helps identify many issues that may lead to problems when running
|
||||||
|
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||||
|
# style and format.
|
||||||
|
#
|
||||||
|
# If you want a smaller set of lints you can change this to specify
|
||||||
|
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||||
|
# (the recommended set includes the core lints).
|
||||||
|
# The core lints are also what is used by pub.dev for scoring packages.
|
||||||
|
|
||||||
|
include: package:lint/package.yaml
|
||||||
|
# Uncomment the following section to specify additional rules.
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
unawaited_futures: true
|
||||||
|
discarded_futures: true
|
||||||
|
collection_methods_unrelated_type: true
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
exclude:
|
||||||
|
- "**.g.dart"
|
||||||
|
- "**.swagger.dart"
|
||||||
|
- "**.freezed.dart"
|
||||||
|
- "**.chopper.dart"
|
||||||
|
# For more information about the core and recommended set of lints, see
|
||||||
|
# https://dart.dev/go/core-lints
|
||||||
|
|
||||||
|
# For additional information about configuring this file, see
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "eu.weblibre.flutter_tor_example"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "eu.weblibre.flutter_tor_example"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="flutter_tor_example"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package eu.weblibre.flutter_tor_example
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath =
|
||||||
|
run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.11.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_tor/flutter_tor.dart';
|
||||||
|
import 'package:socks5_proxy/socks_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Flutter Tor Example',
|
||||||
|
theme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
home: const TorDemoPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TorDemoPage extends StatefulWidget {
|
||||||
|
const TorDemoPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TorDemoPage> createState() => _TorDemoPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TorDemoPageState extends State<TorDemoPage> {
|
||||||
|
final _tor = FlutterTor();
|
||||||
|
final _logs = <TorLogMessage>[];
|
||||||
|
|
||||||
|
TransportType _selectedTransport = TransportType.none;
|
||||||
|
int? _socksPort;
|
||||||
|
int _bootstrapProgress = 0;
|
||||||
|
bool _isRunning = false;
|
||||||
|
String _entryCountries = '';
|
||||||
|
String _exitCountries = '';
|
||||||
|
bool _strictNodes = false;
|
||||||
|
final _bridgeLinesController = TextEditingController();
|
||||||
|
|
||||||
|
// IP test state
|
||||||
|
String? _currentIp;
|
||||||
|
bool _isTestingIp = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Listen to logs
|
||||||
|
_tor.logStream.listen((log) {
|
||||||
|
setState(() {
|
||||||
|
_logs.add(log);
|
||||||
|
if (_logs.length > 100) {
|
||||||
|
_logs.removeAt(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to status changes
|
||||||
|
_tor.statusStream.listen((status) {
|
||||||
|
print(
|
||||||
|
'DEBUG statusStream: isRunning=${status.isRunning}, socksPort=${status.socksPort}, bootstrap=${status.bootstrapProgress}',
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isRunning = status.isRunning;
|
||||||
|
final newPort = status.socksPort?.toInt();
|
||||||
|
if (newPort != _socksPort) {
|
||||||
|
print('DEBUG: Port changed from $_socksPort to $newPort');
|
||||||
|
}
|
||||||
|
_socksPort = newPort;
|
||||||
|
_bootstrapProgress = status.bootstrapProgress.toInt();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to bootstrap progress
|
||||||
|
_tor.bootstrapProgressStream.listen((progress) {
|
||||||
|
setState(() {
|
||||||
|
_bootstrapProgress = progress;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startTor() async {
|
||||||
|
try {
|
||||||
|
final bridgeLines = _bridgeLinesController.text
|
||||||
|
.split('\n')
|
||||||
|
.where((line) => line.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final config = TorConfiguration(
|
||||||
|
transport: _selectedTransport,
|
||||||
|
bridgeLines: bridgeLines,
|
||||||
|
entryNodeCountries: _entryCountries.isEmpty ? null : _entryCountries,
|
||||||
|
exitNodeCountries: _exitCountries.isEmpty ? null : _exitCountries,
|
||||||
|
strictNodes: _strictNodes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final socksPort = await _tor.start(config);
|
||||||
|
print('DEBUG startTor result: socksPort=${socksPort}');
|
||||||
|
setState(() {
|
||||||
|
_socksPort = socksPort.toInt();
|
||||||
|
print('DEBUG: Set _socksPort to $_socksPort from start result');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Tor started on port ${socksPort}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to start Tor: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopTor() async {
|
||||||
|
try {
|
||||||
|
await _tor.stop();
|
||||||
|
setState(() {
|
||||||
|
_socksPort = null;
|
||||||
|
_bootstrapProgress = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Tor stopped')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to stop Tor: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestNewIdentity() async {
|
||||||
|
try {
|
||||||
|
await _tor.requestNewIdentity();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Requested new Tor identity')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to request new identity: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testIpAddress() async {
|
||||||
|
if (_socksPort == null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Tor is not running. Start Tor first.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isTestingIp = true;
|
||||||
|
_currentIp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Show which port we're using
|
||||||
|
print('DEBUG: _testIpAddress called');
|
||||||
|
print('DEBUG: Current _socksPort value: $_socksPort');
|
||||||
|
print('DEBUG: Attempting to connect to SOCKS5 proxy on port $_socksPort');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final portToUse = _socksPort!;
|
||||||
|
print('DEBUG: About to connect via SOCKS5 proxy at 127.0.0.1:$portToUse');
|
||||||
|
|
||||||
|
// Create HttpClient object
|
||||||
|
final client = HttpClient();
|
||||||
|
|
||||||
|
// Assign connection factory
|
||||||
|
SocksTCPClient.assignToHttpClient(client, [
|
||||||
|
ProxySettings(InternetAddress.loopbackIPv4, portToUse),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Connect to ifconfig.me through the SOCKS5 proxy
|
||||||
|
_currentIp = await client
|
||||||
|
.getUrl(Uri.parse('https://icanhazip.com/'))
|
||||||
|
.then((x) => x.close())
|
||||||
|
.then((x) => utf8.decodeStream(x));
|
||||||
|
|
||||||
|
print('DEBUG: Connected to ifconfig.me through SOCKS5 proxy');
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Your Tor IP: ${_currentIp}'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('DEBUG: Error: $e');
|
||||||
|
setState(() {
|
||||||
|
_currentIp = 'Error: $e';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to fetch IP: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isTestingIp = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Flutter Tor Example'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Status Card
|
||||||
|
Card(
|
||||||
|
margin: const EdgeInsets.all(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Status: ${_isRunning ? 'Running' : 'Stopped'}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
if (_socksPort != null) Text('SOCKS Port: $_socksPort'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(value: _bootstrapProgress / 100),
|
||||||
|
Text('Bootstrap: $_bootstrapProgress%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<TransportType>(
|
||||||
|
value: _selectedTransport,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Transport Type',
|
||||||
|
),
|
||||||
|
items: TransportType.values.map((transport) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: transport,
|
||||||
|
child: Text(transport.name),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedTransport = value!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _bridgeLinesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Bridge Lines (one per line)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Entry Countries (e.g., de,fr,nl)',
|
||||||
|
),
|
||||||
|
onChanged: (value) => _entryCountries = value,
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Exit Countries (e.g., ch,is,se)',
|
||||||
|
),
|
||||||
|
onChanged: (value) => _exitCountries = value,
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Strict Nodes'),
|
||||||
|
value: _strictNodes,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_strictNodes = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isRunning ? null : _startTor,
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
label: const Text('Start Tor'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isRunning ? _stopTor : null,
|
||||||
|
icon: const Icon(Icons.stop),
|
||||||
|
label: const Text('Stop Tor'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_isRunning) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _requestNewIdentity,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('New Identity'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isTestingIp ? null : _testIpAddress,
|
||||||
|
icon: _isTestingIp
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.public),
|
||||||
|
label: Text(
|
||||||
|
_isTestingIp ? 'Testing...' : 'Test IP Address',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_currentIp != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _currentIp!.startsWith('Error')
|
||||||
|
? Colors.red.shade100
|
||||||
|
: Colors.green.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _currentIp!.startsWith('Error')
|
||||||
|
? Colors.red
|
||||||
|
: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_currentIp!.startsWith('Error')
|
||||||
|
? Icons.error_outline
|
||||||
|
: Icons.check_circle_outline,
|
||||||
|
color: _currentIp!.startsWith('Error')
|
||||||
|
? Colors.red
|
||||||
|
: Colors.green,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_currentIp!.startsWith('Error')
|
||||||
|
? _currentIp!
|
||||||
|
: 'Your Tor IP: $_currentIp',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _currentIp!.startsWith('Error')
|
||||||
|
? Colors.red.shade900
|
||||||
|
: Colors.green.shade900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
Text('Logs:', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _logs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = _logs[index];
|
||||||
|
Color color;
|
||||||
|
switch (log.severity) {
|
||||||
|
case 'ERR':
|
||||||
|
color = Colors.red;
|
||||||
|
break;
|
||||||
|
case 'WARN':
|
||||||
|
color = Colors.orange;
|
||||||
|
break;
|
||||||
|
case 'NOTICE':
|
||||||
|
color = Colors.blue;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
color = Colors.black;
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'[${log.severity}] ${log.message}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: flutter_tor_example
|
||||||
|
description: "Demonstrates how to use the flutter_tor plugin."
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
resolution: workspace
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.4
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter_tor:
|
||||||
|
# When depending on this package from a real application you should use:
|
||||||
|
# flutter_tor: ^x.y.z
|
||||||
|
# See https://dart.dev/tools/pub/dependencies#version-constraints
|
||||||
|
# The example app is bundled with the plugin so we use a path dependency on
|
||||||
|
# the parent directory to use the current plugin's version.
|
||||||
|
path: ../
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
|
# HTTP client for testing Tor connectivity
|
||||||
|
http: any
|
||||||
|
|
||||||
|
# SOCKS5 proxy client for Tor
|
||||||
|
socks5_proxy: any
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/to/font-from-package
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// This is a basic Flutter widget test.
|
||||||
|
//
|
||||||
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_tor_example/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Verify Platform version', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
|
||||||
|
// Verify that platform version is retrieved.
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(Widget widget) => widget is Text &&
|
||||||
|
widget.data!.startsWith('Running on:'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
library flutter_tor;
|
||||||
|
|
||||||
|
export 'src/tor_api.g.dart'
|
||||||
|
show
|
||||||
|
TransportType,
|
||||||
|
TorConfiguration,
|
||||||
|
TorStartResult,
|
||||||
|
TorStatus,
|
||||||
|
TorLogMessage,
|
||||||
|
IPtProxyController;
|
||||||
|
|
||||||
|
export 'src/flutter_tor.dart';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_tor/src/tor_api.g.dart';
|
||||||
|
|
||||||
|
/// Flutter Tor implementation
|
||||||
|
/// Provides a clean Dart API over the Pigeon-generated code
|
||||||
|
class FlutterTor {
|
||||||
|
FlutterTor() {
|
||||||
|
_torLogApi = _TorLogApiImpl(
|
||||||
|
onLog: _logController.add,
|
||||||
|
onStatus: _statusController.add,
|
||||||
|
onBootstrap: _bootstrapController.add,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the Flutter API handler so native can call us
|
||||||
|
TorLogApi.setUp(_torLogApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _torApi = TorApi();
|
||||||
|
late final _TorLogApiImpl _torLogApi;
|
||||||
|
|
||||||
|
final _logController = StreamController<TorLogMessage>.broadcast();
|
||||||
|
final _statusController = StreamController<TorStatus>.broadcast();
|
||||||
|
final _bootstrapController = StreamController<int>.broadcast();
|
||||||
|
|
||||||
|
/// Stream of log messages from Tor
|
||||||
|
Stream<TorLogMessage> get logStream => _logController.stream;
|
||||||
|
|
||||||
|
/// Stream of status changes
|
||||||
|
Stream<TorStatus> get statusStream => _statusController.stream;
|
||||||
|
|
||||||
|
/// Stream of bootstrap progress updates (0-100)
|
||||||
|
Stream<int> get bootstrapProgressStream => _bootstrapController.stream;
|
||||||
|
|
||||||
|
/// Start Tor with the given configuration
|
||||||
|
Future<int> start(TorConfiguration config) async {
|
||||||
|
return await _torApi.startTor(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop Tor
|
||||||
|
Future<void> stop() async {
|
||||||
|
await _torApi.stopTor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current Tor status
|
||||||
|
Future<TorStatus> getStatus() async {
|
||||||
|
return await _torApi.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a new Tor identity (new circuit)
|
||||||
|
Future<void> requestNewIdentity() async {
|
||||||
|
await _torApi.requestNewIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose resources
|
||||||
|
void dispose() {
|
||||||
|
// Unregister the Flutter API handler
|
||||||
|
TorLogApi.setUp(null);
|
||||||
|
|
||||||
|
_logController.close();
|
||||||
|
_statusController.close();
|
||||||
|
_bootstrapController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation of TorLogApi for receiving callbacks from native
|
||||||
|
class _TorLogApiImpl extends TorLogApi {
|
||||||
|
_TorLogApiImpl({
|
||||||
|
required this.onLog,
|
||||||
|
required this.onStatus,
|
||||||
|
required this.onBootstrap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(TorLogMessage) onLog;
|
||||||
|
final void Function(TorStatus) onStatus;
|
||||||
|
final void Function(int) onBootstrap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onLogMessage(TorLogMessage log) {
|
||||||
|
onLog(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onStatusChanged(TorStatus status) {
|
||||||
|
onStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onBootstrapProgress(int progress) {
|
||||||
|
onBootstrap(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
PlatformException _createConnectionError(String channelName) {
|
||||||
|
return PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
|
if (empty) {
|
||||||
|
return <Object?>[];
|
||||||
|
}
|
||||||
|
if (error == null) {
|
||||||
|
return <Object?>[result];
|
||||||
|
}
|
||||||
|
return <Object?>[error.code, error.message, error.details];
|
||||||
|
}
|
||||||
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
|
if (a is List && b is List) {
|
||||||
|
return a.length == b.length &&
|
||||||
|
a.indexed
|
||||||
|
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
|
}
|
||||||
|
if (a is Map && b is Map) {
|
||||||
|
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||||
|
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||||
|
_deepEquals(entry.value, b[entry.key]));
|
||||||
|
}
|
||||||
|
return a == b;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Transport types for Tor connections
|
||||||
|
enum TransportType {
|
||||||
|
/// Direct Tor connection (no bridges)
|
||||||
|
none,
|
||||||
|
/// obfs4 pluggable transport
|
||||||
|
obfs4,
|
||||||
|
/// Snowflake pluggable transport (default broker)
|
||||||
|
snowflake,
|
||||||
|
/// Snowflake via AMP cache
|
||||||
|
snowflakeAmp,
|
||||||
|
/// Meek pluggable transport
|
||||||
|
meek,
|
||||||
|
/// Meek via Azure CDN
|
||||||
|
meekAzure,
|
||||||
|
/// WebTunnel pluggable transport
|
||||||
|
webtunnel,
|
||||||
|
/// Custom bridge lines (passthrough)
|
||||||
|
custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for starting Tor
|
||||||
|
class TorConfiguration {
|
||||||
|
TorConfiguration({
|
||||||
|
required this.transport,
|
||||||
|
required this.bridgeLines,
|
||||||
|
this.entryNodeCountries,
|
||||||
|
this.exitNodeCountries,
|
||||||
|
this.strictNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Transport type to use
|
||||||
|
TransportType transport;
|
||||||
|
|
||||||
|
/// Bridge lines for the transport (empty for direct connection)
|
||||||
|
List<String> bridgeLines;
|
||||||
|
|
||||||
|
/// Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl")
|
||||||
|
String? entryNodeCountries;
|
||||||
|
|
||||||
|
/// Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se")
|
||||||
|
String? exitNodeCountries;
|
||||||
|
|
||||||
|
/// If true, never use nodes outside specified countries
|
||||||
|
bool? strictNodes;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[
|
||||||
|
transport,
|
||||||
|
bridgeLines,
|
||||||
|
entryNodeCountries,
|
||||||
|
exitNodeCountries,
|
||||||
|
strictNodes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList(); }
|
||||||
|
|
||||||
|
static TorConfiguration decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return TorConfiguration(
|
||||||
|
transport: result[0]! as TransportType,
|
||||||
|
bridgeLines: (result[1] as List<Object?>?)!.cast<String>(),
|
||||||
|
entryNodeCountries: result[2] as String?,
|
||||||
|
exitNodeCountries: result[3] as String?,
|
||||||
|
strictNodes: result[4] as bool?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! TorConfiguration || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList())
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current Tor status
|
||||||
|
class TorStatus {
|
||||||
|
TorStatus({
|
||||||
|
required this.isRunning,
|
||||||
|
this.socksPort,
|
||||||
|
required this.bootstrapProgress,
|
||||||
|
this.currentCircuit,
|
||||||
|
this.exitNodeCountry,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether Tor is running
|
||||||
|
bool isRunning;
|
||||||
|
|
||||||
|
/// SOCKS proxy port (if running)
|
||||||
|
int? socksPort;
|
||||||
|
|
||||||
|
/// Bootstrap progress (0-100)
|
||||||
|
int bootstrapProgress;
|
||||||
|
|
||||||
|
/// Current circuit ID
|
||||||
|
String? currentCircuit;
|
||||||
|
|
||||||
|
/// Exit node country code
|
||||||
|
String? exitNodeCountry;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[
|
||||||
|
isRunning,
|
||||||
|
socksPort,
|
||||||
|
bootstrapProgress,
|
||||||
|
currentCircuit,
|
||||||
|
exitNodeCountry,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList(); }
|
||||||
|
|
||||||
|
static TorStatus decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return TorStatus(
|
||||||
|
isRunning: result[0]! as bool,
|
||||||
|
socksPort: result[1] as int?,
|
||||||
|
bootstrapProgress: result[2]! as int,
|
||||||
|
currentCircuit: result[3] as String?,
|
||||||
|
exitNodeCountry: result[4] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! TorStatus || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList())
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log message from Tor
|
||||||
|
class TorLogMessage {
|
||||||
|
TorLogMessage({
|
||||||
|
required this.severity,
|
||||||
|
required this.message,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Log severity (NOTICE, WARN, ERR, DEBUG)
|
||||||
|
String severity;
|
||||||
|
|
||||||
|
/// Log message
|
||||||
|
String message;
|
||||||
|
|
||||||
|
/// Timestamp (milliseconds since epoch)
|
||||||
|
int timestamp;
|
||||||
|
|
||||||
|
List<Object?> _toList() {
|
||||||
|
return <Object?>[
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList(); }
|
||||||
|
|
||||||
|
static TorLogMessage decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
return TorLogMessage(
|
||||||
|
severity: result[0]! as String,
|
||||||
|
message: result[1]! as String,
|
||||||
|
timestamp: result[2]! as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! TorLogMessage || other.runtimeType != runtimeType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _deepEquals(encode(), other.encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
|
int get hashCode => Object.hashAll(_toList())
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
|
const _PigeonCodec();
|
||||||
|
@override
|
||||||
|
void writeValue(WriteBuffer buffer, Object? value) {
|
||||||
|
if (value is int) {
|
||||||
|
buffer.putUint8(4);
|
||||||
|
buffer.putInt64(value);
|
||||||
|
} else if (value is TransportType) {
|
||||||
|
buffer.putUint8(129);
|
||||||
|
writeValue(buffer, value.index);
|
||||||
|
} else if (value is TorConfiguration) {
|
||||||
|
buffer.putUint8(130);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is TorStatus) {
|
||||||
|
buffer.putUint8(131);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
|
} else if (value is TorLogMessage) {
|
||||||
|
buffer.putUint8(132);
|
||||||
|
writeValue(buffer, value.encode());
|
||||||
|
} else {
|
||||||
|
super.writeValue(buffer, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
|
switch (type) {
|
||||||
|
case 129:
|
||||||
|
final value = readValue(buffer) as int?;
|
||||||
|
return value == null ? null : TransportType.values[value];
|
||||||
|
case 130:
|
||||||
|
return TorConfiguration.decode(readValue(buffer)!);
|
||||||
|
case 131:
|
||||||
|
return TorStatus.decode(readValue(buffer)!);
|
||||||
|
case 132:
|
||||||
|
return TorLogMessage.decode(readValue(buffer)!);
|
||||||
|
default:
|
||||||
|
return super.readValueOfType(type, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host API (Flutter -> Native)
|
||||||
|
class TorApi {
|
||||||
|
/// Constructor for [TorApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
/// Start Tor with the given configuration
|
||||||
|
/// Returns a Future to avoid blocking the main thread
|
||||||
|
Future<int> startTor(TorConfiguration config) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as int?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop Tor
|
||||||
|
Future<void> stopTor() async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current status
|
||||||
|
Future<TorStatus> getStatus() async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as TorStatus?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a new Tor identity (new circuit)
|
||||||
|
Future<void> requestNewIdentity() async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flutter API (Native -> Flutter)
|
||||||
|
abstract class TorLogApi {
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
/// Called when a log message is received
|
||||||
|
void onLogMessage(TorLogMessage log);
|
||||||
|
|
||||||
|
/// Called when status changes
|
||||||
|
void onStatusChanged(TorStatus status);
|
||||||
|
|
||||||
|
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||||
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
{
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
assert(message != null,
|
||||||
|
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.');
|
||||||
|
final List<Object?> args = (message as List<Object?>?)!;
|
||||||
|
final TorLogMessage? arg_log = (args[0] as TorLogMessage?);
|
||||||
|
assert(arg_log != null,
|
||||||
|
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.');
|
||||||
|
try {
|
||||||
|
api.onLogMessage(arg_log!);
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
|
||||||
|
binaryMessenger: binaryMessenger);
|
||||||
|
if (api == null) {
|
||||||
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
|
} else {
|
||||||
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
|
assert(message != null,
|
||||||
|
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.');
|
||||||
|
final List<Object?> args = (message as List<Object?>?)!;
|
||||||
|
final TorStatus? arg_status = (args[0] as TorStatus?);
|
||||||
|
assert(arg_status != null,
|
||||||
|
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.');
|
||||||
|
try {
|
||||||
|
api.onStatusChanged(arg_status!);
|
||||||
|
return wrapResponse(empty: true);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
return wrapResponse(error: e);
|
||||||
|
} catch (e) {
|
||||||
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IPtProxyController {
|
||||||
|
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<int> start(TransportType proxyType, String proxy) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as int?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop(TransportType proxyType) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
@ConfigurePigeon(
|
||||||
|
PigeonOptions(
|
||||||
|
dartOut: 'lib/src/tor_api.g.dart',
|
||||||
|
dartOptions: DartOptions(),
|
||||||
|
kotlinOut:
|
||||||
|
'android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt',
|
||||||
|
kotlinOptions: KotlinOptions(package: 'eu.weblibre.flutter_tor.generated'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
/// Transport types for Tor connections
|
||||||
|
enum TransportType {
|
||||||
|
/// Direct Tor connection (no bridges)
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// obfs4 pluggable transport
|
||||||
|
obfs4,
|
||||||
|
|
||||||
|
/// Snowflake pluggable transport (default broker)
|
||||||
|
snowflake,
|
||||||
|
|
||||||
|
/// Snowflake via AMP cache
|
||||||
|
snowflakeAmp,
|
||||||
|
|
||||||
|
/// Meek pluggable transport
|
||||||
|
meek,
|
||||||
|
|
||||||
|
/// Meek via Azure CDN
|
||||||
|
meekAzure,
|
||||||
|
|
||||||
|
/// WebTunnel pluggable transport
|
||||||
|
webtunnel,
|
||||||
|
|
||||||
|
/// Custom bridge lines (passthrough)
|
||||||
|
custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for starting Tor
|
||||||
|
class TorConfiguration {
|
||||||
|
TorConfiguration({
|
||||||
|
required this.transport,
|
||||||
|
required this.bridgeLines,
|
||||||
|
this.entryNodeCountries,
|
||||||
|
this.exitNodeCountries,
|
||||||
|
this.strictNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Transport type to use
|
||||||
|
final TransportType transport;
|
||||||
|
|
||||||
|
/// Bridge lines for the transport (empty for direct connection)
|
||||||
|
final List<String> bridgeLines;
|
||||||
|
|
||||||
|
/// Entry node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "de,fr,nl")
|
||||||
|
final String? entryNodeCountries;
|
||||||
|
|
||||||
|
/// Exit node countries (ISO 3166-1 alpha-2, comma-separated, e.g., "ch,is,se")
|
||||||
|
final String? exitNodeCountries;
|
||||||
|
|
||||||
|
/// If true, never use nodes outside specified countries
|
||||||
|
final bool? strictNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current Tor status
|
||||||
|
class TorStatus {
|
||||||
|
TorStatus({
|
||||||
|
required this.isRunning,
|
||||||
|
this.socksPort,
|
||||||
|
required this.bootstrapProgress,
|
||||||
|
this.currentCircuit,
|
||||||
|
this.exitNodeCountry,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether Tor is running
|
||||||
|
final bool isRunning;
|
||||||
|
|
||||||
|
/// SOCKS proxy port (if running)
|
||||||
|
final int? socksPort;
|
||||||
|
|
||||||
|
/// Bootstrap progress (0-100)
|
||||||
|
final int bootstrapProgress;
|
||||||
|
|
||||||
|
/// Current circuit ID
|
||||||
|
final String? currentCircuit;
|
||||||
|
|
||||||
|
/// Exit node country code
|
||||||
|
final String? exitNodeCountry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log message from Tor
|
||||||
|
class TorLogMessage {
|
||||||
|
TorLogMessage({
|
||||||
|
required this.severity,
|
||||||
|
required this.message,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Log severity (NOTICE, WARN, ERR, DEBUG)
|
||||||
|
final String severity;
|
||||||
|
|
||||||
|
/// Log message
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Timestamp (milliseconds since epoch)
|
||||||
|
final int timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host API (Flutter -> Native)
|
||||||
|
@HostApi()
|
||||||
|
abstract class TorApi {
|
||||||
|
/// Start Tor with the given configuration
|
||||||
|
/// Returns a Future to avoid blocking the main thread
|
||||||
|
@async
|
||||||
|
int startTor(TorConfiguration config);
|
||||||
|
|
||||||
|
/// Stop Tor
|
||||||
|
@async
|
||||||
|
void stopTor();
|
||||||
|
|
||||||
|
/// Get current status
|
||||||
|
TorStatus getStatus();
|
||||||
|
|
||||||
|
/// Request a new Tor identity (new circuit)
|
||||||
|
void requestNewIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flutter API (Native -> Flutter)
|
||||||
|
@FlutterApi()
|
||||||
|
abstract class TorLogApi {
|
||||||
|
/// Called when a log message is received
|
||||||
|
void onLogMessage(TorLogMessage log);
|
||||||
|
|
||||||
|
/// Called when status changes
|
||||||
|
void onStatusChanged(TorStatus status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostApi()
|
||||||
|
abstract class IPtProxyController {
|
||||||
|
int start(TransportType proxyType, String proxy);
|
||||||
|
void stop(TransportType proxyType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: flutter_tor
|
||||||
|
description: "A new Flutter plugin project."
|
||||||
|
version: 0.0.1
|
||||||
|
homepage:
|
||||||
|
resolution: workspace
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.4
|
||||||
|
flutter: '>=3.3.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
lint: ^2.8.0
|
||||||
|
pigeon: ^26.1.5
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
# This section identifies this Flutter project as a plugin project.
|
||||||
|
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
|
||||||
|
# which should be registered in the plugin registry. This is required for
|
||||||
|
# using method channels.
|
||||||
|
# The Android 'package' specifies package in which the registered class is.
|
||||||
|
# This is required for using method channels on Android.
|
||||||
|
# The 'ffiPlugin' specifies that native code should be built and bundled.
|
||||||
|
# This is required for using `dart:ffi`.
|
||||||
|
# All these are used by the tooling to maintain consistency when
|
||||||
|
# adding or updating assets for this project.
|
||||||
|
plugin:
|
||||||
|
platforms:
|
||||||
|
android:
|
||||||
|
package: eu.weblibre.flutter_tor
|
||||||
|
pluginClass: FlutterTorPlugin
|
||||||
|
|
||||||
|
# To add assets to your plugin package, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
#
|
||||||
|
# For details regarding assets in packages, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
#
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# To add custom fonts to your plugin package, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts in packages, see
|
||||||
|
# https://flutter.dev/to/font-from-package
|
||||||
@@ -6,6 +6,8 @@ workspace:
|
|||||||
- app/
|
- app/
|
||||||
- packages/flutter_mozilla_components
|
- packages/flutter_mozilla_components
|
||||||
- packages/flutter_mozilla_components/example
|
- packages/flutter_mozilla_components/example
|
||||||
|
- packages/flutter_tor
|
||||||
|
- packages/flutter_tor/example
|
||||||
- packages/simple_intent_receiver
|
- packages/simple_intent_receiver
|
||||||
- packages/simple_intent_receiver/example
|
- packages/simple_intent_receiver/example
|
||||||
- packages/locale_resolver
|
- packages/locale_resolver
|
||||||
|
|||||||
Reference in New Issue
Block a user