psiphon_mobileproxy 0.0.1
psiphon_mobileproxy: ^0.0.1 copied to clipboard
Flutter plugin that runs a Psiphon tunnel on-device behind a local HTTP proxy, so an app can route chosen traffic through Psiphon on Android and iOS without any VPN permission.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:psiphon_mobileproxy/psiphon_mobileproxy.dart';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Psiphon Mobileproxy',
theme: ThemeData(
colorSchemeSeed: const Color(0xFF1565C0),
useMaterial3: true,
),
home: const ProxyDemoPage(),
);
}
}
class ProxyDemoPage extends StatefulWidget {
const ProxyDemoPage({super.key});
@override
State<ProxyDemoPage> createState() => _ProxyDemoPageState();
}
class _ProxyDemoPageState extends State<ProxyDemoPage>
with WidgetsBindingObserver {
final _psiphon = PsiphonMobileproxy();
final _configController = TextEditingController();
String _platformVersion = 'Unknown';
ProxyInfo? _proxy;
bool _busy = false;
String _log = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_psiphon.getPlatformVersion().then((version) {
if (mounted) setState(() => _platformVersion = version ?? 'unknown');
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_configController.dispose();
// Not awaited: dispose is synchronous, and the plugin also tears the
// tunnel down when the engine detaches.
unawaited(_psiphon.stop());
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Releasing the tunnel on detach is the habit worth copying into a real
// app: until it is stopped, Psiphon holds a connection open and keeps
// using battery and data.
if (state == AppLifecycleState.detached) {
unawaited(_psiphon.stop());
}
}
void _appendLog(String message) {
final timestamp = TimeOfDay.now().format(context);
setState(() => _log = '[$timestamp] $message\n$_log');
}
Future<void> _start() async {
final config = _configController.text.trim();
if (config.isEmpty) {
_appendLog('Paste a Psiphon config first — see the README.');
return;
}
setState(() => _busy = true);
_appendLog('Connecting the tunnel, this can take a while...');
try {
final proxy = await _psiphon.start(psiphonConfig: config);
if (!mounted) return;
setState(() => _proxy = proxy);
_appendLog('Started proxy at ${proxy.address}');
} on TunnelTimeoutException catch (e) {
_appendLog('Timed out: ${e.message}');
} on PsiphonMobileproxyException catch (e) {
_appendLog('Failed to start: ${e.message}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _stop() async {
setState(() => _busy = true);
try {
await _psiphon.stop();
if (!mounted) return;
setState(() => _proxy = null);
_appendLog('Stopped the proxy and disconnected the tunnel');
} on PsiphonMobileproxyException catch (e) {
_appendLog('Failed to stop: ${e.message}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _testConnection() async {
final proxy = _proxy;
if (proxy == null) return;
setState(() => _busy = true);
_appendLog('Fetching https://example.com through ${proxy.address} ...');
final httpClient = HttpClient();
try {
httpClient.findProxy = (uri) => 'PROXY ${proxy.address}';
final request = await httpClient
.getUrl(Uri.parse('https://example.com'))
.timeout(const Duration(seconds: 30));
final response =
await request.close().timeout(const Duration(seconds: 30));
await response.drain<void>();
_appendLog('Success: HTTP ${response.statusCode}');
} catch (e) {
_appendLog('Request failed: $e');
} finally {
httpClient.close();
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRunning = _proxy != null;
return Scaffold(
appBar: AppBar(title: const Text('Psiphon Mobileproxy Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Running on $_platformVersion',
style: theme.textTheme.bodySmall),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Psiphon config', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'The JSON document issued to you by the Psiphon team. This '
'package cannot ship one; see the README for how to '
'request it.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
TextField(
controller: _configController,
enabled: !isRunning,
minLines: 3,
maxLines: 6,
style: const TextStyle(fontFamily: 'monospace'),
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '{"PropagationChannelId": "...", ...}',
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: _busy || isRunning ? null : _start,
child: const Text('Start'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _busy || !isRunning ? null : _stop,
child: const Text('Stop'),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
Card(
color: isRunning
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(isRunning ? Icons.check_circle : Icons.circle_outlined),
const SizedBox(width: 12),
Expanded(
child: Text(
isRunning ? 'Running at ${_proxy!.address}' : 'Stopped',
),
),
if (isRunning)
TextButton(
onPressed: _busy ? null : _testConnection,
child: const Text('Test connection'),
),
],
),
),
),
const SizedBox(height: 16),
Text('Log', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(_log.isEmpty ? 'No activity yet.' : _log),
),
],
),
);
}
}