diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..55bfe80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to the ReVoltVPN Flutter app. +Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH + +--- + +## [1.0.7] — Unreleased + +### Added +- **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 (v1.0.7)" + - Update available → opens Play Store or GitHub releases page immediately (no dialogs) +- **Website button** in sidebar → opens `https://getrevolt.app` + +### Changed +- Sidebar now has 6 rows: Ad Consent, Privacy Policy, About, Discord, Website, Check for updates + +### Dependencies +- `package_info_plus` added to pubspec — for reading the local version from the app bundle + +--- + +## [1.0.6] — Previous release + +- VLESS + Xray Reality tunnel with XTLS-Vision +- 60 min session, 4 GB throttle +- Support top-up: +30 min +2 GB via rewarded ad +- Server health dot, speed display, countdown timer +- Persistent Android notification +- Glass-morphism dark UI with yellow accent +- Right-edge sidebar with Ad Consent, Privacy, About, Discord diff --git a/lib/components/sidebar_contents/website_button.dart b/lib/components/sidebar_contents/website_button.dart new file mode 100644 index 0000000..27550b7 --- /dev/null +++ b/lib/components/sidebar_contents/website_button.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; + +class WebsiteButton extends StatelessWidget { + const WebsiteButton({super.key}); + + static Future launchWebsite() async { + final Uri url = Uri.parse('https://getrevolt.app'); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + debugPrint('Could not launch $url'); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.language, color: AppColors.textWhite, size: 28), + onPressed: launchWebsite, + tooltip: 'Website', + splashRadius: 24, + ); + } +} diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart index ee8caf0..623d3db 100644 --- a/lib/components/sidebar_drawer.dart +++ b/lib/components/sidebar_drawer.dart @@ -4,6 +4,8 @@ import 'package:revoltvpn/components/sidebar_contents/gdpr_button.dart'; import 'package:revoltvpn/components/sidebar_contents/privacy_policy_button.dart'; import 'package:revoltvpn/components/sidebar_contents/info_button.dart'; import 'package:revoltvpn/components/sidebar_contents/discord_link_button.dart'; +import 'package:revoltvpn/components/sidebar_contents/website_button.dart'; +import 'package:revoltvpn/logic/updater.dart'; class SidebarDrawer extends StatelessWidget { const SidebarDrawer({super.key}); @@ -84,11 +86,29 @@ class SidebarDrawer extends StatelessWidget { }, ), + _Row( + icon: Icons.language, + label: 'Website', + onTap: () { + Navigator.of(context).pop(); + WebsiteButton.launchWebsite(); + }, + ), + + _Row( + icon: Icons.system_update_alt, + label: 'Check for updates', + onTap: () { + Navigator.of(context).pop(); + Updater.check(context); + }, + ), + const Spacer(), const Padding( padding: EdgeInsets.all(20), - child: Text('ReVoltVPN · v1.0.6', textAlign: TextAlign.center, + child: Text('ReVoltVPN · v1.0.7', textAlign: TextAlign.center, style: TextStyle(color: AppColors.slate, fontSize: 11, letterSpacing: 1), ), ), diff --git a/lib/components/timer_box.dart b/lib/components/timer_box.dart index 6dd17e7..3aa3368 100644 --- a/lib/components/timer_box.dart +++ b/lib/components/timer_box.dart @@ -2,14 +2,19 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/session_timer.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; class TimerBox extends StatelessWidget { const TimerBox({super.key}); @override Widget build(BuildContext context) { - return Consumer( - builder: (context, timer, _) { + return Consumer2( + builder: (context, timer, vpn, _) { + final isConnecting = vpn.status == VpnStatus.connecting; + final isSyncing = vpn.status == VpnStatus.connected && !timer.hasSyncedOnce; + final showIndicator = isConnecting || isSyncing; + return Container( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), decoration: BoxDecoration( @@ -17,20 +22,59 @@ class TimerBox extends StatelessWidget { borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.slate15), ), - child: Text( - timer.hasSyncedOnce ? timer.formatted : '00:00:00', - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - color: AppColors.textWhite, - letterSpacing: 2, - shadows: [ - Shadow( - color: AppColors.accent50, - blurRadius: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Countdown text (always visible) ────────────────────── + Text( + timer.hasSyncedOnce ? timer.formatted : '00:00:00', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: timer.hasSyncedOnce + ? AppColors.textWhite + : AppColors.slate70, + letterSpacing: 2, + shadows: timer.hasSyncedOnce + ? const [ + Shadow( + color: AppColors.accent50, + blurRadius: 16, + ), + ] + : [], + ), + ), + + // ── Syncing indicator (only shown while connecting or + // waiting for the first server poll) ────────────────── + if (showIndicator) ...[ + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accent60, + ), + ), + const SizedBox(width: 8), + Text( + isConnecting ? 'Connecting…' : 'Syncing…', + style: const TextStyle( + fontSize: 12, + color: AppColors.accent60, + fontWeight: FontWeight.w600, + letterSpacing: 1, + ), + ), + ], ), ], - ), + ], ), ); }, diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index 29a8c99..3934ea6 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -1,19 +1,12 @@ // ============================================================================= // app_config.example.dart — TEMPLATE FOR SERVER SETTINGS // ───────────────────────────────────────────────────────────────────────────── -// Rename this file to `app_config.dart` and fill in your real values. -// Do not commit your real `app_config.dart` to GitHub! -// -// Most Reality tunnel fields (vless_ip, vless_port, reality_pbk, reality_sid, -// reality_sni, reality_fp) come from the server's /session/status response. -// The constants below are fallbacks — the server is the source of truth. -// ============================================================================= abstract final class AppConfig { AppConfig._(); // ── Server (API) ────────────────────────────────────────────────────── - static const String serverDomain = 'yourdomain.com'; // ◄── REPLACE + static const String serverDomain = 'yourdomain.com'; static String get hivemindApiBase => 'https://$serverDomain/api'; @@ -27,6 +20,15 @@ abstract final class AppConfig { static const String vlessType = 'tcp'; static const String vlessFlow = 'xtls-rprx-vision'; + // ── Updates ──────────────────────────────────────────────────────────── + /// Used by the updater. + static const String applicationId = 'com.paladinvpn.app'; + static const String githubOwner = 'YOUR_USERNAME'; + static const String githubRepo = 'revoltvpn'; + + /// Fallback URL if the GitHub API call fails. + static const String githubReleasesUrl = 'https://github.com/$githubOwner/$githubRepo/releases/latest'; + // ── AdMob ────────────────────────────────────────────────────────────── static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE } diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart new file mode 100644 index 0000000..d5a3986 --- /dev/null +++ b/lib/logic/updater.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +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, +} + +/// 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 # +// # +// ############################################################################## + +class PlayStoreUpdater { + 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); + } catch (e) { + debugPrint('[Updater] Failed to open Play Store: $e'); + return false; + } + } +} + + +// ############################################################################## +// # +// 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'; + + try { + final response = await http + .get(Uri.parse(url), headers: {'Accept': 'application/vnd.github+json'}) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode != 200) { + debugPrint('[Updater] GitHub API returned ${response.statusCode}'); + return null; + } + + final data = jsonDecode(response.body) as Map; + _cachedDownloadUrl = data['html_url'] as String?; + + final tag = data['tag_name'] as String? ?? '0.0.0'; + return tag.startsWith('v') ? tag.substring(1) : tag; + } catch (e) { + debugPrint('[Updater] GitHub API error: $e'); + return null; + } + } + + /// 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); + } catch (e) { + debugPrint('[Updater] Failed to open GitHub: $e'); + return false; + } + } +} + + +// ############################################################################## +// # +// 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; + } catch (e) { + debugPrint('[Updater] Installer detection failed, assuming sideload: $e'); + _cachedSource = InstallSource.sideload; + return _cachedSource!; + } + } + + static Future _detectInstallerSource() async { + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ TODO — BEFORE PLAY STORE RELEASE ║ + // ║ ║ + // ║ Wire this method channel in MainActivity.kt so Play Store users ║ + // ║ get redirected to the Play Store listing instead of GitHub. ║ + // ║ ║ + // ║ Kotlin (android/app/src/main/kotlin/.../MainActivity.kt): ║ + // ║ ║ + // ║ 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() } ║ + // ║ } ║ + // ║ } ║ + // ║ ║ + // ║ Then uncomment the 7 lines below this block. ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + // ── Uncomment after wiring MainActivity.kt ────────────────────────── + // 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; // TODO: remove this line after uncommenting 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; + } + 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'); + + // 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; + } + + 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; + } + + // 4. Update available — open the store immediately, no dialog. + debugPrint('[Updater] Update available: $localVersion → $latestVersion'); + + final source = await installSource; + final isPlayStore = source == InstallSource.playStore; + + 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), + ), + ); + } + + if (isPlayStore) { + await PlayStoreUpdater.open(); + } else { + await GitHubUpdater.open(); + } + return UpdateStatus.updateAvailable; + } +} + + +// ############################################################################## +// # +// 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/pubspec.yaml b/pubspec.yaml index 11e5b8e..3bc7d99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.6+10 +version: 1.0.7+11 environment: sdk: ">=3.0.0 <4.0.0" @@ -38,6 +38,7 @@ dependencies: uuid: ^4.5.3 flutter_local_notifications: ^17.0.0 path_provider: ^2.1.5 + package_info_plus: ^6.0.0 dev_dependencies: flutter_test: diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index 596da24..26a731a 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -635,7 +635,8 @@ def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_REALITY_TAG): inbound["settings"]["clients"].append({ "id": uuid, "email": email, - "level": level + "level": level, + "flow": "xtls-rprx-vision" }) break