Android CI / android (push) Canceled after 0s
Regression frome67f0b9, shipped in 3.3.7. Reconnect repairs an orphaned proxy by tearing the runtime down and re-attaching to the session the device already holds. It does that through SessionTimer.disconnect(), whiche67f0b9taught to hand the session back to the server - so it destroyed the very session the following connect(resume: true) then asked for. Unawaited, so it raced: the resume either found the session already gone or removed moments later, surfacing as "Session not activated". disconnect() now takes release, and the Reconnect path passes false. Every other caller still hands the session back. Narrow in practice: SOCKS5 mode only, only the Settings Reconnect button, and the main-screen connect was unaffected because it mints a new session anyway. Co-Authored-By: Claude Opus 5 <[email protected]>
293 lines
9.9 KiB
Dart
293 lines
9.9 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;
|
|
}
|
|
|
|
final saved = await ConnectionSettings.setMode(next);
|
|
if (!mounted) return;
|
|
if (!saved) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not save connection mode.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_mode = next;
|
|
_lastSocksTest = null;
|
|
});
|
|
widget.onChanged?.call(next);
|
|
}
|
|
|
|
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
|
// session already held. No ad, no new session.
|
|
Future<void> _reconnect() async {
|
|
if (_reconnecting) return;
|
|
final vpn = context.read<VpnConnection>();
|
|
final timer = context.read<SessionTimer>();
|
|
|
|
setState(() => _reconnecting = true);
|
|
try {
|
|
await timer.disconnect(release: false);
|
|
if (!mounted) return;
|
|
if (await vpn.connect(resume: true)) {
|
|
if (!mounted) return;
|
|
await timer.start(resetSupportClaim: false);
|
|
}
|
|
} 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|