diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a495740 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.jks binary +*.keystore binary +*.jar binary diff --git a/.gitignore b/.gitignore index 6918e26..6fff17c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,63 +5,24 @@ # everything else is excluded by extension or directory rather than by being # listed individually. A new file is therefore ignored by default instead of # needing to be named here, and this file does not enumerate what it protects. -# -# Machine-local exclusions live in .git/info/exclude, which is never committed. + # ============================================================================= -# ── OS & editor ──────────────────────────────────────────────────────────── -*.swp -.DS_Store -.atom/ -.history -.svn/ -.java-version -.idea/ -.vscode/ - -# ── Flutter / Dart build output ──────────────────────────────────────────── -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -build/ -**/build/ -local_packages/*/build/ -local_packages/*/.dart_tool/ -*.dart_tool/ -**/doc/api/ -**/ios/Flutter/.last_build_id - -# ── Android build output ─────────────────────────────────────────────────── -**/android/**/gradle-wrapper.jar -**/android/.gradle -**/android/captures/ -**/android/gradlew -**/android/gradlew.bat -**/android/local.properties -**/android/**/GeneratedPluginRegistrant.java -**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java -**/android/.cxx/ -**/android/app/build/ -**/android/app/crashlytics-build.properties - -# ── Python bytecode ──────────────────────────────────────────────────────── -__pycache__/ -*.pyc - -# ── Credentials & machine-local config ───────────────────────────────────── +# ── Secrets ──────────────────────────────────────────────────────────────── *.keystore *.jks android/key.properties android/admob.properties + +# Real IPs and IDs. app_config.example.dart is the committed template. lib/logic/app_config.dart -devtools_options.yaml # ── File types the shipped app does not contain ──────────────────────────── -# Server code, infrastructure configs, captures, archives and logs. None of -# these extensions are tracked, so a blanket rule is safe and stays safe. +# Server code, infrastructure configs, captures, archives, dumps and logs. +# None of these extensions are tracked, so a blanket rule is safe and stays +# safe — it covers files that do not exist yet. *.py +*.pyc *.json *.conf *.sh @@ -81,10 +42,58 @@ devtools_options.yaml !/CONTRIBUTING.md !/PRIVACY_POLICY.md -# ── Local working directories ────────────────────────────────────────────── -# Working material that is not part of the app. Needs a committed rule rather -# than a local-only one, because it can hold source-file types the extension -# rules above deliberately do not cover. +# ── Working directories ──────────────────────────────────────────────────── +# Material that is not part of the app. These need directory rules rather +# than type rules, because they can hold source-file types the rules above +# deliberately do not cover. .local/ +internal/ +hivemind/ +scraped/ backup/ +backups/ lib/logic/backup/ + +# ── Flutter / Dart build output ──────────────────────────────────────────── +.dart_tool/ +*.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +build/ +**/build/ +local_packages/*/build/ +local_packages/*/.dart_tool/ +**/doc/api/ +**/ios/Flutter/.last_build_id + +# ── Android build output ─────────────────────────────────────────────────── +**/android/.gradle +**/android/.cxx/ +**/android/app/build/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/gradle-wrapper.jar +**/android/**/GeneratedPluginRegistrant.java +**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +**/android/app/crashlytics-build.properties + +# ── Editors, tooling & OS ────────────────────────────────────────────────── +.claude/ +.codewhale/ +.idea/ +.vscode/ +.atom/ +.history +.svn/ +.java-version +.DS_Store +*.swp +__pycache__/ +devtools_options.yaml + +#why are you even looking at this file? it's a secret. go away. +whatsthis \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e07e533..d577ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [3.3.0] — Unreleased + +### Added +- Session countdown in the VPN notification, updated each second while connected. + +### Fixed +- **Session-expiry desync** — the app could show "Online" with no internet after + the server ended the session. The countdown no longer freezes when a poll fails. +- **Notification Disconnect button** disappeared a second after connecting. + Re-posting the foreground notification replaces it wholesale, so the action, + colour and tap-intent flutter_vless sets are now rebuilt rather than dropped. +- **Speed readout** divided the byte delta by the nominal poll interval instead of + the real time between polls — wrong by up to 6x around every background switch. +- **Settings toggles** now apply at launch. Rain, lightning and haptics were only + read from disk when Settings mounted, so an effect turned off came back — ticker + and all — on every relaunch, and haptic feedback did nothing until Settings had + been opened at least once. +- **Haptic feedback** now fires `mediumImpact()` instead of `lightImpact()` (which + is imperceptible on many Android devices), wrapped best-effort so a platform + vibration failure cannot fail a connect/disconnect, with a preview tap when the + toggle is switched on. +- First-launch disclosure dialog said data was linked to "your account". It is + linked to a randomly generated device ID, and the policy is in the sidebar, not + on a website. +- Sidebar "Settings" and "Check for updates" used the drawer's context after + `Navigator.pop()` — a defunct context. They now capture the root navigator (and + messenger) before closing the drawer. *(Fix ported from Naveax's review.)* + +### Performance +- Rain overlay ticks only while enabled, repaints through a scoped `Listenable` + instead of rebuilding every frame, and precomputes drop colours. +- Lightning no longer fires strikes while disabled. +- Connect button pulse pauses when backgrounded; brand image hoisted out of the + per-frame rebuild. +- Server poll drops to 30 s while backgrounded (5 s foreground), with an immediate + re-sync on resume. The 1 s countdown keeps running. +- Health probe runs only while disconnected, and pauses in the background. +- Speed and support widgets rebuild on value change instead of every second. +- Notification channel is created once per process, not once per second. + +### Changed +- VLESS engine initializes under the splash screen; the intro waits on + `VpnConnection.ready` (8 s cap) before showing the main screen. +- AdMob SDK init moved off the blocking pre-`runApp()` path so startup cannot hang + on an unreachable Google endpoint. +- `app_config.example.dart` drops the dead bootstrap fields and documents + `hivemindApiPublic` in their place. +- `.gitignore` reorganized around type and directory rules; the enumerated + filename list is gone (redundant with the type rules) and `.claude/` is now + ignored. + +### Security +- Update checker pins the release URL to this repo's path on `github.com`, not just + the host. Anything else falls back to the releases page. +- Notification timer updates are gated on a live tunnel, so a late tick cannot leave + an ongoing notification with no foreground service behind it. +- Dropped `READ_EXTERNAL_STORAGE`, which `flutter_vless` pulls in but the app never uses + (least privilege). +- Pinned the Gradle 8.14 distribution SHA-256; `validateDistributionUrl` enabled + (supply-chain integrity). + +### Build & tooling (ported from Naveax) +- Removed the deprecated `android.enableR8` flag. +- Added `analysis_options.yaml` (flutter_lints, `local_packages/**` excluded, `avoid_print`). +- Added `.gitattributes` to pin LF line endings repo-wide. +- Credit: reviewed and ported from **Naveax**. + +### Docs +- Privacy policy: disclosed that the server sees the connecting IP, corrected the + "stored in memory" claim, and documented consent being changeable from the sidebar. + ## [3.2.2] — 2026-08-08 ### Fixed diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 881d8f8..d3e1f8e 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -15,9 +15,13 @@ We collect the minimum required to operate the service: - A randomly generated device ID created on your phone. This is not linked to your identity in any way. - Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below). +## What our server sees + +To start, check, or refresh your session, the app contacts our API directly rather than through the VPN tunnel. Our server therefore sees the IP address you are connecting from, alongside your device ID, for as long as the app is running. This is a necessary consequence of how sessions are issued and kept alive — it is separate from your browsing traffic, which is carried inside the tunnel and is described below. + ## Session quotas -Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session data (duration and bytes transferred) is stored in memory and deleted when your session ends. +Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session records (duration and bytes transferred) are held on our server for the life of your session and removed when it ends. Support ads (the "Support us" button) extend an active session by 30 minutes and add extra data allowance. @@ -34,7 +38,7 @@ We keep a short in-memory log buffer (last 200 lines) for debugging and server h ## Ads & Consent -ReVoltVPN uses Google AdMob to display rewarded video ads. On first launch, users in the European Economic Area (EEA) and United Kingdom are shown a consent dialog (provided by Google's User Messaging Platform) that lets them choose whether to allow personalized ads. This consent choice is stored on your device and can be changed at any time by reinstalling the app. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details. +ReVoltVPN uses Google AdMob to display rewarded video ads. Where required — for users in the European Economic Area (EEA) and United Kingdom — a consent dialog provided by Google's User Messaging Platform is shown before the first ad is requested, letting you choose whether to allow personalized ads. This choice is stored on your device and can be reviewed at any time from the "Ad Consent" entry in the sidebar menu. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details. ## Your traffic diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..b9bce79 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - local_packages/** + - whatsthis/** + +linter: + rules: + avoid_print: true diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c95280..e4758ae 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,19 +1,25 @@ - + - - - - - - - - - - - - + + + + + - + + + + + + + + + + - @@ -35,17 +40,16 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme"/> + + android:value="2"/> - diff --git a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt index ba1bedb..095b894 100644 --- a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -1,6 +1,16 @@ package com.paladinvpn.app +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.github.tfox.flutter_vless.xray.service.XrayVPNService +import com.github.tfox.flutter_vless.xray.utils.AppConfigs import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -11,6 +21,11 @@ class MainActivity : FlutterActivity() { // Must match the string in lib/logic/updater.dart. private val installerChannel = "com.revoltvpn.app/installer" + // Must match lib/logic/notification_service.dart. + private val notificationChannel = "com.revoltvpn.app/notification" + + private var notificationChannelReady = false + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -22,6 +37,102 @@ class MainActivity : FlutterActivity() { result.notImplemented() } } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel) + .setMethodCallHandler { call, result -> + if (call.method == "updateNotificationText") { + val text = call.argument("text") ?: "" + val title = call.argument("title") ?: "Revolt VPN" + val action = call.argument("disconnectLabel") ?: "Disconnect" + updateVpnNotification(title, text, action) + result.success(null) + } else { + result.notImplemented() + } + } + } + + /** + * Sets the session countdown as the VPN notification's content text. + * + * notify() is per-package, so re-posting flutter_vless's notification id + * replaces it wholesale — every field the plugin set has to be rebuilt + * here or it is dropped, the Disconnect action most of all. Keep in sync + * with XrayCoreManager.showNotification(). + */ + private fun updateVpnNotification(title: String, text: String, actionLabel: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + + ensureNotificationChannel() + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + // Same intent shape as the plugin, so a tap brings the running task + // forward instead of starting a second one. + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + launchIntent?.action = "FROM_DISCONNECT_BTN" + launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_NEW_TASK + + // Tapping "Disconnect" stops the tunnel without opening the app. + val stopIntent = Intent(this, XrayVPNService::class.java) + stopIntent.putExtra( + "COMMAND", + AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE + ) + val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) + + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(title) + .setContentText(text) + .addAction(0, actionLabel, stopPendingIntent) + .setColorized(true) + .setColor(0xFF0D1117.toInt()) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + + if (launchIntent != null) { + builder.setContentIntent( + PendingIntent.getActivity(this, 0, launchIntent, flags) + ) + } + + try { + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build()) + } catch (_: Exception) { + // The foreground notification may not be up yet — the update is cosmetic. + } + } + + /** A binder round-trip, and the caller runs once a second — so, once per process. */ + private fun ensureNotificationChannel() { + if (notificationChannelReady) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Revolt VPN Background Service", + NotificationManager.IMPORTANCE_LOW + ) + channel.setShowBadge(false) + manager.createNotificationChannel(channel) + } + notificationChannelReady = true } /** @@ -39,4 +150,10 @@ class MainActivity : FlutterActivity() { } catch (e: Exception) { null } + + companion object { + // Must match flutter_vless's XrayCoreManager. + private const val NOTIFICATION_ID = 1 + private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" + } } diff --git a/android/gradle.properties b/android/gradle.properties index 763d1a1..72dce06 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,8 +1,5 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true -# This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false -# This newDsl flag was added automatically by Flutter migrator android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..49fb88e 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,3 +3,6 @@ distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1 +networkTimeout=10000 +validateDistributionUrl=true diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 73a1c91..888067b 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -1,5 +1,5 @@ +import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; @@ -15,7 +15,7 @@ class ConnectButton extends StatefulWidget { } class _ConnectButtonState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, WidgetsBindingObserver { bool _busy = false; DateTime _lastTap = DateTime.now(); AnimationController? _pulse; @@ -29,6 +29,7 @@ class _ConnectButtonState extends State final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000)); _pulse = pulse; _pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut)); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().addListener(_onVpnChanged); }); @@ -36,6 +37,7 @@ class _ConnectButtonState extends State @override void dispose() { + WidgetsBinding.instance.removeObserver(this); context.read().removeListener(_onVpnChanged); _pulse?.dispose(); super.dispose(); @@ -51,6 +53,20 @@ class _ConnectButtonState extends State } } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!mounted) return; + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _pulse?.stop(); + } else if (state == AppLifecycleState.resumed) { + final vpn = context.read(); + if (vpn.status == VpnStatus.connected) { + _pulse?.repeat(reverse: true); + } + } + } + Future _handleTap() async { final now = DateTime.now(); @@ -65,7 +81,7 @@ class _ConnectButtonState extends State if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { _busy = false; await timer.disconnect(); - if (hapticEnabled) HapticFeedback.lightImpact(); + unawaited(Haptics.connectionChanged()); return; } @@ -85,7 +101,7 @@ class _ConnectButtonState extends State final ok = await vpn.connect(); if (ok) { timer.start(); - if (hapticEnabled) HapticFeedback.lightImpact(); + unawaited(Haptics.connectionChanged()); } } finally { if (mounted) setState(() => _busy = false); @@ -105,15 +121,36 @@ class _ConnectButtonState extends State child: _pulseAnim != null ? AnimatedBuilder( animation: _pulseAnim!, - builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue), + child: _buildInner(spinning), + builder: (_, child) => + _buildCircle(isConnected, _pulseValue, child), ) - : _buildCircle(isConnected, spinning, 0), + : _buildCircle(isConnected, 0, _buildInner(spinning)), ); }, ); } - Widget _buildCircle(bool isConnected, bool spinning, double pulse) { + Widget _buildInner(bool spinning) { + if (spinning) { + return const Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: AppColors.accent70, + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.all(14), + child: Image.asset('assets/brand.png', fit: BoxFit.contain), + ); + } + + Widget _buildCircle(bool isConnected, double pulse, Widget? child) { final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0; final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0; @@ -128,21 +165,17 @@ class _ConnectButtonState extends State width: isConnected ? 2.5 : 1.5, ), boxShadow: isConnected - ? [BoxShadow(color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3)] + ? [ + BoxShadow( + color: Color.fromARGB(glowAlpha, 255, 214, 0), + blurRadius: glowRadius, + spreadRadius: glowRadius * 0.3, + ) + ] : [], ), clipBehavior: Clip.hardEdge, - child: spinning - ? const Center( - child: SizedBox( - width: 36, height: 36, - child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent70), - ), - ) - : Padding( - padding: const EdgeInsets.all(14), - child: Image.asset('assets/brand.png', fit: BoxFit.contain), - ), + child: child, ); } } diff --git a/lib/components/data_disclosure_dialog.dart b/lib/components/data_disclosure_dialog.dart index eec31b6..85d8132 100644 --- a/lib/components/data_disclosure_dialog.dart +++ b/lib/components/data_disclosure_dialog.dart @@ -64,10 +64,11 @@ class _DisclosureDialog extends StatelessWidget { const Text( 'Revolt VPN creates a secure VPN tunnel to protect your ' 'traffic. To enforce your data/time quota, we log your ' - 'connection duration and data usage, linked to your ' - 'account. We do not log or monitor the content of your ' - 'traffic. We do not share your data with third parties.' - 'If you have more questions, visit our website.', + 'connection duration and data usage, linked to a randomly ' + 'generated device ID — not to your identity. We do not log ' + 'or monitor the content of your traffic. We do not share ' + 'your data with third parties. The full policy is in the ' + 'sidebar menu under "Privacy Policy".', style: TextStyle( color: AppColors.textMuted, fontSize: 14, diff --git a/lib/components/lightning.dart b/lib/components/lightning.dart index 2bb1b8b..03a032d 100644 --- a/lib/components/lightning.dart +++ b/lib/components/lightning.dart @@ -37,11 +37,24 @@ class _LightningEffectState extends State void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); - _scheduleNext(); + lightningEnabled.addListener(_onEnabledChanged); + if (lightningEnabled.value) _scheduleNext(); + } + + void _onEnabledChanged() { + if (lightningEnabled.value) { + _scheduleNext(); + } else { + _timer?.cancel(); + _ctrl.stop(); + _bolt = null; + } + if (mounted) setState(() {}); } @override void dispose() { + lightningEnabled.removeListener(_onEnabledChanged); _timer?.cancel(); _ctrl.dispose(); super.dispose(); @@ -54,13 +67,13 @@ class _LightningEffectState extends State } void _strike() { - if (!mounted) return; + if (!mounted || !lightningEnabled.value) return; _bolt = LightningBolt.random(_random); _ctrl.forward(from: 0.0); if (_random.nextDouble() < widget.doubleChance) { Future.delayed(const Duration(milliseconds: 120), () { - if (mounted) { + if (mounted && lightningEnabled.value) { _bolt = LightningBolt.random(_random); _ctrl.forward(from: 0.0); } @@ -78,7 +91,7 @@ class _LightningEffectState extends State @override Widget build(BuildContext context) { - if (!lightningEnabled) return const SizedBox.shrink(); + if (!lightningEnabled.value) return const SizedBox.shrink(); return AnimatedBuilder( animation: _ctrl, builder: (_, child) { diff --git a/lib/components/rain.dart b/lib/components/rain.dart index 8584d9e..1a96ff2 100644 --- a/lib/components/rain.dart +++ b/lib/components/rain.dart @@ -36,6 +36,7 @@ class _RainEffectState extends State late final Ticker _ticker; final _drops = <_Drop>[]; final _random = Random(); + final _repaint = ValueNotifier(0); Duration _last = Duration.zero; // Lifecycle @@ -44,47 +45,63 @@ class _RainEffectState extends State void initState() { super.initState(); for (int i = 0; i < widget.dropCount; i++) { - _drops.add(_Drop.spawn(_random, widget.minLength, widget.maxLength)); + _drops.add( + _Drop.spawn(_random, widget.minLength, widget.maxLength, widget.color)); } - _ticker = createTicker(_onTick)..start(); + _ticker = createTicker(_onTick); + rainEnabled.addListener(_onEnabledChanged); + if (rainEnabled.value) _ticker.start(); } @override void dispose() { + rainEnabled.removeListener(_onEnabledChanged); _ticker.dispose(); + _repaint.dispose(); super.dispose(); } + void _onEnabledChanged() { + if (rainEnabled.value) { + // Ticker elapsed restarts at zero; a stale _last makes the first dt negative. + _last = Duration.zero; + _ticker.start(); + } else { + _ticker.stop(); + } + if (mounted) setState(() {}); + } + // Tick void _onTick(Duration now) { double dt = 0.016; // ~60 fps fallback on first tick if (_last != Duration.zero) { dt = (now - _last).inMicroseconds / 1000000.0; - dt = dt.clamp(0.0, 0.1); + dt = dt.clamp(0.0, 0.1); } _last = now; for (final d in _drops) { d.y += d.speed * dt; if (d.y > 1.08) { - d.spawn(_random, widget.minLength, widget.maxLength); - d.y = -0.06 - _random.nextDouble() * 0.08; + d.spawn(_random, widget.minLength, widget.maxLength, widget.color); + d.y = -0.06 - _random.nextDouble() * 0.08; } } - setState(() {}); + _repaint.value++; } // Build @override Widget build(BuildContext context) { - if (!rainEnabled) return const SizedBox.shrink(); + if (!rainEnabled.value) return const SizedBox.shrink(); return CustomPaint( painter: _RainPainter( drops: _drops, - color: widget.color, windDrift: widget.windDrift, + repaint: _repaint, ), size: Size.infinite, ); @@ -96,37 +113,37 @@ class _RainEffectState extends State // ═══════════════════════════════════════════════════════════════════════════ class _Drop { - double x; - double y; - double speed; - double length; - double opacity; + double x; + double y; + double speed; + double length; + Color color; _Drop({ required this.x, required this.y, required this.speed, required this.length, - required this.opacity, + required this.color, }); /// Create a drop at a random position anywhere on screen. - factory _Drop.spawn(Random r, double minLen, double maxLen) { + factory _Drop.spawn(Random r, double minLen, double maxLen, Color base) { return _Drop( x: r.nextDouble(), y: r.nextDouble(), speed: 0.30 + r.nextDouble() * 0.55, length: minLen + r.nextDouble() * (maxLen - minLen), - opacity: 0.05 + r.nextDouble() * 0.12, + color: base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round()), ); } /// Re-randomise all properties (call when recycling a drop to the top). - void spawn(Random r, double minLen, double maxLen) { + void spawn(Random r, double minLen, double maxLen, Color base) { x = r.nextDouble(); speed = 0.30 + r.nextDouble() * 0.55; length = minLen + r.nextDouble() * (maxLen - minLen); - opacity = 0.05 + r.nextDouble() * 0.12; + color = base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round()); } } @@ -136,14 +153,13 @@ class _Drop { class _RainPainter extends CustomPainter { final List<_Drop> drops; - final Color color; final double windDrift; _RainPainter({ required this.drops, - required this.color, required this.windDrift, - }); + required Listenable repaint, + }) : super(repaint: repaint); @override void paint(Canvas canvas, Size size) { @@ -157,11 +173,11 @@ class _RainPainter extends CustomPainter { final ex = sx - windDrift; // slight leftward slant final ey = sy + d.length; - paint.color = color.withAlpha((d.opacity * 255).round()); + paint.color = d.color; canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint); } } @override - bool shouldRepaint(_RainPainter old) => true; // always animating + bool shouldRepaint(_RainPainter old) => false; // repaint listenable drives frames } diff --git a/lib/components/sidebar_contents/settings.dart b/lib/components/sidebar_contents/settings.dart index 8ddcac9..bd31654 100644 --- a/lib/components/sidebar_contents/settings.dart +++ b/lib/components/sidebar_contents/settings.dart @@ -12,9 +12,7 @@ class SettingsButton extends StatelessWidget { // Opens the settings page sliding in from the right — matches the // sidebar end-drawer animation so it feels like one continuous motion. - static void open(BuildContext context) { - Navigator.of(context).push( - PageRouteBuilder( + static PageRoute _route() => PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), pageBuilder: (_, __, ___) => const SettingsScreen(), @@ -23,15 +21,21 @@ class SettingsButton extends StatelessWidget { position: Tween( begin: const Offset(1.0, 0.0), end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, - curve: Curves.easeOut, - )), + ).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOut), + ), child: child, ); }, - ), - ); + ); + + static void openWithNavigator(NavigatorState navigator) { + if (!navigator.mounted) return; + navigator.push(_route()); + } + + static void open(BuildContext context) { + openWithNavigator(Navigator.of(context, rootNavigator: true)); } @override diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart index e305d22..afb444a 100644 --- a/lib/components/sidebar_drawer.dart +++ b/lib/components/sidebar_drawer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:revoltvpn/logic/app_colors.dart'; @@ -126,8 +127,9 @@ class _SidebarDrawerState extends State { icon: Icons.settings, label: 'Settings', onTap: () { + final navigator = Navigator.of(context, rootNavigator: true); Navigator.of(context).pop(); - SettingsButton.open(context); + SettingsButton.openWithNavigator(navigator); }, ), @@ -135,8 +137,12 @@ class _SidebarDrawerState extends State { icon: Icons.system_update_alt, label: 'Check for updates', onTap: () { + final navigator = Navigator.of(context, rootNavigator: true); + final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); - Updater.check(context); + unawaited( + Updater.check(navigator: navigator, messenger: messenger), + ); }, ), diff --git a/lib/components/speed_box.dart b/lib/components/speed_box.dart index 38bf44b..8bdf5db 100644 --- a/lib/components/speed_box.dart +++ b/lib/components/speed_box.dart @@ -8,10 +8,11 @@ class SpeedBox extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, timer, _) { - final speed = timer.currentSpeedKBps > 0.5 - ? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s' + return Selector( + selector: (_, timer) => timer.currentSpeedKBps, + builder: (context, speedKBps, _) { + final speed = speedKBps > 0.5 + ? '${speedKBps.toStringAsFixed(1)} KB/s' : 'Idle'; return Container( diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart index 85f5af3..25e5cdb 100644 --- a/lib/components/support_button.dart +++ b/lib/components/support_button.dart @@ -34,9 +34,10 @@ class _SupportButtonState extends State { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, timer, _) { - if (!timer.isRunning && !timer.hasSyncedOnce) { + return Selector( + selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce, + builder: (context, visible, _) { + if (!visible) { return const SizedBox.shrink(); } diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index a1abc5c..a706bd1 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -8,26 +8,11 @@ abstract final class AppConfig { // ── Server ─────────────────────────────────────────────────────────── /// Your server's public IP. The VPN tunnel is pinned to this. static const String serverIp = '0.0.0.0'; // ◄── REPLACE - - // ── Bootstrap (first-connect config fetch) ──────────────────────────── - /// Hardcoded Reality config. App connects with this, fetches the real - /// per-session VLESS URL through the tunnel, then reconnects. - static String get bootstrapVlessUrl => - 'vless://$bootstrapUuid@$serverIp:8443' - '?security=reality&type=xhttp&path=/revolt' - '&pbk=$realityPbk&sni=www.github.com&sid=$realitySid&fp=chrome' - '#ReVoltVPN'; - - /// Generate with `xray uuid` on the server. Must match xray_config_reality.json. - static const String bootstrapUuid = '00000000-0000-0000-0000-000000000000'; // ◄── REPLACE - - /// Server's Reality public key (from `xray x25519`). - static const String realityPbk = 'REPLACE_WITH_YOUR_PUBLIC_KEY'; - - /// Must be in xray_config_reality.json shortIds[]. - static const String realitySid = 'abc123'; + static const String hivemindApiPublic = 'https://YOUR_DOMAIN/api'; // ◄── REPLACE // ── Reality (VPN tunnel) ────────────────────────────────────────────── + // Credentials (public key, shortId) come from /session/status, not from + // here. Only the transport parameters below are compiled in. /// The server picks a random SNI per session from REALITY_SNI_POOL. /// The client does NOT hardcode one — server is always the source of truth. diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart new file mode 100644 index 0000000..d9e9f8c --- /dev/null +++ b/lib/logic/notification_service.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart'; + +// ── lib/logic/notification_service.dart ───────────────────────────────────── +// Bridge to the native VPN notification, which flutter_vless owns. This only +// refreshes its content text with the session countdown, so a failure here is +// never worth propagating to the session timer. + +class NotificationService { + NotificationService._(); + + static const MethodChannel _channel = + MethodChannel('com.revoltvpn.app/notification'); + + static String? _lastText; + + /// Sets the notification's content text to [text]. + /// + /// The native side rebuilds the whole notification, so skip the round-trip + /// when the text has not changed. + static Future updateTimer(String text) async { + if (text == _lastText) return; + _lastText = text; + try { + await _channel.invokeMethod('updateNotificationText', { + 'title': 'Revolt VPN', + 'text': text, + 'disconnectLabel': 'Disconnect', + }); + } catch (_) { + // Cosmetic. + } + } + + /// Forces the next connect to re-post even if the countdown resumes on the + /// same value. + static void reset() => _lastText = null; +} diff --git a/lib/logic/server_list.dart b/lib/logic/server_list.dart index f2e62ac..f5665c7 100644 --- a/lib/logic/server_list.dart +++ b/lib/logic/server_list.dart @@ -5,9 +5,9 @@ import 'package:shared_preferences/shared_preferences.dart'; // Server data model and ChangeNotifier — shared by StatusBar (shows current // server name/flag) and SelectionScreen (lets user pick a server). // -// Each server is defined by its tunnel IP/port. Reality keys (public key, -// shortId, fingerprint) come from AppConfig — all servers share the same -// Reality keypair and bootstrap UUID (same operator, different locations). +// Each server is defined by its tunnel IP/port. Reality credentials (public +// key, shortId) come from /session/status per session; only the transport +// parameters live in AppConfig. // // When multi-server ships: // - _all becomes a fetched list (from /api/servers or bundled config) diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 7838ead..d14750f 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -1,14 +1,16 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; +import 'package:revoltvpn/logic/notification_service.dart'; -class SessionTimer extends ChangeNotifier { +class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Timer? _timer; int _tickCount = 0; + bool _appBackgrounded = false; final VpnConnection vpnConnection; @@ -25,11 +27,14 @@ class SessionTimer extends ChangeNotifier { int _offlineSeconds = 0; static const int _pollIntervalSeconds = 5; + static const int _backgroundPollIntervalSeconds = 30; - int _lastUsedBytes = 0; - double _currentSpeedKBps = 0.0; + int _lastUsedBytes = 0; + double _currentSpeedKBps = 0.0; + DateTime? _lastSyncAt; SessionTimer({required this.vpnConnection}) { + WidgetsBinding.instance.addObserver(this); vpnConnection.addListener(_onVpnConnectionChanged); } @@ -77,11 +82,14 @@ class SessionTimer extends ChangeNotifier { _usedBytes = 0; _lastUsedBytes = 0; _currentSpeedKBps = 0.0; + _lastSyncAt = null; + NotificationService.reset(); _tickCount = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; _offlineSeconds = 0; _isDisconnecting = false; + _appBackgrounded = false; _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); @@ -92,10 +100,9 @@ class SessionTimer extends ChangeNotifier { void _tick(Timer t) { if (_isDisconnecting) return; - if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { - if (_remainingSeconds > 0) { - _remainingSeconds--; - } + // A local clock: poll failures are about the network, not the clock. + if (_hasSyncedOnce && _remainingSeconds > 0) { + _remainingSeconds--; } if (_consecutiveFailures >= _maxConsecutiveFailures) { @@ -112,11 +119,22 @@ class SessionTimer extends ChangeNotifier { } _tickCount++; - if (_tickCount % _pollIntervalSeconds == 0) { + final pollInterval = _appBackgrounded + ? _backgroundPollIntervalSeconds + : _pollIntervalSeconds; + if (_tickCount % pollInterval == 0) { _syncWithHivemind(); } notifyListeners(); + + // Gated on a live tunnel. The plugin cancels the notification when the + // service stops, and re-posting after that leaves an ongoing notification + // the user cannot swipe away below Android 14. + if (_hasSyncedOnce && + vpnConnection.status == VpnStatus.connected) { + NotificationService.updateTimer(formatted); + } } Future disconnect({String reason = 'User requested'}) async { @@ -133,6 +151,8 @@ class SessionTimer extends ChangeNotifier { _currentSpeedKBps = 0.0; _remainingSeconds = 0; _hasSyncedOnce = false; + _lastSyncAt = null; + NotificationService.reset(); notifyListeners(); await vpnConnection.disconnect(); @@ -163,9 +183,17 @@ class SessionTimer extends ChangeNotifier { _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; _usedBytes = data['used_bytes'] ?? _usedBytes; + // Measure the real gap between polls: the interval is 5 s in the + // foreground, 30 s in the background, and arbitrary on a resume sync. + final now = DateTime.now(); + final lastSync = _lastSyncAt; + _lastSyncAt = now; + final int deltaBytes = _usedBytes - _lastUsedBytes; - if (_hasSyncedOnce && deltaBytes > 0) { - _currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000; + if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) { + final elapsed = now.difference(lastSync).inMilliseconds / 1000.0; + _currentSpeedKBps = + elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps; } else if (!_hasSyncedOnce) { _currentSpeedKBps = 0.0; } @@ -200,6 +228,7 @@ class SessionTimer extends ChangeNotifier { } void _resumeTicking() { + _lastSyncAt = null; _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); _isDisconnecting = false; @@ -207,8 +236,23 @@ class SessionTimer extends ChangeNotifier { notifyListeners(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + // Keep ticking, but slow the poll — the 5 s one wakes the radio. + _appBackgrounded = true; + } else if (state == AppLifecycleState.resumed) { + _appBackgrounded = false; + if (_timer != null && _timer!.isActive) { + _syncWithHivemind(); + } + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); vpnConnection.removeListener(_onVpnConnectionChanged); _timer?.cancel(); super.dispose(); diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart index 7d711db..5a935ef 100644 --- a/lib/logic/updater.dart +++ b/lib/logic/updater.dart @@ -61,8 +61,24 @@ class GitHubUpdater { } } + static bool _isTrustedReleaseUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') { + return false; + } + // Host alone is not enough: anyone can host a release on github.com. + return uri.path + .startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/'); + } + static Future open() async { - final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; + final cached = _cachedDownloadUrl; + final url = (cached != null && _isTrustedReleaseUrl(cached)) + ? cached + : AppConfig.githubReleasesUrl; + if (cached != null && url != cached) { + debugPrint('[Updater] Rejected untrusted release URL: $cached'); + } try { return await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); @@ -115,32 +131,35 @@ class Updater { return '$prefix$v${isLocal ? ' (installed)' : ''}'; } - static Future check(BuildContext context) async { + static Future check({ + required NavigatorState navigator, + required ScaffoldMessengerState messenger, + }) async { final info = await PackageInfo.fromPlatform(); final localVersion = info.version; if (localVersion.isEmpty) { - _showSnackBar(context, 'Could not determine app version', + _showSnackBar(messenger, 'Could not determine app version', isError: true); return; } final latestVersion = await GitHubUpdater.fetchLatestVersion(); if (latestVersion == null) { - _showSnackBar(context, 'Could not reach GitHub', isError: true); + _showSnackBar(messenger, 'Could not reach GitHub', isError: true); return; } if (_compareVersions(localVersion, latestVersion) >= 0) { - _showSnackBar(context, 'Up to date'); + _showSnackBar(messenger, 'Up to date'); return; } final source = await installSource; final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub'; - if (!context.mounted) return; + if (!navigator.mounted) return; showDialog( - context: context, + context: navigator.context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.bgCard, title: const Text('Update available', @@ -171,9 +190,10 @@ class Updater { ); } - static void _showSnackBar(BuildContext context, String message, + static void _showSnackBar(ScaffoldMessengerState messenger, String message, {bool isError = false}) { - ScaffoldMessenger.of(context).showSnackBar( + if (!messenger.mounted) return; + messenger.showSnackBar( SnackBar( content: Row( children: [ diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index ef39336..f89ef25 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_vless/flutter_vless.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; @@ -11,7 +12,7 @@ enum VpnStatus { error, } -class VpnConnection extends ChangeNotifier { +class VpnConnection extends ChangeNotifier with WidgetsBindingObserver { bool _cancelled = false; VpnStatus _status = VpnStatus.disconnected; VpnStatus get status => _status; @@ -36,6 +37,7 @@ class VpnConnection extends ChangeNotifier { Future get ready => _readyCompleter.future; VpnConnection() { + WidgetsBinding.instance.addObserver(this); _init(); } @@ -71,9 +73,7 @@ class VpnConnection extends ChangeNotifier { debugPrint('[VPN] VLESS init error (expected on emulator): $e'); } - _checkHealth(); - _healthTimer = - Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth()); + _startHealthPolling(); try { final coreVersion = await _vless.getCoreVersion(); @@ -251,13 +251,37 @@ class VpnConnection extends ChangeNotifier { notifyListeners(); } + void _startHealthPolling() { + _healthTimer?.cancel(); + _checkHealthIfIdle(); + _healthTimer = Timer.periodic( + const Duration(seconds: 30), (_) => _checkHealthIfIdle()); + } + + Future _checkHealthIfIdle() async { + // A live tunnel already proves reachability. + if (_status == VpnStatus.connected) return; + await _checkHealth(); + } + Future _checkHealth() async { _serverReachable = await HivemindService.checkHealth(); notifyListeners(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _healthTimer?.cancel(); + } else if (state == AppLifecycleState.resumed) { + _startHealthPolling(); + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _healthTimer?.cancel(); if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { try { diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index 179d41a..59c41c7 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -4,6 +4,9 @@ import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/main_screen.dart'; +import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; +import 'package:revoltvpn/screens/settings/in_settings/lightning.dart'; +import 'package:revoltvpn/screens/settings/in_settings/haptic.dart'; class IntroScreen extends StatefulWidget { const IntroScreen({super.key}); @@ -42,6 +45,11 @@ class _IntroScreenState extends State with WidgetsBindingObserver { final elapsed = Stopwatch()..start(); final vpn = context.read(); + // The overlays read these in initState, so they must land before + // MainScreen mounts. + final settingsPrefs = Future.wait( + [loadRainPref(), loadLightningPref(), loadHapticPref()]); + try { await vpn.ready.timeout(_bootTimeout); } on TimeoutException { @@ -51,6 +59,12 @@ class _IntroScreenState extends State with WidgetsBindingObserver { debugPrint('[Boot] Engine init error: $e'); } + try { + await settingsPrefs; + } catch (e) { + debugPrint('[Boot] Settings prefs load failed, using defaults: $e'); + } + if (!mounted) return; setState(() => _status = 'Ready'); diff --git a/lib/screens/settings/in_settings/haptic.dart b/lib/screens/settings/in_settings/haptic.dart index 8c06622..a5d0df7 100644 --- a/lib/screens/settings/in_settings/haptic.dart +++ b/lib/screens/settings/in_settings/haptic.dart @@ -1,9 +1,44 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:revoltvpn/logic/app_colors.dart'; +// A plain bool, not a ValueNotifier: this is only read at the moment of a tap, +// so nothing has to react to it changing. Default: false. bool hapticEnabled = false; +/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below +/// only mounts once Settings is opened. +Future loadHapticPref() async { + final prefs = await SharedPreferences.getInstance(); + hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false; +} + +/// Haptic calls are deliberately best-effort: a vibration failure must never +/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of +/// `lightImpact` — the latter is imperceptible on many Android devices. +class Haptics { + Haptics._(); + + static Future connectionChanged() async { + if (!hapticEnabled) return; + try { + await HapticFeedback.mediumImpact(); + } catch (e) { + debugPrint('[Haptics] Feedback unavailable: $e'); + } + } + + static Future previewEnabled() async { + try { + await HapticFeedback.selectionClick(); + } catch (e) { + debugPrint('[Haptics] Preview unavailable: $e'); + } + } +} + class HapticFeedbackTile extends StatefulWidget { const HapticFeedbackTile({super.key}); @@ -20,12 +55,9 @@ class _HapticFeedbackTileState extends State { _load(); } - // Loads persisted state and syncs the top-level bool. Future _load() async { - final prefs = await SharedPreferences.getInstance(); - final value = prefs.getBool('haptic_feedback_enabled') ?? false; - hapticEnabled = value; - if (mounted) setState(() => _on = value); + await loadHapticPref(); + if (mounted) setState(() => _on = hapticEnabled); } // Writes to SharedPreferences AND updates the live bool so the connect @@ -34,6 +66,7 @@ class _HapticFeedbackTileState extends State { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('haptic_feedback_enabled', value); hapticEnabled = value; + if (value) unawaited(Haptics.previewEnabled()); setState(() => _on = value); } diff --git a/lib/screens/settings/in_settings/lightning.dart b/lib/screens/settings/in_settings/lightning.dart index cfba408..944b6ff 100644 --- a/lib/screens/settings/in_settings/lightning.dart +++ b/lib/screens/settings/in_settings/lightning.dart @@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart'; // ── lib/screens/settings/in_settings/lightning.dart ───────────────────────── // Toggle for the lightning strike overlay on the main screen. -// -// Same architecture as haptic.dart: exports a top-level bool read synchronously -// by lib/components/lightning.dart. Default: true (lightning is on — opt-out). -// lib/components/lightning.dart reads this in its build method. -bool lightningEnabled = true; +final ValueNotifier lightningEnabled = ValueNotifier(true); + +Future loadLightningPref() async { + final prefs = await SharedPreferences.getInstance(); + lightningEnabled.value = prefs.getBool('lightning_effect_enabled') ?? true; +} class LightningToggleTile extends StatefulWidget { const LightningToggleTile({super.key}); @@ -28,16 +29,14 @@ class _LightningToggleTileState extends State { } Future _load() async { - final prefs = await SharedPreferences.getInstance(); - final value = prefs.getBool('lightning_effect_enabled') ?? true; - lightningEnabled = value; - if (mounted) setState(() => _on = value); + await loadLightningPref(); + if (mounted) setState(() => _on = lightningEnabled.value); } Future _toggle(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('lightning_effect_enabled', value); - lightningEnabled = value; + lightningEnabled.value = value; setState(() => _on = value); } diff --git a/lib/screens/settings/in_settings/rain.dart b/lib/screens/settings/in_settings/rain.dart index 6b93856..929b228 100644 --- a/lib/screens/settings/in_settings/rain.dart +++ b/lib/screens/settings/in_settings/rain.dart @@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart'; // ── lib/screens/settings/in_settings/rain.dart ────────────────────────────── // Toggle for the animated rain overlay on the main screen. -// -// Same architecture as haptic.dart: exports a top-level bool read synchronously -// by lib/components/rain.dart. Default: true (rain is on — opt-out). -// lib/components/rain.dart reads this in its build method. -bool rainEnabled = true; +final ValueNotifier rainEnabled = ValueNotifier(true); + +Future loadRainPref() async { + final prefs = await SharedPreferences.getInstance(); + rainEnabled.value = prefs.getBool('rain_effect_enabled') ?? true; +} class RainToggleTile extends StatefulWidget { const RainToggleTile({super.key}); @@ -28,16 +29,14 @@ class _RainToggleTileState extends State { } Future _load() async { - final prefs = await SharedPreferences.getInstance(); - final value = prefs.getBool('rain_effect_enabled') ?? true; - rainEnabled = value; - if (mounted) setState(() => _on = value); + await loadRainPref(); + if (mounted) setState(() => _on = rainEnabled.value); } Future _toggle(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('rain_effect_enabled', value); - rainEnabled = value; + rainEnabled.value = value; setState(() => _on = value); } diff --git a/pubspec.yaml b/pubspec.yaml index 56a018e..209a56d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.2.2+25 +version: 3.3.0+26 environment: sdk: ">=3.0.0 <4.0.0"