bugfixes: switch fallthrough, SSV debug-gate, session verify, speed math, auto-reconnect, permission recovery, health indicator, lifecycle-safety, GDPR consent, progress feedback

This commit is contained in:
esefxdz committed 2026-07-16 13:40:09 +03:00
1 parent 86baf38149
commit a84474a586
15 files changed
+567 -94

No files matched your search

+16 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/session_timer.dart';
import 'package:paladinvpn/logic/ad_manager.dart';
import 'package:paladinvpn/components/online_dot.dart';
import 'package:paladinvpn/components/watch_ad_button.dart';
@@ -14,9 +15,12 @@ class BottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<VpnConnection>(
builder: (context, vpn, _) {
final isOnline = vpn.status == VpnStatus.connected;
return Consumer2<VpnConnection, SessionTimer>(
builder: (context, vpn, timer, _) {
final isConnected = vpn.status == VpnStatus.connected;
// Health is based on whether we've successfully talked to the
// server recently — not just whether the VPN tunnel is up.
final serverHealthy = isConnected && timer.hasSyncedOnce;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -31,10 +35,17 @@ class BottomBar extends StatelessWidget {
child: Row(
children: [
// Server online/offline chip
OnlineDot(isOnline: isOnline),
OnlineDot(
isOnline: serverHealthy,
label: isConnected && !timer.hasSyncedOnce
? 'Syncing…'
: serverHealthy
? 'Server Online'
: 'Disconnected',
),
const Spacer(),
// Support button — only visible while connected AND ads are enabled
if (isOnline && AdManager.adsEnabled) const WatchAdButton(),
if (isConnected && AdManager.adsEnabled) const WatchAdButton(),
],
),
);
+2 -4
View File
@@ -1,4 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/session_timer.dart';
@@ -13,7 +12,6 @@ import 'package:paladinvpn/logic/session_timer.dart';
class ClockDisplay extends StatelessWidget {
const ClockDisplay({super.key});
static const Color _cyanGlow = Color(0xFF00E5FF);
@override
Widget build(BuildContext context) {
@@ -77,8 +75,8 @@ class ClockDisplay extends StatelessWidget {
const Icon(Icons.speed, color: Colors.white54, size: 14),
const SizedBox(width: 4),
Text(
timer.currentSpeedKbps > 0.5
? '${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s'
timer.currentSpeedKBps > 0.5
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
: 'Idle',
style: const TextStyle(
fontSize: 12,
+45 -4
View File
@@ -65,10 +65,12 @@ class _ConnectButtonState extends State<ConnectButton>
final timer = context.read<SessionTimer>();
if (vpn.status == VpnStatus.connected) {
// Disconnect — apply a short cooldown to prevent rapid toggle spam
// Disconnect — await the full teardown so the notification and UI
// stay in sync with the actual tunnel state.
HapticFeedback.lightImpact();
vpn.disconnect();
timer.stop();
await vpn.disconnect();
// Short cooldown to prevent accidental immediate reconnect
setState(() => _inCooldown = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _inCooldown = false);
@@ -85,11 +87,50 @@ class _ConnectButtonState extends State<ConnectButton>
if (mounted) setState(() => _inCooldown = false);
});
}
// On failure the button stays active — the error message in
// StatusText tells the user what went wrong.
// On failure, check if it's a permission issue and offer recovery.
if (!success && mounted) {
final msg = vpn.errorMessage ?? '';
if (msg.contains('permission') || msg.contains('Permission')) {
_showPermissionDialog();
}
}
}
}
/// Shows a dialog guiding the user to grant the VPN permission in
/// Android system settings, since there's no way to re-prompt the
/// native VpnService dialog after the first denial.
void _showPermissionDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A202C),
title: const Text('VPN Permission Required',
style: TextStyle(color: Colors.white)),
content: const Text(
'PaladinVPN needs permission to set up a VPN connection.\n\n'
'Since the permission was denied once, Android won\'t ask again. '
'To grant it manually:\n\n'
'1. Open your device Settings\n'
'2. Go to Apps → PaladinVPN\n'
'3. Tap "VPN" and enable it\n'
'4. Return to PaladinVPN and tap connect',
style: TextStyle(color: Color(0xFFA0AEC0)),
),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF00E5FF),
),
child: const Text('Got it',
style: TextStyle(color: Color(0xFF0D1117))),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Consumer2<VpnConnection, SessionTimer>(
+92
View File
@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:url_launcher/url_launcher.dart';
/// GdprButton lets the user review or change their ad consent choices.
///
/// When tapped, it tries to show Google's UMP consent form so the user
/// can adjust their preferences (personalized vs non-personalized ads).
/// If the form isn't available (e.g. user is outside the EEA), it falls
/// back to opening the privacy policy instead.
class GdprButton extends StatelessWidget {
const GdprButton({super.key});
Future<void> _showConsentForm() async {
try {
// If the form is already available, show it directly — no need to
// re-request consent info from Google's servers on every tap.
if (await ConsentInformation.instance.isConsentFormAvailable()) {
await _loadAndShowForm();
return;
}
// Form not available — refresh consent info first, then try again.
final completer = Completer<void>();
ConsentInformation.instance.requestConsentInfoUpdate(
ConsentRequestParameters(),
() async {
if (await ConsentInformation.instance.isConsentFormAvailable()) {
await _loadAndShowForm();
}
completer.complete();
},
(FormError error) {
debugPrint('[GDPR] Consent update error: ${error.message}');
_fallbackToPrivacyPolicy();
completer.complete();
},
);
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: _fallbackToPrivacyPolicy,
);
} catch (e) {
debugPrint('[GDPR] Error: $e');
_fallbackToPrivacyPolicy();
}
}
Future<void> _loadAndShowForm() async {
final completer = Completer<void>();
ConsentForm.loadConsentForm(
(ConsentForm form) {
form.show((FormError? dismissError) {
if (dismissError != null) {
debugPrint('[GDPR] Form dismissed with error: ${dismissError.message}');
}
completer.complete();
});
},
(FormError loadError) {
debugPrint('[GDPR] Form load error: ${loadError.message}');
_fallbackToPrivacyPolicy();
completer.complete();
},
);
await completer.future.timeout(
const Duration(seconds: 120),
onTimeout: _fallbackToPrivacyPolicy,
);
}
void _fallbackToPrivacyPolicy() async {
final Uri url = Uri.parse(
'https://github.com/esefxdz/PaladinVPN/blob/main/PRIVACY_POLICY.md',
);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url');
}
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.admin_panel_settings_outlined,
color: Colors.white, size: 28),
onPressed: _showConsentForm,
tooltip: 'Ad consent & privacy choices',
splashRadius: 24,
);
}
}
+32 -8
View File
@@ -41,10 +41,14 @@ class VpnNotificationManager {
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(
'paladin_vpn_status',
'paladin_vpn_status_v2',
'VPN Status',
channelDescription: 'Persistent notification shown while VPN is active',
channelDescription: 'Shown while VPN is active — non-dismissable',
importance: Importance.low,
priority: Priority.low,
// Non-dismissable while VPN is running
@@ -58,16 +62,36 @@ class VpnNotificationManager {
showWhen: false,
// Show on lock screen
visibility: NotificationVisibility.public,
// Extra insurance against swipe-to-dismiss on aggressive Android skins
usesChronometer: false,
category: AndroidNotificationCategory.service,
);
const notificationDetails = NotificationDetails(android: androidDetails);
await _plugin.show(
888, // Fixed ID — always updates the same notification
'Paladin VPN Active',
'Time: $timeLeft • $speedKbps',
notificationDetails,
);
try {
await _plugin.show(
888, // Fixed ID — always updates the same notification
'Paladin VPN Active',
'Time: $timeLeft • $speedKbps',
notificationDetails,
);
} 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,
'Paladin VPN Active',
'Time: $timeLeft • $speedKbps',
notificationDetails,
);
} catch (_) {
// Give up — the VPN still works, just without the notification.
}
}
}
static Future<void> cancel() async {
+12 -7
View File
@@ -1,22 +1,29 @@
import 'package:flutter/material.dart';
/// OnlineDot displays a glowing green dot when the server is online/connected,
/// and a muted gray dot when disconnected.
/// OnlineDot displays a glowing green dot when the server is healthy,
/// and a muted gray dot when disconnected or still syncing.
class OnlineDot extends StatelessWidget {
/// Represents if the VPN tunnel session is active (online).
/// Whether the server has been confirmed reachable.
final bool isOnline;
/// Optional custom label. Falls back to "Server Online" / "Disconnected".
final String? label;
const OnlineDot({
super.key,
required this.isOnline,
this.label,
});
@override
Widget build(BuildContext context) {
final displayLabel = label ??
(isOnline ? 'Server Online' : 'Disconnected');
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Glowing status dot: Green when active, muted gray when inactive.
// Glowing status dot: Green when healthy, muted gray when not.
AnimatedContainer(
duration: const Duration(milliseconds: 400),
width: 8,
@@ -35,10 +42,8 @@ class OnlineDot extends StatelessWidget {
),
),
const SizedBox(width: 10),
// Dynamic status text label
Text(
isOnline ? 'Server Online' : 'Disconnected',
displayLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,