cleaning up

This commit is contained in:
esefxdz committed 2026-08-02 00:56:55 +03:00
1 parent c1ec77a075
commit 75cf59f22c
12 files changed
+214 -322

No files matched your search

+77 -35
View File
@@ -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
+30 -26
View File
@@ -1,56 +1,60 @@
---
<p align="center">
<img src="screenshots/feature_graphic.png" alt="ReVoltVPN banner" width="100%"/>
</p>
# 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.*
---
+3 -3
View File
@@ -65,7 +65,7 @@ class _ConnectButtonState extends State<ConnectButton>
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<ConnectButton>
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<ConnectButton>
),
)
: 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),
),
);
+3 -3
View File
@@ -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())
+3 -3
View File
@@ -37,7 +37,7 @@ class _RainEffectState extends State<RainEffect>
final _random = Random();
Duration _last = Duration.zero;
// ── Lifecycle ────────────────────────────────────────────────────────
// Lifecycle
@override
void initState() {
@@ -54,7 +54,7 @@ class _RainEffectState extends State<RainEffect>
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<RainEffect>
setState(() {});
}
// ── Build ────────────────────────────────────────────────────────────
// Build
@override
Widget build(BuildContext context) {
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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(
+2 -24
View File
@@ -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<bool> 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<bool> 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<bool> rewardCompleter = Completer<bool>();
// 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);
}
+3 -10
View File
@@ -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<String> 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');
}
+1 -1
View File
@@ -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 &&
+86 -210
View File
@@ -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<bool> 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<String?> 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<bool> 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<InstallSource> 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<String>('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<InstallSource> _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<String>('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<UpdateStatus> 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<void> 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 #
// #
// ##############################################################################
+2 -2
View File
@@ -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(