Files
ReVoltVPN/lib/screens/settings/in_settings/connection_mode.dart
T
esefxdzandClaude Opus 5 186dc906da feat(app): keep the tunnel alive via the OS, drop the workarounds
3.3.1 could display "connected" against a tunnel that was no longer
running, with a frozen countdown and no traffic. Three causes, and the
fix for each removes more code than it adds.

Liveness was read from getConnectedServerDelay(), which measures through
AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807. AppConfigs lives in
the :RunSoLibXrayDaemon process, so from the UI process it is always null
and the port always fell back to 10807 -- accidentally correct while the
SOCKS port was fixed, permanently wrong once 3.3.1 made it ephemeral.
Liveness now comes from the OS: is our service process running, and is a
VPN transport actually present.

The foreground notification (id 1) is owned by XrayVPNService via
startForeground, but MainActivity was posting to the same id from the UI
process. When that process died its notification stayed on screen with
nothing left to update it. MainActivity now refuses to post for a dead
runtime and clears the stale notification instead.

Nothing ever asked Android for permission to stay alive. Adds the
battery-optimisation exemption prompt and a deep link to Always-on VPN --
the two sanctioned mechanisms for background survival, each needing one
user tap.

Removes Standard/Extreme resilience and the runtime-restart machinery:
recovery ran off a disconnect broadcast delivered to the app process, so
it could never fire when Android killed that process. Removes startup
restoration and the config-snapshot rebuild along with it; with the OS
keeping the tunnel up there is nothing to restore.

Version 3.3.2+28.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 00:16:05 +03:00

289 lines
9.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class ConnectionModeTile extends StatefulWidget {
final ValueChanged<ConnectionMode>? onChanged;
const ConnectionModeTile({super.key, this.onChanged});
@override
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
}
class _ConnectionModeTileState extends State<ConnectionModeTile> {
ConnectionMode _mode = ConnectionSettings.mode;
bool _testingLocalSocks = false;
bool _reconnecting = false;
LocalSocksTestResult? _lastSocksTest;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.mode);
}
bool _isTunnelBusy(VpnStatus status) {
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
Future<void> _changeMode(ConnectionMode? next) async {
if (next == null || next == _mode) return;
final vpn = context.read<VpnConnection>();
if (_isTunnelBusy(vpn.status)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Disconnect before changing connection mode.'),
),
);
return;
}
await ConnectionSettings.setMode(next);
if (!mounted) return;
setState(() {
_mode = next;
_lastSocksTest = null;
});
widget.onChanged?.call(next);
}
/// Recovers from an orphaned proxy: tear the runtime down and start a fresh
/// session so a readable set of credentials exists again. No rewarded ad —
/// the user is repairing a connection they already paid for, not opening a
/// new one.
Future<void> _reconnect() async {
if (_reconnecting) return;
final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>();
setState(() => _reconnecting = true);
try {
await timer.disconnect();
if (!mounted) return;
if (await vpn.connect()) {
if (!mounted) return;
await timer.start();
}
} finally {
if (mounted) {
setState(() {
_reconnecting = false;
_lastSocksTest = null;
});
}
}
}
Future<void> _testLocalSocks() async {
if (_testingLocalSocks) return;
final vpn = context.read<VpnConnection>();
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
final orphaned =
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
orphaned
? 'Credentials were lost when the app restarted. Reconnect first.'
: 'Connect with SOCKS5 first, then run the local test.',
),
),
);
return;
}
setState(() => _testingLocalSocks = true);
final result = await vpn.testActiveLocalSocks();
if (!mounted) return;
setState(() {
_testingLocalSocks = false;
_lastSocksTest = result;
});
final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${result.message}$latency')),
);
}
String get _subtitle {
switch (_mode) {
case ConnectionMode.tun:
return 'TUN: Android VPN routing for normal device traffic.';
case ConnectionMode.proxy:
return 'SOCKS5: local authenticated proxy — point apps at 127.0.0.1:<port> with the credentials shown below.';
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: const Text(
'Connection mode',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
_subtitle,
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<ConnectionMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _changeMode,
items: const [
DropdownMenuItem(
value: ConnectionMode.tun,
child: Text('TUN'),
),
DropdownMenuItem(
value: ConnectionMode.proxy,
child: Text('SOCKS5'),
),
],
),
),
),
if (_mode == ConnectionMode.proxy) ...[
Consumer<VpnConnection>(
builder: (context, vpn, _) {
final session = vpn.activeSocksSession;
if (session == null) {
// The proxy can be running while this session is unknown:
// Android killed the app process, the foreground service kept
// the listener alive, and its credentials existed only in the
// memory that died with it. Saying "Connect" there would be a
// lie — the user is already connected.
final orphaned = vpn.status == VpnStatus.connected &&
vpn.adoptedRunningRuntime;
if (orphaned) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'The proxy is still running, but its credentials were '
'lost when the app restarted. Reconnect to get a new '
'endpoint.',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _reconnecting ? null : _reconnect,
icon: _reconnecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh),
label: Text(
_reconnecting ? 'Reconnecting…' : 'Reconnect',
),
),
],
),
);
}
return const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(
'Connect to start a local SOCKS5 proxy. Credentials change every session.',
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Proxy: 127.0.0.1:${session.port}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontFamily: 'monospace',
),
),
Text(
'Username: ${session.username}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontFamily: 'monospace',
),
),
Text(
'Password: ${session.password}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
),
);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: OutlinedButton.icon(
onPressed: _testingLocalSocks ? null : _testLocalSocks,
icon: _testingLocalSocks
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
label: Text(
_testingLocalSocks ? 'Testing…' : 'Test Local SOCKS',
),
),
),
if (_lastSocksTest != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
_lastSocksTest!.ok
? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}'
: 'Last test: Failed · ${_lastSocksTest!.message}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
),
],
],
);
}
}