v3.2.0 — DPI hardening, notification consolidation, disconnect refactor
User-Agent spoofed to Chrome on Android (was Dart/3.x). Domain renamed to userevolt.app. Branding to Revolt VPN. Removed duplicate notification, patched flutter_vless for custom icon + dark background. Merged 5 disconnect paths into single idempotent gate, fixed stopVless timeout silently lying about state. Removed kDebugMode guard from AdMob bypass.
This commit is contained in:
1 parent
cf50c3a01b
commit
54f949ca8e
20 files changed
+552
-402
No files matched your search
@@ -64,8 +64,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
timer.stop();
|
||||
await vpn.disconnect();
|
||||
await timer.disconnect();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class VpnNotificationManager {
|
||||
static final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static bool _initialized = false;
|
||||
static String? _lastTimeLeft;
|
||||
static String? _lastSpeedKbps;
|
||||
|
||||
// Android channels are immutable — bump this if settings change.
|
||||
static const _channelId = 'revolt_vpn_v4';
|
||||
static const _channelName = 'Revolt VPN';
|
||||
static const _notifId = 888;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
@@ -15,7 +23,6 @@ class VpnNotificationManager {
|
||||
|
||||
await _plugin.initialize(initSettings);
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
@@ -26,77 +33,61 @@ class VpnNotificationManager {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static String? _lastTimeLeft;
|
||||
static String? _lastSpeedKbps;
|
||||
|
||||
static Future<void> showOrUpdateStatus({
|
||||
required String timeLeft,
|
||||
required String speedKbps,
|
||||
}) async {
|
||||
// Skip update if nothing changed — prevents notification flicker
|
||||
// on Android skins that animate every notification update.
|
||||
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
|
||||
_lastTimeLeft = timeLeft;
|
||||
_lastTimeLeft = timeLeft;
|
||||
_lastSpeedKbps = speedKbps;
|
||||
|
||||
if (!_initialized) await init();
|
||||
|
||||
// NOTE: The channel ID includes 'v2' to force Android to create a fresh
|
||||
// notification channel. Android channels are immutable once created — if
|
||||
// the old channel was created without 'ongoing: true', users would be
|
||||
// stuck with a swipeable notification forever.
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'revolt_vpn_status_v2',
|
||||
'VPN Status',
|
||||
channelDescription: 'Shown while VPN is active — non-dismissable',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
// Non-dismissable while VPN is running
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
// Silent updates — no sound or vibration each time stats refresh
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
onlyAlertOnce: true,
|
||||
// Hide the auto-generated timestamp
|
||||
showWhen: false,
|
||||
// Show on lock screen
|
||||
visibility: NotificationVisibility.public,
|
||||
// Extra insurance against swipe-to-dismiss on aggressive Android skins
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
final bodyText = 'Time: $timeLeft • $speedKbps';
|
||||
|
||||
final bigPictureStyle = BigPictureStyleInformation(
|
||||
const DrawableResourceAndroidBitmap('notification_banner'),
|
||||
largeIcon: const DrawableResourceAndroidBitmap('notification_icon'),
|
||||
contentTitle: 'Revolt VPN',
|
||||
summaryText: bodyText,
|
||||
htmlFormatContentTitle: false,
|
||||
htmlFormatSummaryText: false,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: 'Session timer and speed',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
showWhen: false,
|
||||
visibility: NotificationVisibility.public,
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
colorized: true,
|
||||
color: const ui.Color(0xFF0D1117),
|
||||
styleInformation: bigPictureStyle,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(android: androidDetails);
|
||||
|
||||
try {
|
||||
await _plugin.show(
|
||||
888, // Fixed ID — always updates the same notification
|
||||
'REVOLT VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (e) {
|
||||
// If showing fails (e.g. permission revoked), try to recover by
|
||||
// re-initializing and retrying once.
|
||||
_initialized = false;
|
||||
try {
|
||||
await init();
|
||||
await _plugin.show(
|
||||
888,
|
||||
'REVOLT VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
} catch (_) {
|
||||
// Give up — the VPN still works, just without the notification.
|
||||
}
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> cancel() async {
|
||||
if (!_initialized) return;
|
||||
await _plugin.cancel(888);
|
||||
await _plugin.cancel(_notifId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class WebsiteButton extends StatelessWidget {
|
||||
const WebsiteButton({super.key});
|
||||
|
||||
static Future<void> launchWebsite() async {
|
||||
final Uri url = Uri.parse('https://getrevolt.app');
|
||||
final Uri url = Uri.parse('https://userevolt.app');
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,19 @@ import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/components/online_dot.dart';
|
||||
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
||||
|
||||
// ── lib/components/status_bar.dart ──────────────────────────────────────────
|
||||
// Two-card layout at the bottom of the main screen.
|
||||
//
|
||||
// Top card: server location from ServerList provider (flag + city).
|
||||
// Tappable → opens server selector. Always visible.
|
||||
// Bottom card: connection status (health dot + label + lock icon).
|
||||
//
|
||||
// Separated into two rows — no more RenderFlex overflow and ready for
|
||||
// multi-server picker.
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
const StatusBar({super.key});
|
||||
@@ -14,57 +26,97 @@ class StatusBar extends StatelessWidget {
|
||||
builder: (context, vpn, timer, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
final statusLabel = isConnected
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
|
||||
// Read from the ServerList provider — the source of truth for
|
||||
// which server is selected and what it looks like.
|
||||
final server = context.watch<ServerList>().selected;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Connection status — wrapped in Flexible to prevent overflow
|
||||
// on narrow screens (the Row must fit both the status text
|
||||
// and the online dot without RenderFlex errors).
|
||||
if (isConnected) ...[
|
||||
Image.asset('assets/finland_flag_256.png', width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(
|
||||
child: Text('Helsinki, Finland', style: TextStyle(
|
||||
color: AppColors.accent, fontWeight: FontWeight.w600,
|
||||
fontSize: 13, letterSpacing: 0.5,
|
||||
), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
] else ...[
|
||||
Icon(
|
||||
isConnecting ? Icons.sync : Icons.shield_outlined,
|
||||
size: 18,
|
||||
color: isConnecting ? AppColors.accent : AppColors.slate70,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
vpn.statusMessage.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: isConnecting ? AppColors.accent : AppColors.slate70,
|
||||
fontWeight: FontWeight.w600, fontSize: 13, letterSpacing: 1,
|
||||
// ── Server location ──────────────────────────────────────────
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (_, __, ___) => const SelectionScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(server.flagAsset, width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(server.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
)),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textDim, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Server health
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
label: isConnected
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'),
|
||||
// ── Connection status ────────────────────────────────────────
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
label: statusLabel,
|
||||
),
|
||||
const Spacer(),
|
||||
if (isConnected)
|
||||
const Icon(Icons.lock,
|
||||
color: AppColors.green50, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user