refactor(app): debloat 3.3.1 and fix startup restoration

Maintainer changes on top of PR #2 (Naveax). Reduces the release to two
deliberate connection modes and resolves the issues found in review:

- Lock-screen privacy: move VISIBILITY_PRIVATE and a generic publicVersion
  into MainActivity.updateVpnNotification. The PR's fix lived in an
  unreferenced class and never reached the live notification.
- Haptics: restore the default to off, and reduce HapticSettings to a
  single success() on connect/disconnect via the native channel.
- SOCKS5: make it a real proxyOnly local-proxy mode rather than a TUN
  connection under a different label. TUN stays the default.
- Health probe: restore the idle-only guard so /health is no longer polled
  every 30s while connected.
- Recovery: add a _connectionEpoch counter so connect() aborts an
  in-flight Extreme recovery.
- Startup restoration: rebuild the recovery snapshot from the still-active
  session after an app-process restart (TUN), and surface an honest
  orphaned state with a Reconnect action in proxy mode.

Drops per-app routing entirely. It only ever functioned in TUN --
addDisallowedApplication lives inside the VPN-interface setup that
proxyOnly skips -- and its allowlist half had no Dart caller.

Also removes flutter_local_notifications, the global tap-haptic surface,
and unused runtime telemetry.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-08-31 23:31:51 +03:00
1 parent a71161e7e5
commit 6fcde9dd7b
18 files changed
+271 -1027

No files matched your search

@@ -1,329 +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/installed_apps_service.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class AppRoutingTile extends StatefulWidget {
final ConnectionMode connectionMode;
const AppRoutingTile({super.key, required this.connectionMode});
@override
State<AppRoutingTile> createState() => _AppRoutingTileState();
}
class _AppRoutingTileState extends State<AppRoutingTile> {
AppRoutingMode _mode = ConnectionSettings.routingMode;
int _selectedCount = ConnectionSettings.appPackages.length;
bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (!mounted) return;
setState(() {
_mode = ConnectionSettings.routingMode;
_selectedCount = ConnectionSettings.appPackages.length;
});
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().status;
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
void _showDisconnectFirst() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Disconnect before changing app routing.')),
);
}
Future<void> _changeMode(AppRoutingMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await ConnectionSettings.setRoutingMode(next);
if (mounted) setState(() => _mode = next);
}
Future<void> _openPicker() async {
if (_mode == AppRoutingMode.all) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _AppPicker(mode: _mode),
),
);
if (mounted) {
setState(() => _selectedCount = ConnectionSettings.appPackages.length);
}
}
String get _subtitle {
if (_proxyMode) {
switch (_mode) {
case AppRoutingMode.all:
return 'Transparent SOCKS5 routes all apps automatically.';
case AppRoutingMode.exclude:
return _selectedCount == 0
? 'Transparent SOCKS5 routes all apps. No apps are excluded.'
: 'SOCKS5 routes all apps except $_selectedCount selected bypass app${_selectedCount == 1 ? '' : 's'}.';
case AppRoutingMode.selected:
return _selectedCount == 0
? 'Choose apps for compatibility selected-app SOCKS routing.'
: 'SOCKS5 routes $_selectedCount selected app${_selectedCount == 1 ? '' : 's'} while other launcher apps bypass it.';
}
}
switch (_mode) {
case AppRoutingMode.all:
return 'All apps use the TUN VPN.';
case AppRoutingMode.exclude:
return _selectedCount == 0
? 'No apps excluded from TUN.'
: '$_selectedCount app${_selectedCount == 1 ? '' : 's'} bypass the TUN VPN.';
case AppRoutingMode.selected:
return _selectedCount == 0
? 'Choose at least one app for compatible Selected only routing.'
: '$_selectedCount selected user app${_selectedCount == 1 ? '' : 's'} are targeted; Android network helpers stay available.';
}
}
@override
Widget build(BuildContext context) {
final showPicker = _mode != AppRoutingMode.all;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: const Text(
'App routing',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
_subtitle,
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<AppRoutingMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _changeMode,
items: const [
DropdownMenuItem(
value: AppRoutingMode.all,
child: Text('All apps'),
),
DropdownMenuItem(
value: AppRoutingMode.exclude,
child: Text('Exclude apps'),
),
DropdownMenuItem(
value: AppRoutingMode.selected,
child: Text('Selected only'),
),
],
),
),
),
if (showPicker)
ListTile(
onTap: _openPicker,
leading: const Icon(Icons.apps),
title: Text(
_mode == AppRoutingMode.selected
? 'Choose routed apps'
: 'Choose excluded apps',
),
subtitle: Text(
_mode == AppRoutingMode.selected
? 'Selected user apps are targeted; Android network helpers remain available for compatibility.'
: 'Routing is applied to the selected Android package IDs.',
),
trailing: const Icon(Icons.chevron_right),
),
],
);
}
}
class _AppPicker extends StatefulWidget {
final AppRoutingMode mode;
const _AppPicker({required this.mode});
@override
State<_AppPicker> createState() => _AppPickerState();
}
class _AppPickerState extends State<_AppPicker> {
final Set<String> _selected = <String>{...ConnectionSettings.appPackages};
final TextEditingController _search = TextEditingController();
List<InstalledApp> _apps = const <InstalledApp>[];
bool _loading = true;
String? _error;
String _query = '';
@override
void initState() {
super.initState();
_loadApps();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _loadApps() async {
try {
final apps = await InstalledAppsService.loadLaunchableApps();
if (!mounted) return;
setState(() {
_apps = apps;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_error = 'Could not load installed apps.';
_loading = false;
});
}
}
Future<void> _save() async {
if (widget.mode == AppRoutingMode.selected && _selected.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select at least one app.')),
);
return;
}
await ConnectionSettings.setAppPackages(_selected);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final query = _query.trim().toLowerCase();
final visibleApps = query.isEmpty
? _apps
: _apps
.where(
(app) =>
app.label.toLowerCase().contains(query) ||
app.packageName.toLowerCase().contains(query),
)
.toList();
final title = widget.mode == AppRoutingMode.exclude
? 'Exclude apps'
: 'Selected routed apps';
return Scaffold(
backgroundColor: AppColors.bgDeep,
appBar: AppBar(
backgroundColor: AppColors.bgSurface,
title: Text(title),
actions: [
TextButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _search,
onChanged: (value) => setState(() => _query = value),
decoration: const InputDecoration(
hintText: 'Search apps',
prefixIcon: Icon(Icons.search),
),
),
),
Expanded(child: _buildList(visibleApps)),
],
),
);
}
Widget _buildList(List<InstalledApp> visibleApps) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Text(_error!, style: const TextStyle(color: AppColors.textMuted)),
);
}
if (visibleApps.isEmpty) {
return const Center(
child: Text('No apps found.', style: TextStyle(color: AppColors.textMuted)),
);
}
return ListView.builder(
itemCount: visibleApps.length,
itemBuilder: (context, index) {
final app = visibleApps[index];
final selected = _selected.contains(app.packageName);
return CheckboxListTile(
value: selected,
onChanged: (value) {
setState(() {
if (value == true) {
_selected.add(app.packageName);
} else {
_selected.remove(app.packageName);
}
});
},
secondary: app.iconPng == null
? const CircleAvatar(child: Icon(Icons.android, size: 20))
: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
app.iconPng!,
width: 40,
height: 40,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
title: Text(app.label),
subtitle: Text(
app.packageName,
style: const TextStyle(color: AppColors.textDim, fontSize: 11),
),
);
},
);
}
}
@@ -3,6 +3,7 @@ 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 {
@@ -17,6 +18,7 @@ class ConnectionModeTile extends StatefulWidget {
class _ConnectionModeTileState extends State<ConnectionModeTile> {
ConnectionMode _mode = ConnectionSettings.mode;
bool _testingLocalSocks = false;
bool _reconnecting = false;
LocalSocksTestResult? _lastSocksTest;
@override
@@ -59,14 +61,47 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
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.isStartupRestoration;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect with SOCKS5 first, then run the local test.'),
SnackBar(
content: Text(
orphaned
? 'Credentials were lost when the app restarted. Reconnect first.'
: 'Connect with SOCKS5 first, then run the local test.',
),
),
);
return;
@@ -92,7 +127,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
case ConnectionMode.tun:
return 'TUN: Android VPN routing for normal device traffic.';
case ConnectionMode.proxy:
return 'SOCKS5: authenticated per-session local gateway with transparent Android routing.';
return 'SOCKS5: local authenticated proxy — point apps at 127.0.0.1:<port> with the credentials shown below.';
}
}
@@ -129,12 +164,93 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
),
),
if (_mode == ConnectionMode.proxy) ...[
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(
'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.',
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
),
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.isStartupRestoration;
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),
@@ -51,42 +51,34 @@ class _ResilienceModeTileState extends State<ResilienceModeTile> {
Widget build(BuildContext context) {
final extreme = _mode == ResilienceMode.extreme;
return Consumer<VpnConnection>(
builder: (context, vpn, _) {
final connected = vpn.status == VpnStatus.connected ||
vpn.status == VpnStatus.connecting;
final active = connected ? ' Active: ${vpn.activeTransportProfile}.' : '';
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.$active'
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active',
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'),
),
],
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'),
),
],
),
),
);
}
}