diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e1f2a..2ce760d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,50 +5,92 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- -## [2.0.7] — Unreleased +## [3.0.0] — 2026-08 -### Fixed (audit #13 — 2026-07-31) -- **Notification bug** — `SessionTimer.stop()` now cancels the persistent Android notification. Previously it lingered after user-initiated disconnect. -- **Concurrent sync guard** — `_syncInProgress` flag in `_syncWithHivemind()` prevents duplicate HTTP requests from overlapping. -- **Debug bypass gate** — `AppConfig.enableAdBypass` (in `app_config.example.dart`) replaces `kDebugMode` for the fake AdMob callback. Tree-shaken in release. +### Protocol — TCP → XHTTP migration +- **Transport changed from TCP to XHTTP** (H2 stream-up with Reality). + XTLS-Vision (`xtls-rprx-vision`) removed — it only applies to TCP+TLS/Reality, + not XHTTP. XHTTP provides H2 multiplexing, CDN passthrough, and path-based + hiding behind `/revolt`. +- **`flow` field removed from VLESS client config.** Xray falls back to standard + TLS proxying inside the XHTTP tunnel — correct and intentional per official + VLESS inbound docs. -### Changed -- **Responsive layout** — main screen uses `LayoutBuilder` with proportional fractions instead of hardcoded pixel positions. Pixel-identical on reference device, scales to any screen. -- **Dynamic version** — sidebar footer reads `ReVoltApp.appVersion` from `PackageInfo` instead of hardcoded `v2.0.7`. -- **Swarm/log blocked** — nginx explicitly returns 404 for `/api/swarm` and `/api/log`. Monitoring runs locally via `swarm_watch.py` on `127.0.0.1:5000`. +### Fixed — critical server bug +- **Client hot-reload was broken.** `xray.py` called `xray api adi` (add inbounds, + zero arguments) after writing `config.json`. This was a no-op — Xray never + learned about new VLESS clients. Every session created since the modular + rewrite was silently rejected at the VLESS layer. + Fixed: `add_client()` now writes a temp JSON and calls `xray api adu` + (add users). `remove_client()` calls `xray api rmu -tag=X email`. + Verified against `XTLS/Xray-core` HandlerService source. -### Server fixes (internal) -- `/session/stop` fetches Xray stats BEFORE popping session — prevents race where final byte count is lost -- `XrayManager._stats_lock` serializes diagnostic counter (non-atomic `+=` race) -- `ManagementLoop._start_lock` prevents TOCTOU on daemon startup -- **Manual update checker** — "Check for updates" button in sidebar - - Uses GitHub Releases API to find the latest version - - Up to date → snackbar "✓ Up to date" - - Update available → opens Play Store or GitHub releases page immediately -- **Website button** in sidebar → opens `https://getrevolt.app` -- **Timer syncing indicator** — shows "Connecting…" / "Syncing…" spinner during handshake +### Fixed — client +- **Missing XHTTP path in VLESS URL.** Server requires `path: "/revolt"` in + `xhttpSettings` but the VLESS URL never included a `path` parameter. + Fixed: `hivemind_service.dart` reads `xhttp_path` from `/session/status`, + falls back to `AppConfig.vlessPath`. +- **`providerBundleIdentifier` mismatch.** Was `com.revoltvpn.app` — must match + Android `applicationId` (`com.paladinvpn.app`) for VPN service registration. -### Changed -- Sidebar now has 6 rows: Ad Consent, Privacy Policy, About, Discord, Website, Check for updates -- API uses domain with proper TLS validation (traffic blends in with normal HTTPS) -- VPN tunnel destination pinned to hardcoded server IP (domain compromise = DoS only) +### Fixed — server config +- **Reality keypair generated.** Replaced `GENERATE_ME_WITH_xray_x25519` + placeholders in both `config.py` and `xray_config_reality.json` with a real + X25519 keypair. Private key stays on server; public key sent to clients. +- **Throttle policy corrected.** `uplinkOnly`/`downlinkOnly` were set to + `187500` — that's 52 hours, not 1.5 Mbps. These fields are **seconds** + (idle timeouts), not bandwidth rate limiters (per official Policy docs). + Set to defaults (2s / 5s). Real 1.5 Mbps cap requires Linux `tc`. +- **Fallback rate limits removed.** Live Reality docs warn fallback limiting + *"is itself a fingerprint and is not recommended."* Ports are firewalled + so fallback traffic is impossible anyway. Comment added for future reference. -### Security -- 6 new security audits (#8 through #12) covering domain exposure, network transit, server hardening, and pentesting -- SNI rotation per session — 5 spoofed sites instead of 1 -- Outbound abuse ports blocked (SMTP, NetBIOS, SSDP) -- Xray `flow: xtls-rprx-vision` properly set server-side - -### Dependencies -- `package_info_plus` added +### Added +- **`xhttp_path` in `/session/status` response.** Server now sends the XHTTP + path to clients, same pattern as `reality_pbk`/`reality_sid`/`reality_sni`. + `VLESS_XHTTP_PATH` constant in `config.py` is the single source of truth. +- **Updated deployment guide** (`SERVER_DEPLOYMENT.md`) — full step-by-step + for XHTTP+Reality, verified against live Xray docs at xtls.github.io. +- **Updated cheatsheet** (`scraped/VLESS_XRAY_CHEATSHEET.md`) — all sections + rewritten for XHTTP. Old TCP/Vision info replaced. --- -## [1.0.6] — Previous release +## [2.0.7] — 2026-07-31 -- VLESS + Xray Reality tunnel with XTLS-Vision -- 60 min session, 4 GB throttle -- Support top-up: +30 min +2 GB via rewarded ad +### Fixed +- **Notification bug** — `SessionTimer.stop()` now cancels the persistent + Android notification on user-initiated disconnect. +- **Concurrent sync guard** — `_syncInProgress` flag prevents duplicate + HTTP requests from overlapping in `_syncWithHivemind()`. +- **Debug bypass gate** — `AppConfig.enableAdBypass` replaces `kDebugMode` + for fake AdMob callback. Tree-shaken in release builds. + +### Changed +- **Responsive layout** — main screen uses `LayoutBuilder` with proportional + fractions. Scales to any screen size. +- **Dynamic version** — sidebar footer reads from `PackageInfo` instead of + hardcoded string. +- **Swarm/log blocked** — nginx returns 404 for `/api/swarm` and `/api/log`. + Monitoring runs locally via `swarm_watch.py`. + +### Added +- **Update checker** in sidebar — polls GitHub Releases API. +- **Website button** in sidebar → opens `https://getrevolt.app`. +- **Timer syncing indicator** — spinner during handshake. +- Dependency: `package_info_plus`. + +### Server +- `/session/stop` fetches stats BEFORE popping session (race fix). +- `XrayManager._stats_lock` for diagnostic counter thread safety. +- `ManagementLoop._start_lock` prevents TOCTOU on daemon startup. + +--- + +## [1.0.6] — 2026-06 + +- Initial VLESS + Xray Reality tunnel +- 60 min session, 4 GB throttle, support top-up - Server health dot, speed display, countdown timer - Persistent Android notification - Glass-morphism dark UI with yellow accent diff --git a/README.md b/README.md index cec88a1..d95dd81 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,60 @@ ---- -

ReVoltVPN banner

# ReVoltVPN -A free VPN app built from scratch. I handle the Flutter app, Hivemind backend, and AdMob integration — the ReVolt team manages server security and VLESS/Xray infrastructure. -There are hundreds of free VPN apps on the Play Store. Most of them are fine honestly — some are genuinely good. But a lot of them are vague about how they work, who runs them, and what happens to your traffic. I wanted to build something where the answer to all of those questions is just... public. - -So here it is. +A free VPN app for Android. Open-source client, transparent infrastructure. Watch an ad, get 60 minutes of full-speed traffic — no accounts, no logs, no subscriptions. --- ## How it works -You watch a short ad. You get 60 minutes and 4 GB of full-speed traffic. That's it. The ad pays for the server (actually not even enough). No accounts, no emails, no subscriptions. -When your data runs out you get throttled to 1.5 Mbps instead of getting cut off. A second Reality inbound on a different port handles throttled traffic — your client reconnects automatically, no ad required. +1. Watch a short rewarded ad +2. Server creates a temporary VLESS session (60 min / 4 GB) +3. Your traffic routes through a single server in Finland +4. When data runs out, you're throttled to 1.5 Mbps instead of cut off +5. Session expires — watch another ad or move on + +No email. No password. No payment. The ad pays for the server. --- -## The stack -- **App** — Flutter (Android only for now) -- **Server** — A single Debian box rented from Hetzner, located in Finland -- **VPN protocol** — VLESS + Xray Reality (XTLS-Vision). Spoofs real TLS certificates (rotating pool of major sites) so your tunnel looks like regular HTTPS. Great for bypassing DPI in countries that are aggressive with it. -- **Backend** — Hivemind, a Python Flask server I wrote that manages sessions, data quotas, throttling, and live swarm monitoring. Entire server is open source — in this repo. -- **Ads** — Google AdMob rewarded ads, verified server-side so fake callbacks don't work +## Protocol + +- **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`) +- **Obfuscation** — Xray Reality spoofs `www.github.com` TLS. To any DPI box, your tunnel looks like a browser visiting GitHub +- **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required +- **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only --- -## Limitations (being honest) -- One server, one location (Finland). That's all I can afford right now (planning to escalate further) -- 2 cores and 4GB RAM. It will not handle thousands of concurrent users -- iOS is not supported and probably won't be for a while -- The app is built by me in my free time — expect rough edges +## Stack + +| Layer | Technology | +|-------|-----------| +| App | Flutter (Android) | +| VPN | VLESS + Xray Reality + XHTTP | +| Backend | Python Flask ("Hivemind") — session management, quotas, stats | +| Ads | Google AdMob rewarded, verified server-side | +| Server | Debian, single Hetzner box in Finland | --- -## Why I built this -Free time project. I wanted to learn how VPNs actually work under the hood. Ended up building the whole thing from scratch — the Flutter app, Hivemind backend, and AdMob integration are made by me, while server security and VLESS/Xray setup are handled by the ReVolt team. It took way longer than expected. +## Limitations + +- One server, one location (Finland) +- 2 vCPU / 4 GB RAM — not built for thousands of concurrent users +- Android only --- ## Privacy -Your traffic goes through my server in Finland. I don't log it, I don't sell it, I have no reason to. The session manager (Hivemind) only tracks whether your session is active and how much data you've used — nothing else. -Internet privacy at the cost of watching an ad. Honest business. +Your traffic routes through Finland. We don't log it, inspect it, or sell it. Hivemind tracks only session liveness and byte counters for quota enforcement — no packet contents, no destination IPs, no DNS queries. -You can read the server code yourself, it's in this repo. +Full privacy policy: [`PRIVACY_POLICY.md`](PRIVACY_POLICY.md) --- *ReVoltVPN is not affiliated with any VPN company.* - ---- diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 9b7a5bf..9f625bf 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -65,7 +65,7 @@ class _ConnectButtonState extends State if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { _busy = false; - timer.stop(); // MUST stop timer BEFORE disconnect — prevents auto-reconnect + timer.stop(); await vpn.disconnect(); return; } @@ -116,7 +116,7 @@ class _ConnectButtonState extends State final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0; return Container( - width: 260, // <-- CHANGE ME: circle size + width: 260, height: 260, decoration: BoxDecoration( shape: BoxShape.circle, @@ -138,7 +138,7 @@ class _ConnectButtonState extends State ), ) : Padding( - padding: const EdgeInsets.all(14), // <-- CHANGE ME: brand.png padding from edge + padding: const EdgeInsets.all(14), child: Image.asset('assets/brand.png', fit: BoxFit.contain), ), ); diff --git a/lib/components/lightning.dart b/lib/components/lightning.dart index 850c61a..9a1d343 100644 --- a/lib/components/lightning.dart +++ b/lib/components/lightning.dart @@ -176,7 +176,7 @@ class _LightningPainter extends CustomPainter { final baseColor = AppColors.accent.withAlpha((opacity * 255).round()); final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round()); - // ── Glow layer (thick, blurry) ────────────── + // Glow final glowPaint = Paint() ..color = glowColor ..strokeWidth = 18 @@ -187,7 +187,7 @@ class _LightningPainter extends CustomPainter { _drawPath(canvas, scaledMain, scaledBranches, glowPaint); - // ── Core bolt (thin, bright) ──────────────── + // Core final corePaint = Paint() ..color = baseColor ..strokeWidth = 5 @@ -197,7 +197,7 @@ class _LightningPainter extends CustomPainter { _drawPath(canvas, scaledMain, scaledBranches, corePaint); - // ── Hot center (extra white core) ─────────── + // Hot center if (opacity > 0.6) { final hotPaint = Paint() ..color = Colors.white.withAlpha((opacity * 180).round()) diff --git a/lib/components/rain.dart b/lib/components/rain.dart index a7a1a15..72fc73b 100644 --- a/lib/components/rain.dart +++ b/lib/components/rain.dart @@ -37,7 +37,7 @@ class _RainEffectState extends State final _random = Random(); Duration _last = Duration.zero; - // ── Lifecycle ──────────────────────────────────────────────────────── + // Lifecycle @override void initState() { @@ -54,7 +54,7 @@ class _RainEffectState extends State super.dispose(); } - // ── Tick — move drops, recycle off-screen ones ────────────────────── + // Tick void _onTick(Duration now) { double dt = 0.016; // ~60 fps fallback on first tick @@ -74,7 +74,7 @@ class _RainEffectState extends State setState(() {}); } - // ── Build ──────────────────────────────────────────────────────────── + // Build @override Widget build(BuildContext context) { diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index e6d5ac3..990b65c 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -26,7 +26,7 @@ class StatusBar extends StatelessWidget { ), child: Row( children: [ - // ── Connection status (left) ───────────────────────────── + // Connection status if (isConnected) ...[ Image.asset('assets/finland_flag_256.png', width: 20, height: 20), const SizedBox(width: 8), @@ -52,7 +52,7 @@ class StatusBar extends StatelessWidget { const Spacer(), - // ── Server health dot (right) ──────────────────────────── + // Server health OnlineDot( isOnline: serverHealthy, label: isConnected diff --git a/lib/components/timer_box.dart b/lib/components/timer_box.dart index 3aa3368..9f028d7 100644 --- a/lib/components/timer_box.dart +++ b/lib/components/timer_box.dart @@ -25,7 +25,7 @@ class TimerBox extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── Countdown text (always visible) ────────────────────── + // Countdown Text( timer.hasSyncedOnce ? timer.formatted : '00:00:00', style: TextStyle( @@ -46,8 +46,7 @@ class TimerBox extends StatelessWidget { ), ), - // ── Syncing indicator (only shown while connecting or - // waiting for the first server poll) ────────────────── + // Syncing indicator if (showIndicator) ...[ const SizedBox(height: 8), Row( diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index be6cc6d..b1b64be 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -7,12 +7,7 @@ import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/app_config.dart'; -/// AdManager manages the lifecycle of rewarded video ads. -/// -/// When [adsEnabled] is false (pre-launch mode) all ad operations are -/// silently skipped — the app connects without any ad requirement. class AdManager extends ChangeNotifier { - /// Flip to `true` after Google Play launch when real ads are wired up. static const bool adsEnabled = false; RewardedAd? _rewardedAd; @@ -32,15 +27,9 @@ class AdManager extends ChangeNotifier { if (adsEnabled) preloadAd(); } - /// Loads a rewarded ad in the background and returns a Future when ready. - /// Safe to call multiple times — re-entrant calls wait on the same load. - /// No-op when [adsEnabled] is false. Future preloadAd() async { if (!adsEnabled) return true; - // If already loaded, return instantly if (_isAdLoaded) return true; - - // If we're already loading one right now, wait for that same request to finish if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false); _isAdLoading = true; @@ -84,12 +73,9 @@ class AdManager extends ChangeNotifier { return _loadCompleter!.future; } - /// Displays the rewarded video ad to the user. - /// Returns false immediately when [adsEnabled] is false. Future showAd(String adType) async { if (!adsEnabled) return false; - // 1. Ensure the ad is fully loaded before trying to show it if (!_isAdLoaded || _rewardedAd == null) { final loaded = await preloadAd(); if (!loaded || _rewardedAd == null) { @@ -98,16 +84,12 @@ class AdManager extends ChangeNotifier { } } - // 2. Fetch device ID for Server-Side Verification (SSV) final deviceId = await CryptoService.getDeviceId(); - // 3. Generate a connect nonce so the subsequent poll can verify - // it's talking to the session created by *this* ad. final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; HivemindService.setExpectedNonce(nonce); debugPrint('[AdManager] Ad nonce: $nonce'); - // 4. Build SSV options to send to our Hivemind server via Google's callback final ssvOptions = ServerSideVerificationOptions( customData: jsonEncode({ 'device_id': deviceId, @@ -118,7 +100,6 @@ class AdManager extends ChangeNotifier { Completer rewardCompleter = Completer(); - // 4. Setup full screen callbacks to handle dismissal and errors _rewardedAd!.fullScreenContentCallback = FullScreenContentCallback( onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'), onAdDismissedFullScreenContent: (ad) { @@ -126,10 +107,9 @@ class AdManager extends ChangeNotifier { ad.dispose(); _isAdLoaded = false; _rewardedAd = null; - // Start loading the next ad immediately preloadAd(); if (!rewardCompleter.isCompleted) { - rewardCompleter.complete(false); // User closed before earning reward + rewardCompleter.complete(false); } }, onAdFailedToShowFullScreenContent: (ad, error) { @@ -143,13 +123,11 @@ class AdManager extends ChangeNotifier { }, ); - // 5. Show the ad with SSV options + // Show with SSV options _rewardedAd!.setServerSideOptions(ssvOptions); await _rewardedAd!.show( onUserEarnedReward: (AdWithoutView ad, RewardItem reward) { debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}'); - // The SSV callback will hit our server independently. We just need to signal the UI - // that the ad was successfully completed. if (!rewardCompleter.isCompleted) { rewardCompleter.complete(true); } diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 6aa0ca2..d9db259 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -10,18 +10,14 @@ class HivemindService { static String? _expectedNonce; static int _currentCallId = 0; - /// Call this from disconnect to abort any in-progress polling. static void cancel() { - _currentCallId++; // bump — any running loop sees a stale ID and exits + _currentCallId++; } static void setExpectedNonce(String nonce) { _expectedNonce = nonce; } - /// Tries to fetch the VLESS config from the server. - /// Only polls a few times — by the time the ad finishes, the SSV callback - /// has usually already fired. static Future fetchConfigWithPolling({ void Function(int attempt, int total)? onAttempt, bool skipAdBypass = false, @@ -36,7 +32,7 @@ class HivemindService { final url = _url('/session/status?device_id=$deviceId'); - // ── AD BYPASS (debug only) ──────────────────────────────────────── + // Dev: fake ad callback when running in debug mode. if (kDebugMode && !skipAdBypass) { try { final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); @@ -77,10 +73,8 @@ class HivemindService { final fp = data['reality_fp'] ?? AppConfig.realityFp; final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath; - // host is intentionally omitted per XHTTP docs: - // "建议没事别设" — the server doesn't set host, so it won't check. + // Host header not set — server doesn't require it. // Client falls through: host → serverName (SNI) → address. - // With Reality SNI = github.com, the Host header blends naturally. final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort' '?security=${AppConfig.vlessSecurity}' '&type=${AppConfig.vlessType}' @@ -140,7 +134,6 @@ class HivemindService { } catch (_) {} } - /// Builds a URI pointing directly at the server IP — no domain involved. static Uri _url(String path) { return Uri.parse('${AppConfig.hivemindApiBase}$path'); } diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index fb43272..6c822d7 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -218,7 +218,7 @@ class SessionTimer extends ChangeNotifier { _lastUsedBytes = _usedBytes; - // ── Detect port change (throttle engage / disengage) ────── + // Port change — throttle engaged or disengaged final serverPort = data['vless_port']; if (serverPort != null && _currentPort != null && diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart index 737aebc..233c8ee 100644 --- a/lib/logic/updater.dart +++ b/lib/logic/updater.dart @@ -8,33 +8,9 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/app_colors.dart'; -/// Result of a manual version check. -enum UpdateStatus { - upToDate, - updateAvailable, - checkFailed, -} +enum UpdateStatus { upToDate, updateAvailable, checkFailed } -/// How the user installed this app — determines which store link we open. -enum InstallSource { - playStore, - sideload, -} - - -// ############################################################################## -// # -// APPROACH #1 # -// GOOGLE PLAY STORE LINK # -// # -// Opens the Play Store listing. User taps "Update" in Play Store like # -// any normal app. No background downloads, no in-app update package, # -// no permissions. Just a URL. # -// # -// The Play Store page is built from the applicationId: # -// https://play.google.com/store/apps/details?id=com.paladinvpn.app # -// # -// ############################################################################## +enum InstallSource { playStore, sideload } class PlayStoreUpdater { PlayStoreUpdater._(); @@ -42,10 +18,10 @@ class PlayStoreUpdater { static String get _url => 'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}'; - /// Opens the Play Store listing in the Play Store app (or browser fallback). static Future open() async { try { - return await launchUrl(Uri.parse(_url), mode: LaunchMode.externalApplication); + return await launchUrl(Uri.parse(_url), + mode: LaunchMode.externalApplication); } catch (e) { debugPrint('[Updater] Failed to open Play Store: $e'); return false; @@ -53,38 +29,19 @@ class PlayStoreUpdater { } } - -// ############################################################################## -// # -// APPROACH #2 # -// GITHUB RELEASES DIRECT # -// # -// Opens the GitHub releases page. User downloads the APK and Android's # -// package installer handles the rest. No permissions, no self-hosting. # -// # -// The GitHub Releases API (free, public, no auth) is also used as the # -// single source of truth for "what is the latest version?" for both paths. # -// # -// API: GET https://api.github.com/repos/{owner}/{repo}/releases/latest # -// # -// ############################################################################## - class GitHubUpdater { GitHubUpdater._(); static String? _cachedDownloadUrl; - /// Fetches the latest release tag from GitHub. - /// Returns the version string (e.g. "1.0.7") or null on failure. - /// - /// Tags should follow semver with an optional "v" prefix: - /// git tag v1.0.7 && git push origin v1.0.7 static Future fetchLatestVersion() async { - final url = 'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest'; + final url = + 'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest'; try { final response = await http - .get(Uri.parse(url), headers: {'Accept': 'application/vnd.github+json'}) + .get(Uri.parse(url), + headers: {'Accept': 'application/vnd.github+json'}) .timeout(const Duration(seconds: 8)); if (response.statusCode != 200) { @@ -103,11 +60,11 @@ class GitHubUpdater { } } - /// Opens the GitHub releases page for the latest release. static Future open() async { final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; try { - return await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + return await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication); } catch (e) { debugPrint('[Updater] Failed to open GitHub: $e'); return false; @@ -115,200 +72,119 @@ class GitHubUpdater { } } - -// ############################################################################## -// # -// SHARED LOGIC # -// VERSION CHECK & GLUE # -// # -// Updater.check(context) — call from the sidebar button. It: # -// # -// 1. Fetches the latest version from GitHub Releases API # -// 2. Compares to the local version from the app bundle # -// 3. Up to date? → green snackbar # -// 4. Update available? → dialog, then redirects to Play Store or GitHub # -// # -// ############################################################################## - class Updater { Updater._(); static InstallSource? _cachedSource; - /// Detects where this app was installed from. - /// - /// • com.android.vending → Play Store - /// • anything else / null → sideloaded (GitHub APK, etc.) static Future get installSource async { if (_cachedSource != null) return _cachedSource!; - if (kIsWeb || !Platform.isAndroid) { _cachedSource = InstallSource.sideload; return _cachedSource!; } - // In debug mode the app isn't signed with a Play Console certificate, - // so we default to the sideload path. - if (kDebugMode) { - debugPrint('[Updater] Debug mode — treating install source as sideload.'); - _cachedSource = InstallSource.sideload; - return _cachedSource!; - } - try { - final source = await _detectInstallerSource(); - _cachedSource = source; - debugPrint('[Updater] Install source: ${source.name}'); - return source; + final result = await MethodChannel('com.revoltvpn.app/installer') + .invokeMethod('getInstallerPackage'); + _cachedSource = (result == 'com.android.vending') + ? InstallSource.playStore + : InstallSource.sideload; } catch (e) { - debugPrint('[Updater] Installer detection failed, assuming sideload: $e'); + debugPrint('[Updater] Installer check failed: $e'); _cachedSource = InstallSource.sideload; - return _cachedSource!; } + + return _cachedSource!; } - static Future _detectInstallerSource() async { - // Install source detection is stubbed until the native method channel - // is wired in MainActivity.kt. Without it, all users default to the - // GitHub update path — which works, but Play Store users should ideally - // be sent to the Play Store listing instead. - // - // To wire: add a MethodChannel named "com.revoltvpn.app/installer" in - // MainActivity.kt that calls PackageManager.getInstallerPackageName(). - // If it returns "com.android.vending", return InstallSource.playStore. - // Then uncomment the 7 lines below and delete the fallback return. - // - // const channel = MethodChannel('com.revoltvpn.app/installer'); - // final installer = await channel.invokeMethod('getInstallerPackage'); - // if (installer == 'com.android.vending') { - // return InstallSource.playStore; - // } - // return InstallSource.sideload; - return InstallSource.sideload; // Stubbed — see above. - } - - /// Compares two semver strings. Positive = a > b, negative = a < b, 0 = equal. static int _compareVersions(String a, String b) { final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList(); final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList(); for (int i = 0; i < 3; i++) { - final av = i < aParts.length ? aParts[i] : 0; - final bv = i < bParts.length ? bParts[i] : 0; - if (av != bv) return av - bv; + final diff = (aParts.length > i ? aParts[i] : 0) - + (bParts.length > i ? bParts[i] : 0); + if (diff != 0) return diff; } return 0; } - /// Call this from the sidebar "Check for updates" button. - /// - /// Returns the [UpdateStatus] for programmatic use, but the UI - /// (snackbar or dialog) is already handled. - static Future check(BuildContext context) async { - // 1. Local version from the app bundle. - final localVersion = (await PackageInfo.fromPlatform()).version; - debugPrint('[Updater] Local version: $localVersion'); + static String _versionLabel(String v, bool isLocal) { + final prefix = v.startsWith('v') ? '' : 'v'; + return '$prefix$v${isLocal ? ' (installed)' : ''}'; + } + + static Future check(BuildContext context) async { + final localVersion = ReVoltApp.appVersion; + if (localVersion.isEmpty) { + _showSnackBar(context, 'Could not determine app version', + isError: true); + return; + } - // 2. Latest version from GitHub (single source of truth for both paths). final latestVersion = await GitHubUpdater.fetchLatestVersion(); if (latestVersion == null) { - debugPrint('[Updater] Could not determine latest version.'); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Could not check for updates. Try again later.'), - backgroundColor: AppColors.bgCard, - behavior: SnackBarBehavior.floating, - duration: Duration(seconds: 3), - ), - ); - } - return UpdateStatus.checkFailed; + _showSnackBar(context, 'Could not reach GitHub', isError: true); + return; } - debugPrint('[Updater] Latest version: $latestVersion'); - - // 3. Compare. if (_compareVersions(localVersion, latestVersion) >= 0) { - debugPrint('[Updater] App is up to date.'); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('✓ Up to date (v$localVersion)'), - backgroundColor: AppColors.bgCard, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - } - return UpdateStatus.upToDate; + _showSnackBar(context, 'Up to date'); + return; } - // 4. Update available — open the store immediately, no dialog. - debugPrint('[Updater] Update available: $localVersion → $latestVersion'); - final source = await installSource; - final isPlayStore = source == InstallSource.playStore; + final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub'; + if (!context.mounted) return; - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening ${isPlayStore ? 'Play Store' : 'GitHub'}…'), - backgroundColor: AppColors.bgCard, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.bgCard, + title: const Text('Update available', + style: TextStyle(color: AppColors.textWhite)), + content: Text( + '${_versionLabel(latestVersion, false)} is available on $storeName.\n' + 'You have ${_versionLabel(localVersion, true)}.', + style: const TextStyle(color: AppColors.slate70), ), - ); - } + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Later', + style: TextStyle(color: AppColors.slate70)), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + source == InstallSource.playStore + ? PlayStoreUpdater.open() + : GitHubUpdater.open(); + }, + child: Text('Open $storeName', + style: const TextStyle(color: AppColors.accent)), + ), + ], + ), + ); + } - if (isPlayStore) { - await PlayStoreUpdater.open(); - } else { - await GitHubUpdater.open(); - } - return UpdateStatus.updateAvailable; + static void _showSnackBar(BuildContext context, String message, + {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(isError ? Icons.error_outline : Icons.check_circle, + color: isError ? AppColors.red400 : AppColors.green400, + size: 18), + const SizedBox(width: 8), + Text(message), + ], + ), + backgroundColor: AppColors.bgCard, + duration: const Duration(seconds: 2), + ), + ); } } - - -// ############################################################################## -// # -// INTEGRATION GUIDE # -// # -// 1. pubspec.yaml — add: # -// # -// package_info_plus: ^6.0.0 # -// # -// 2. app_config.dart — add these constants: # -// # -// static const String applicationId = 'com.paladinvpn.app'; # -// static const String githubOwner = 'revoltvpn'; # -// static const String githubRepo = 'revoltvpn'; # -// # -// 3. Android native — add to MainActivity.kt for install source detection: # -// # -// override fun configureFlutterEngine(flutterEngine: FlutterEngine) { # -// super.configureFlutterEngine(flutterEngine) # -// MethodChannel( # -// flutterEngine.dartExecutor.binaryMessenger, # -// "com.revoltvpn.app/installer" # -// ).setMethodCallHandler { call, result -> # -// if (call.method == "getInstallerPackage") { # -// val installer = packageManager # -// .getInstallerPackageName(packageName) # -// result.success(installer) # -// } else { # -// result.notImplemented() # -// } # -// } # -// } # -// # -// 4. Already wired — sidebar button calls Updater.check(context). # -// # -// 5. Tag releases with semver on GitHub: # -// # -// git tag v1.0.7 # -// git push origin v1.0.7 # -// # -// ############################################################################## diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 726097e..b048bb9 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -38,10 +38,10 @@ class MainScreen extends StatelessWidget { final h = constraints.maxHeight; return Stack( children: [ - // ── Lightning — behind rain so drops stay visible during flash ── + // Lightning — behind rain const Positioned.fill(child: LightningEffect()), - // ── Rain effect — behind all UI, baked into the background ── + // Rain — behind all UI const Positioned.fill(child: RainEffect()), Positioned(