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:
1 parent
86baf38149
commit
a84474a586
15 files changed
+567
-94
No files matched your search
@@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user