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
+211 -319

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) ### Protocol — TCP → XHTTP migration
- **Notification bug** — `SessionTimer.stop()` now cancels the persistent Android notification. Previously it lingered after user-initiated disconnect. - **Transport changed from TCP to XHTTP** (H2 stream-up with Reality).
- **Concurrent sync guard** — `_syncInProgress` flag in `_syncWithHivemind()` prevents duplicate HTTP requests from overlapping. XTLS-Vision (`xtls-rprx-vision`) removed — it only applies to TCP+TLS/Reality,
- **Debug bypass gate** — `AppConfig.enableAdBypass` (in `app_config.example.dart`) replaces `kDebugMode` for the fake AdMob callback. Tree-shaken in release. 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 ### Fixed — critical server bug
- **Responsive layout** — main screen uses `LayoutBuilder` with proportional fractions instead of hardcoded pixel positions. Pixel-identical on reference device, scales to any screen. - **Client hot-reload was broken.** `xray.py` called `xray api adi` (add inbounds,
- **Dynamic version** — sidebar footer reads `ReVoltApp.appVersion` from `PackageInfo` instead of hardcoded `v2.0.7`. zero arguments) after writing `config.json`. This was a no-op — Xray never
- **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`. 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) ### Fixed — client
- `/session/stop` fetches Xray stats BEFORE popping session — prevents race where final byte count is lost - **Missing XHTTP path in VLESS URL.** Server requires `path: "/revolt"` in
- `XrayManager._stats_lock` serializes diagnostic counter (non-atomic `+=` race) `xhttpSettings` but the VLESS URL never included a `path` parameter.
- `ManagementLoop._start_lock` prevents TOCTOU on daemon startup Fixed: `hivemind_service.dart` reads `xhttp_path` from `/session/status`,
- **Manual update checker** — "Check for updates" button in sidebar falls back to `AppConfig.vlessPath`.
- Uses GitHub Releases API to find the latest version - **`providerBundleIdentifier` mismatch.** Was `com.revoltvpn.app` — must match
- Up to date → snackbar "✓ Up to date" Android `applicationId` (`com.paladinvpn.app`) for VPN service registration.
- 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
### Changed ### Fixed — server config
- Sidebar now has 6 rows: Ad Consent, Privacy Policy, About, Discord, Website, Check for updates - **Reality keypair generated.** Replaced `GENERATE_ME_WITH_xray_x25519`
- API uses domain with proper TLS validation (traffic blends in with normal HTTPS) placeholders in both `config.py` and `xray_config_reality.json` with a real
- VPN tunnel destination pinned to hardcoded server IP (domain compromise = DoS only) 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 ### Added
- 6 new security audits (#8 through #12) covering domain exposure, network transit, server hardening, and pentesting - **`xhttp_path` in `/session/status` response.** Server now sends the XHTTP
- SNI rotation per session — 5 spoofed sites instead of 1 path to clients, same pattern as `reality_pbk`/`reality_sid`/`reality_sni`.
- Outbound abuse ports blocked (SMTP, NetBIOS, SSDP) `VLESS_XHTTP_PATH` constant in `config.py` is the single source of truth.
- Xray `flow: xtls-rprx-vision` properly set server-side - **Updated deployment guide** (`SERVER_DEPLOYMENT.md`) — full step-by-step
for XHTTP+Reality, verified against live Xray docs at xtls.github.io.
### Dependencies - **Updated cheatsheet** (`scraped/VLESS_XRAY_CHEATSHEET.md`) — all sections
- `package_info_plus` added 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 ### Fixed
- 60 min session, 4 GB throttle - **Notification bug** — `SessionTimer.stop()` now cancels the persistent
- Support top-up: +30 min +2 GB via rewarded ad 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 - Server health dot, speed display, countdown timer
- Persistent Android notification - Persistent Android notification
- Glass-morphism dark UI with yellow accent - Glass-morphism dark UI with yellow accent
+30 -26
View File
@@ -1,56 +1,60 @@
---
<p align="center"> <p align="center">
<img src="screenshots/feature_graphic.png" alt="ReVoltVPN banner" width="100%"/> <img src="screenshots/feature_graphic.png" alt="ReVoltVPN banner" width="100%"/>
</p> </p>
# ReVoltVPN # 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. 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.
So here it is.
--- ---
## How it works ## 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 ## Protocol
- **App** — Flutter (Android only for now)
- **Server** — A single Debian box rented from Hetzner, located in Finland - **Transport** — VLESS over XHTTP (HTTP/2 multiplexed, path-hidden behind `/revolt`)
- **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. - **Obfuscation** — Xray Reality spoofs `www.github.com` TLS. To any DPI box, your tunnel looks like a browser visiting GitHub
- **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. - **Encryption** — TLS 1.3 with borrowed certificate (Reality). No certbot, no domain ownership required
- **Ads** — Google AdMob rewarded ads, verified server-side so fake callbacks don't work - **API** — Standard HTTPS to a separate domain. Tunnel destination is IP-pinned — domain compromise is DoS only
--- ---
## Limitations (being honest) ## Stack
- 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 | Layer | Technology |
- 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 | 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 ## Limitations
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.
- One server, one location (Finland)
- 2 vCPU / 4 GB RAM — not built for thousands of concurrent users
- Android only
--- ---
## Privacy ## 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.* *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) { if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false; _busy = false;
timer.stop(); // MUST stop timer BEFORE disconnect — prevents auto-reconnect timer.stop();
await vpn.disconnect(); await vpn.disconnect();
return; return;
} }
@@ -116,7 +116,7 @@ class _ConnectButtonState extends State<ConnectButton>
final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0; final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
return Container( return Container(
width: 260, // <-- CHANGE ME: circle size width: 260,
height: 260, height: 260,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -138,7 +138,7 @@ class _ConnectButtonState extends State<ConnectButton>
), ),
) )
: Padding( : 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), 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 baseColor = AppColors.accent.withAlpha((opacity * 255).round());
final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round()); final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round());
// ── Glow layer (thick, blurry) ────────────── // Glow
final glowPaint = Paint() final glowPaint = Paint()
..color = glowColor ..color = glowColor
..strokeWidth = 18 ..strokeWidth = 18
@@ -187,7 +187,7 @@ class _LightningPainter extends CustomPainter {
_drawPath(canvas, scaledMain, scaledBranches, glowPaint); _drawPath(canvas, scaledMain, scaledBranches, glowPaint);
// ── Core bolt (thin, bright) ──────────────── // Core
final corePaint = Paint() final corePaint = Paint()
..color = baseColor ..color = baseColor
..strokeWidth = 5 ..strokeWidth = 5
@@ -197,7 +197,7 @@ class _LightningPainter extends CustomPainter {
_drawPath(canvas, scaledMain, scaledBranches, corePaint); _drawPath(canvas, scaledMain, scaledBranches, corePaint);
// ── Hot center (extra white core) ─────────── // Hot center
if (opacity > 0.6) { if (opacity > 0.6) {
final hotPaint = Paint() final hotPaint = Paint()
..color = Colors.white.withAlpha((opacity * 180).round()) ..color = Colors.white.withAlpha((opacity * 180).round())
+3 -3
View File
@@ -37,7 +37,7 @@ class _RainEffectState extends State<RainEffect>
final _random = Random(); final _random = Random();
Duration _last = Duration.zero; Duration _last = Duration.zero;
// ── Lifecycle ──────────────────────────────────────────────────────── // Lifecycle
@override @override
void initState() { void initState() {
@@ -54,7 +54,7 @@ class _RainEffectState extends State<RainEffect>
super.dispose(); super.dispose();
} }
// ── Tick — move drops, recycle off-screen ones ────────────────────── // Tick
void _onTick(Duration now) { void _onTick(Duration now) {
double dt = 0.016; // ~60 fps fallback on first tick double dt = 0.016; // ~60 fps fallback on first tick
@@ -74,7 +74,7 @@ class _RainEffectState extends State<RainEffect>
setState(() {}); setState(() {});
} }
// ── Build ──────────────────────────────────────────────────────────── // Build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+2 -2
View File
@@ -26,7 +26,7 @@ class StatusBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// ── Connection status (left) ───────────────────────────── // Connection status
if (isConnected) ...[ if (isConnected) ...[
Image.asset('assets/finland_flag_256.png', width: 20, height: 20), Image.asset('assets/finland_flag_256.png', width: 20, height: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -52,7 +52,7 @@ class StatusBar extends StatelessWidget {
const Spacer(), const Spacer(),
// ── Server health dot (right) ──────────────────────────── // Server health
OnlineDot( OnlineDot(
isOnline: serverHealthy, isOnline: serverHealthy,
label: isConnected label: isConnected
+2 -3
View File
@@ -25,7 +25,7 @@ class TimerBox extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ── Countdown text (always visible) ────────────────────── // Countdown
Text( Text(
timer.hasSyncedOnce ? timer.formatted : '00:00:00', timer.hasSyncedOnce ? timer.formatted : '00:00:00',
style: TextStyle( style: TextStyle(
@@ -46,8 +46,7 @@ class TimerBox extends StatelessWidget {
), ),
), ),
// ── Syncing indicator (only shown while connecting or // Syncing indicator
// waiting for the first server poll) ──────────────────
if (showIndicator) ...[ if (showIndicator) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( 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/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.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 { class AdManager extends ChangeNotifier {
/// Flip to `true` after Google Play launch when real ads are wired up.
static const bool adsEnabled = false; static const bool adsEnabled = false;
RewardedAd? _rewardedAd; RewardedAd? _rewardedAd;
@@ -32,15 +27,9 @@ class AdManager extends ChangeNotifier {
if (adsEnabled) preloadAd(); 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 { Future<bool> preloadAd() async {
if (!adsEnabled) return true; if (!adsEnabled) return true;
// If already loaded, return instantly
if (_isAdLoaded) return true; 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); if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false);
_isAdLoading = true; _isAdLoading = true;
@@ -84,12 +73,9 @@ class AdManager extends ChangeNotifier {
return _loadCompleter!.future; return _loadCompleter!.future;
} }
/// Displays the rewarded video ad to the user.
/// Returns false immediately when [adsEnabled] is false.
Future<bool> showAd(String adType) async { Future<bool> showAd(String adType) async {
if (!adsEnabled) return false; if (!adsEnabled) return false;
// 1. Ensure the ad is fully loaded before trying to show it
if (!_isAdLoaded || _rewardedAd == null) { if (!_isAdLoaded || _rewardedAd == null) {
final loaded = await preloadAd(); final loaded = await preloadAd();
if (!loaded || _rewardedAd == null) { 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(); 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}'; final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
HivemindService.setExpectedNonce(nonce); HivemindService.setExpectedNonce(nonce);
debugPrint('[AdManager] Ad nonce: $nonce'); debugPrint('[AdManager] Ad nonce: $nonce');
// 4. Build SSV options to send to our Hivemind server via Google's callback
final ssvOptions = ServerSideVerificationOptions( final ssvOptions = ServerSideVerificationOptions(
customData: jsonEncode({ customData: jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
@@ -118,7 +100,6 @@ class AdManager extends ChangeNotifier {
Completer<bool> rewardCompleter = Completer<bool>(); Completer<bool> rewardCompleter = Completer<bool>();
// 4. Setup full screen callbacks to handle dismissal and errors
_rewardedAd!.fullScreenContentCallback = FullScreenContentCallback( _rewardedAd!.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'), onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'),
onAdDismissedFullScreenContent: (ad) { onAdDismissedFullScreenContent: (ad) {
@@ -126,10 +107,9 @@ class AdManager extends ChangeNotifier {
ad.dispose(); ad.dispose();
_isAdLoaded = false; _isAdLoaded = false;
_rewardedAd = null; _rewardedAd = null;
// Start loading the next ad immediately
preloadAd(); preloadAd();
if (!rewardCompleter.isCompleted) { if (!rewardCompleter.isCompleted) {
rewardCompleter.complete(false); // User closed before earning reward rewardCompleter.complete(false);
} }
}, },
onAdFailedToShowFullScreenContent: (ad, error) { 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); _rewardedAd!.setServerSideOptions(ssvOptions);
await _rewardedAd!.show( await _rewardedAd!.show(
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) { onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}'); 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) { if (!rewardCompleter.isCompleted) {
rewardCompleter.complete(true); rewardCompleter.complete(true);
} }
+3 -10
View File
@@ -10,18 +10,14 @@ class HivemindService {
static String? _expectedNonce; static String? _expectedNonce;
static int _currentCallId = 0; static int _currentCallId = 0;
/// Call this from disconnect to abort any in-progress polling.
static void cancel() { static void cancel() {
_currentCallId++; // bump — any running loop sees a stale ID and exits _currentCallId++;
} }
static void setExpectedNonce(String nonce) { static void setExpectedNonce(String nonce) {
_expectedNonce = 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({ static Future<String> fetchConfigWithPolling({
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
bool skipAdBypass = false, bool skipAdBypass = false,
@@ -36,7 +32,7 @@ class HivemindService {
final url = _url('/session/status?device_id=$deviceId'); final url = _url('/session/status?device_id=$deviceId');
// ── AD BYPASS (debug only) ──────────────────────────────────────── // Dev: fake ad callback when running in debug mode.
if (kDebugMode && !skipAdBypass) { if (kDebugMode && !skipAdBypass) {
try { try {
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
@@ -77,10 +73,8 @@ class HivemindService {
final fp = data['reality_fp'] ?? AppConfig.realityFp; final fp = data['reality_fp'] ?? AppConfig.realityFp;
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath; final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
// host is intentionally omitted per XHTTP docs: // Host header not set — server doesn't require it.
// "建议没事别设" — the server doesn't set host, so it won't check.
// Client falls through: host → serverName (SNI) → address. // Client falls through: host → serverName (SNI) → address.
// With Reality SNI = github.com, the Host header blends naturally.
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort' final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
'?security=${AppConfig.vlessSecurity}' '?security=${AppConfig.vlessSecurity}'
'&type=${AppConfig.vlessType}' '&type=${AppConfig.vlessType}'
@@ -140,7 +134,6 @@ class HivemindService {
} catch (_) {} } catch (_) {}
} }
/// Builds a URI pointing directly at the server IP — no domain involved.
static Uri _url(String path) { static Uri _url(String path) {
return Uri.parse('${AppConfig.hivemindApiBase}$path'); return Uri.parse('${AppConfig.hivemindApiBase}$path');
} }
+1 -1
View File
@@ -218,7 +218,7 @@ class SessionTimer extends ChangeNotifier {
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
// ── Detect port change (throttle engage / disengage) ────── // Port change — throttle engaged or disengaged
final serverPort = data['vless_port']; final serverPort = data['vless_port'];
if (serverPort != null && if (serverPort != null &&
_currentPort != null && _currentPort != null &&
+83 -207
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_config.dart';
import 'package:revoltvpn/logic/app_colors.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 }
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 #
// #
// ##############################################################################
class PlayStoreUpdater { class PlayStoreUpdater {
PlayStoreUpdater._(); PlayStoreUpdater._();
@@ -42,10 +18,10 @@ class PlayStoreUpdater {
static String get _url => static String get _url =>
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}'; '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 { static Future<bool> open() async {
try { try {
return await launchUrl(Uri.parse(_url), mode: LaunchMode.externalApplication); return await launchUrl(Uri.parse(_url),
mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
debugPrint('[Updater] Failed to open Play Store: $e'); debugPrint('[Updater] Failed to open Play Store: $e');
return false; 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 { class GitHubUpdater {
GitHubUpdater._(); GitHubUpdater._();
static String? _cachedDownloadUrl; 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 { 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 { try {
final response = await http 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)); .timeout(const Duration(seconds: 8));
if (response.statusCode != 200) { if (response.statusCode != 200) {
@@ -103,11 +60,11 @@ class GitHubUpdater {
} }
} }
/// Opens the GitHub releases page for the latest release.
static Future<bool> open() async { static Future<bool> open() async {
final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
try { try {
return await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); return await launchUrl(Uri.parse(url),
mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
debugPrint('[Updater] Failed to open GitHub: $e'); debugPrint('[Updater] Failed to open GitHub: $e');
return false; 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 { class Updater {
Updater._(); Updater._();
static InstallSource? _cachedSource; 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 { static Future<InstallSource> get installSource async {
if (_cachedSource != null) return _cachedSource!; if (_cachedSource != null) return _cachedSource!;
if (kIsWeb || !Platform.isAndroid) { if (kIsWeb || !Platform.isAndroid) {
_cachedSource = InstallSource.sideload; _cachedSource = InstallSource.sideload;
return _cachedSource!; 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 { try {
final source = await _detectInstallerSource(); final result = await MethodChannel('com.revoltvpn.app/installer')
_cachedSource = source; .invokeMethod<String>('getInstallerPackage');
debugPrint('[Updater] Install source: ${source.name}'); _cachedSource = (result == 'com.android.vending')
return source; ? InstallSource.playStore
: InstallSource.sideload;
} catch (e) { } catch (e) {
debugPrint('[Updater] Installer detection failed, assuming sideload: $e'); debugPrint('[Updater] Installer check failed: $e');
_cachedSource = InstallSource.sideload; _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) { static int _compareVersions(String a, String b) {
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList(); final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList();
final bParts = b.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++) { for (int i = 0; i < 3; i++) {
final av = i < aParts.length ? aParts[i] : 0; final diff = (aParts.length > i ? aParts[i] : 0) -
final bv = i < bParts.length ? bParts[i] : 0; (bParts.length > i ? bParts[i] : 0);
if (av != bv) return av - bv; if (diff != 0) return diff;
} }
return 0; return 0;
} }
/// Call this from the sidebar "Check for updates" button. static String _versionLabel(String v, bool isLocal) {
/// final prefix = v.startsWith('v') ? '' : 'v';
/// Returns the [UpdateStatus] for programmatic use, but the UI return '$prefix$v${isLocal ? ' (installed)' : ''}';
/// (snackbar or dialog) is already handled. }
static Future<UpdateStatus> check(BuildContext context) async {
// 1. Local version from the app bundle. static Future<void> check(BuildContext context) async {
final localVersion = (await PackageInfo.fromPlatform()).version; final localVersion = ReVoltApp.appVersion;
debugPrint('[Updater] Local version: $localVersion'); 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(); final latestVersion = await GitHubUpdater.fetchLatestVersion();
if (latestVersion == null) { if (latestVersion == null) {
debugPrint('[Updater] Could not determine latest version.'); _showSnackBar(context, 'Could not reach GitHub', isError: true);
return;
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;
} }
debugPrint('[Updater] Latest version: $latestVersion');
// 3. Compare.
if (_compareVersions(localVersion, latestVersion) >= 0) { if (_compareVersions(localVersion, latestVersion) >= 0) {
debugPrint('[Updater] App is up to date.'); _showSnackBar(context, 'Up to date');
return;
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;
}
// 4. Update available — open the store immediately, no dialog.
debugPrint('[Updater] Update available: $localVersion → $latestVersion');
final source = await installSource; final source = await installSource;
final isPlayStore = source == InstallSource.playStore; final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
if (!context.mounted) return;
if (context.mounted) { showDialog(
ScaffoldMessenger.of(context).showSnackBar( context: context,
SnackBar( builder: (ctx) => AlertDialog(
content: Text('Opening ${isPlayStore ? 'Play Store' : 'GitHub'}…'),
backgroundColor: AppColors.bgCard, backgroundColor: AppColors.bgCard,
behavior: SnackBarBehavior.floating, title: const Text('Update available',
duration: const Duration(seconds: 2), 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) { static void _showSnackBar(BuildContext context, String message,
await PlayStoreUpdater.open(); {bool isError = false}) {
} else { ScaffoldMessenger.of(context).showSnackBar(
await GitHubUpdater.open(); SnackBar(
} content: Row(
return UpdateStatus.updateAvailable; 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; final h = constraints.maxHeight;
return Stack( return Stack(
children: [ children: [
// ── Lightning — behind rain so drops stay visible during flash ── // Lightning — behind rain
const Positioned.fill(child: LightningEffect()), const Positioned.fill(child: LightningEffect()),
// ── Rain effect — behind all UI, baked into the background ── // Rain — behind all UI
const Positioned.fill(child: RainEffect()), const Positioned.fill(child: RainEffect()),
Positioned( Positioned(