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]>
This commit is contained in:
1 parent
ac52681452
commit
186dc906da
12 files changed
+422
-291
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/power_settings.dart';
|
||||
|
||||
/// Keeping the tunnel alive is an OS concern, not an app one.
|
||||
///
|
||||
/// Android stops background services of apps that are not exempt from Doze, and
|
||||
/// removes the whole process group when its task is swiped away. There are
|
||||
/// exactly two sanctioned ways around that, both requiring one user tap, and
|
||||
/// neither can be enabled programmatically. This tile surfaces both.
|
||||
class BackgroundReliabilityTile extends StatefulWidget {
|
||||
const BackgroundReliabilityTile({super.key});
|
||||
|
||||
@override
|
||||
State<BackgroundReliabilityTile> createState() =>
|
||||
_BackgroundReliabilityTileState();
|
||||
}
|
||||
|
||||
class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
|
||||
with WidgetsBindingObserver {
|
||||
bool? _batteryExempt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// The exemption is granted in a system dialog, so the only reliable moment
|
||||
// to re-read it is when we come back to the foreground.
|
||||
if (state == AppLifecycleState.resumed) _refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final exempt = await PowerSettings.isBatteryOptimisationDisabled();
|
||||
if (mounted) setState(() => _batteryExempt = exempt);
|
||||
}
|
||||
|
||||
Future<void> _requestExemption() async {
|
||||
final opened = await PowerSettings.requestDisableBatteryOptimisation();
|
||||
if (!mounted || opened) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open battery settings on this device.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openVpnSettings() async {
|
||||
final opened = await PowerSettings.openVpnSettings();
|
||||
if (!mounted || opened) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not open VPN settings.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final exempt = _batteryExempt;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text(
|
||||
'Background reliability',
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Android stops background apps to save power. These two settings '
|
||||
'are what keep the VPN running when the app is closed.',
|
||||
style: TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
exempt == null
|
||||
? 'Battery optimisation: checking…'
|
||||
: exempt
|
||||
? 'Battery optimisation: disabled for Revolt VPN ✓'
|
||||
: 'Battery optimisation: still active — the system may '
|
||||
'stop the VPN in the background.',
|
||||
style: TextStyle(
|
||||
color: exempt == true ? AppColors.accent : AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (exempt == false) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _requestExemption,
|
||||
icon: const Icon(Icons.battery_saver),
|
||||
label: const Text('Allow background activity'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Always-on VPN keeps the tunnel up even after the app is closed '
|
||||
'or the phone restarts. Turn it on for Revolt VPN in Android\'s '
|
||||
'VPN settings, and enable "Block connections without VPN" if you '
|
||||
'want traffic to stop rather than leak when it drops.',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openVpnSettings,
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Open VPN settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
|
||||
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||
final orphaned =
|
||||
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
|
||||
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@@ -174,7 +174,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
// memory that died with it. Saying "Connect" there would be a
|
||||
// lie — the user is already connected.
|
||||
final orphaned = vpn.status == VpnStatus.connected &&
|
||||
vpn.isStartupRestoration;
|
||||
vpn.adoptedRunningRuntime;
|
||||
if (orphaned) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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/vpn_connection.dart';
|
||||
|
||||
class ResilienceModeTile extends StatefulWidget {
|
||||
const ResilienceModeTile({super.key});
|
||||
|
||||
@override
|
||||
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
|
||||
}
|
||||
|
||||
class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||
ResilienceMode _mode = ConnectionSettings.resilienceMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ConnectionSettings.initialize();
|
||||
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
|
||||
}
|
||||
|
||||
bool _vpnBusy() {
|
||||
final status = context.read<VpnConnection>().status;
|
||||
return status == VpnStatus.connected ||
|
||||
status == VpnStatus.connecting ||
|
||||
status == VpnStatus.disconnecting;
|
||||
}
|
||||
|
||||
Future<void> _change(ResilienceMode? next) async {
|
||||
if (next == null || next == _mode) return;
|
||||
if (_vpnBusy()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Disconnect before changing resilience mode.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectionSettings.setResilienceMode(next);
|
||||
if (mounted) setState(() => _mode = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extreme = _mode == ResilienceMode.extreme;
|
||||
|
||||
return ListTile(
|
||||
title: const Text(
|
||||
'Network resilience',
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
extreme
|
||||
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.'
|
||||
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.',
|
||||
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<ResilienceMode>(
|
||||
value: _mode,
|
||||
dropdownColor: AppColors.bgCard,
|
||||
onChanged: _change,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ResilienceMode.standard,
|
||||
child: Text('Standard'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ResilienceMode.extreme,
|
||||
child: Text('Extreme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user