Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a89af3c62 | ||
|
|
62104d5730 | ||
|
|
4eb1fca666 | ||
|
|
71468f7594 | ||
|
|
6adf03f920 | ||
|
|
5642b0b811 | ||
|
|
096fbf5a97 | ||
|
|
6c137245fd | ||
|
|
cc819abc41 |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.jks binary
|
||||
*.keystore binary
|
||||
*.jar binary
|
||||
+68
-91
@@ -1,23 +1,62 @@
|
||||
# =============================================================================
|
||||
# ReVoltVPN — .gitignore
|
||||
#
|
||||
# Type-based by design. This repo ships Dart, Kotlin, XML and image assets;
|
||||
# everything else is excluded by extension or directory rather than by being
|
||||
# listed individually. A new file is therefore ignored by default instead of
|
||||
# needing to be named here, and this file does not enumerate what it protects.
|
||||
|
||||
# =============================================================================
|
||||
|
||||
# ── OS & Editor ────────────────────────────────────────────────────────────
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.history
|
||||
.svn/
|
||||
.java-version
|
||||
.idea/
|
||||
.vscode/
|
||||
# ── Secrets ────────────────────────────────────────────────────────────────
|
||||
*.keystore
|
||||
*.jks
|
||||
android/key.properties
|
||||
android/admob.properties
|
||||
|
||||
# ── Python ─────────────────────────────────────────────────────────────────
|
||||
__pycache__/
|
||||
# Real IPs and IDs. app_config.example.dart is the committed template.
|
||||
lib/logic/app_config.dart
|
||||
|
||||
# ── File types the shipped app does not contain ────────────────────────────
|
||||
# Server code, infrastructure configs, captures, archives, dumps and logs.
|
||||
# None of these extensions are tracked, so a blanket rule is safe and stays
|
||||
# safe — it covers files that do not exist yet.
|
||||
*.py
|
||||
*.pyc
|
||||
*.json
|
||||
*.conf
|
||||
*.sh
|
||||
*.txt
|
||||
*.html
|
||||
*.zip
|
||||
*.log
|
||||
*.class
|
||||
|
||||
# ── Flutter / Dart ─────────────────────────────────────────────────────────
|
||||
# ── Documentation ──────────────────────────────────────────────────────────
|
||||
# The published set is allow-listed; anything else, at any depth, is not.
|
||||
# Leading slashes anchor these to the repo root so a same-named file in a
|
||||
# subdirectory is NOT re-included by the negation.
|
||||
*.md
|
||||
!/README.md
|
||||
!/CHANGELOG.md
|
||||
!/CONTRIBUTING.md
|
||||
!/PRIVACY_POLICY.md
|
||||
|
||||
# ── Working directories ────────────────────────────────────────────────────
|
||||
# Material that is not part of the app. These need directory rules rather
|
||||
# than type rules, because they can hold source-file types the rules above
|
||||
# deliberately do not cover.
|
||||
.local/
|
||||
internal/
|
||||
hivemind/
|
||||
scraped/
|
||||
backup/
|
||||
backups/
|
||||
lib/logic/backup/
|
||||
|
||||
# ── Flutter / Dart build output ────────────────────────────────────────────
|
||||
.dart_tool/
|
||||
*.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
@@ -26,97 +65,35 @@ build/
|
||||
**/build/
|
||||
local_packages/*/build/
|
||||
local_packages/*/.dart_tool/
|
||||
*.dart_tool/
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
|
||||
# ── Android ────────────────────────────────────────────────────────────────
|
||||
**/android/**/gradle-wrapper.jar
|
||||
# ── Android build output ───────────────────────────────────────────────────
|
||||
**/android/.gradle
|
||||
**/android/.cxx/
|
||||
**/android/app/build/
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/local.properties
|
||||
**/android/**/gradle-wrapper.jar
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
||||
**/android/.cxx/
|
||||
**/android/app/build/
|
||||
**/android/app/crashlytics-build.properties
|
||||
|
||||
# ── Secrets & Keystores ───────────────────────────────────────────────────
|
||||
*.keystore
|
||||
*.jks
|
||||
android/key.properties
|
||||
android/admob.properties
|
||||
|
||||
# App config (contains real IPs/IDs — use example as template)
|
||||
lib/logic/app_config.dart
|
||||
|
||||
# ── Hivemind server (deployed separately, not part of the Flutter repo) ───
|
||||
hivemind/
|
||||
server_hivemind_5_0.py
|
||||
swarm_watch.py
|
||||
nginx_hivemind_reality.conf
|
||||
xray_config_reality.json
|
||||
swarm_total.json
|
||||
|
||||
# ── Internal docs (not for public repo) ────────────────────────────────────
|
||||
AGENTS.md
|
||||
FRONTEND_POLISH_REVIEW.md
|
||||
RUNNING.md
|
||||
SERVER_DEPLOYMENT.md
|
||||
SERVER_NOTES.md
|
||||
SERVER_SETUP.md
|
||||
REALITY_MIGRATION_PLAN.md
|
||||
SECURITY_HARDENING.md
|
||||
VLESS_APP_CONTRACT.md
|
||||
UI_POSITIONING.md
|
||||
|
||||
# ── Technical debt & security audits ───────────────────────────────────────
|
||||
technical_debt_*.md
|
||||
technical_debt_*_answer.md
|
||||
|
||||
# ── Backups & reference files ──────────────────────────────────────────────
|
||||
backups/
|
||||
backup/
|
||||
hivemind_backup.py
|
||||
server full picture.txt
|
||||
server_full_picture_2.txt
|
||||
00-FULL-REPORT.md
|
||||
XRAY_CONFIG_REFERENCE.md
|
||||
|
||||
# ── Logs & temp ────────────────────────────────────────────────────────────
|
||||
*.log
|
||||
*.class
|
||||
server_picture.sh
|
||||
|
||||
# ── AI assistant ───────────────────────────────────────────────────────────
|
||||
# ── Editors, tooling & OS ──────────────────────────────────────────────────
|
||||
.claude/
|
||||
.codewhale/
|
||||
|
||||
# ── Dev tool config ────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
.atom/
|
||||
.history
|
||||
.svn/
|
||||
.java-version
|
||||
.DS_Store
|
||||
*.swp
|
||||
__pycache__/
|
||||
devtools_options.yaml
|
||||
lib/logic/backup/
|
||||
scraped
|
||||
scraped.zip
|
||||
hivemind.zip
|
||||
XRAY_AUDIT.md
|
||||
TECHNICAL_DEBT.md
|
||||
DOCUMENTATION-ARCHITECTURE-V2.md
|
||||
config.json
|
||||
RESILIENCE_REVIEW.md
|
||||
CONFIG_REVIEW.md
|
||||
SERVER_SELECTION.md
|
||||
THREAT_MODEL.md
|
||||
BUSINESS_MODEL.md
|
||||
DPI_FINGERPRINT_ANALYSIS.md
|
||||
CODE_FINGERPRINT_AUDIT.md
|
||||
CONFERENCE_PREP.md
|
||||
CONVERSION_CHECKLIST.md
|
||||
HIVEMIND_INTERNALS.md
|
||||
|
||||
#why are you even looking at this file? it's a secret. go away.
|
||||
SERVER_ARCHITECTURE.md
|
||||
turbovpn_play.html
|
||||
supervpn_play.html
|
||||
hetzner*
|
||||
DEPLOYMENT_REPORT_FINAL.md
|
||||
#why are you even looking at this file? it's a secret. go away.
|
||||
whatsthis
|
||||
@@ -5,6 +5,82 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
||||
|
||||
---
|
||||
|
||||
## [3.3.0] — 2026-08-30
|
||||
|
||||
### Added
|
||||
- Session countdown in the VPN notification, updated each second while connected.
|
||||
|
||||
### Fixed
|
||||
- **Session-expiry desync** — the app could show "Online" with no internet after
|
||||
the server ended the session. The countdown no longer freezes when a poll fails.
|
||||
- **Notification Disconnect button** disappeared a second after connecting.
|
||||
Re-posting the foreground notification replaces it wholesale, so the action,
|
||||
colour and tap-intent flutter_vless sets are now rebuilt rather than dropped.
|
||||
- **Speed readout** divided the byte delta by the nominal poll interval instead of
|
||||
the real time between polls — wrong by up to 6x around every background switch.
|
||||
- **Settings toggles** now apply at launch. Rain, lightning and haptics were only
|
||||
read from disk when Settings mounted, so an effect turned off came back — ticker
|
||||
and all — on every relaunch, and haptic feedback did nothing until Settings had
|
||||
been opened at least once.
|
||||
- **Haptic feedback** now fires `mediumImpact()` instead of `lightImpact()` (which
|
||||
is imperceptible on many Android devices), wrapped best-effort so a platform
|
||||
vibration failure cannot fail a connect/disconnect, with a preview tap when the
|
||||
toggle is switched on.
|
||||
- First-launch disclosure dialog said data was linked to "your account". It is
|
||||
linked to a randomly generated device ID, and the policy is in the sidebar, not
|
||||
on a website.
|
||||
- Sidebar "Settings" and "Check for updates" used the drawer's context after
|
||||
`Navigator.pop()` — a defunct context. They now capture the root navigator (and
|
||||
messenger) before closing the drawer. *(Fix ported from Naveax's review.)*
|
||||
|
||||
### Performance
|
||||
- Rain overlay ticks only while enabled, repaints through a scoped `Listenable`
|
||||
instead of rebuilding every frame, and precomputes drop colours.
|
||||
- Lightning no longer fires strikes while disabled.
|
||||
- Connect button pulse pauses when backgrounded; brand image hoisted out of the
|
||||
per-frame rebuild.
|
||||
- Server poll drops to 30 s while backgrounded (5 s foreground), with an immediate
|
||||
re-sync on resume. The 1 s countdown keeps running.
|
||||
- Health probe runs only while disconnected, and pauses in the background.
|
||||
- Speed and support widgets rebuild on value change instead of every second.
|
||||
- Notification channel is created once per process, not once per second.
|
||||
|
||||
### Changed
|
||||
- VLESS engine initializes under the splash screen; the intro waits on
|
||||
`VpnConnection.ready` (8 s cap) before showing the main screen.
|
||||
- AdMob SDK init moved off the blocking pre-`runApp()` path so startup cannot hang
|
||||
on an unreachable Google endpoint.
|
||||
- `app_config.example.dart` drops the dead bootstrap fields and documents
|
||||
`hivemindApiPublic` in their place.
|
||||
- `.gitignore` reorganized around type and directory rules; the enumerated
|
||||
filename list is gone (redundant with the type rules) and `.claude/` is now
|
||||
ignored.
|
||||
|
||||
### Security
|
||||
- Update checker pins the release URL to this repo's path on `github.com`, not just
|
||||
the host. Anything else falls back to the releases page.
|
||||
- Notification timer updates are gated on a live tunnel, so a late tick cannot leave
|
||||
an ongoing notification with no foreground service behind it.
|
||||
- Dropped `READ_EXTERNAL_STORAGE`, which `flutter_vless` pulls in but the app never uses
|
||||
(least privilege).
|
||||
- Pinned the Gradle 8.14 distribution SHA-256; `validateDistributionUrl` enabled
|
||||
(supply-chain integrity).
|
||||
|
||||
### Build & tooling (ported from Naveax)
|
||||
- Removed the deprecated `android.enableR8` flag.
|
||||
- Added `analysis_options.yaml` (flutter_lints, `local_packages/**` excluded, `avoid_print`).
|
||||
- Added `.gitattributes` to pin LF line endings repo-wide.
|
||||
- Credit: reviewed and ported from **Naveax**.
|
||||
|
||||
### Docs
|
||||
- Privacy policy: disclosed that the server sees the connecting IP, corrected the
|
||||
"stored in memory" claim, and documented consent being changeable from the sidebar.
|
||||
|
||||
## [3.2.2] — 2026-08-08
|
||||
|
||||
### Fixed
|
||||
- Updated Discord invite link.
|
||||
|
||||
## [3.2.1] — 2026-08-08
|
||||
|
||||
### Google Play compliance
|
||||
|
||||
+19
-4
@@ -4,12 +4,26 @@
|
||||
|
||||
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
||||
|
||||
## In-app disclosure
|
||||
|
||||
When you first open the app, we show a brief summary of this policy before you connect. You can review this full policy at any time from the sidebar menu.
|
||||
|
||||
## What we collect
|
||||
|
||||
We collect the minimum required to operate the service:
|
||||
|
||||
- A randomly generated device ID created on your phone. This is not linked to your identity in any way.
|
||||
- Your active session status and data usage for the current session only. Sessions last up to 1 hour, then expire. Session data is stored in memory and deleted when your session ends.
|
||||
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
|
||||
|
||||
## What our server sees
|
||||
|
||||
To start, check, or refresh your session, the app contacts our API directly rather than through the VPN tunnel. Our server therefore sees the IP address you are connecting from, alongside your device ID, for as long as the app is running. This is a necessary consequence of how sessions are issued and kept alive — it is separate from your browsing traffic, which is carried inside the tunnel and is described below.
|
||||
|
||||
## Session quotas
|
||||
|
||||
Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session records (duration and bytes transferred) are held on our server for the life of your session and removed when it ends.
|
||||
|
||||
Support ads (the "Support us" button) extend an active session by 30 minutes and add extra data allowance.
|
||||
|
||||
## Operational logs
|
||||
|
||||
@@ -18,16 +32,17 @@ We keep a short in-memory log buffer (last 200 lines) for debugging and server h
|
||||
## What we do not collect
|
||||
|
||||
- We do not collect your name, email, or any personal information.
|
||||
- We do not log your internet traffic or browsing history.
|
||||
- We do not log your internet traffic, browsing history, DNS queries, or destination IPs.
|
||||
- We do not inspect or store the content of your traffic.
|
||||
- We do not sell or share any data with third parties.
|
||||
|
||||
## Ads & Consent
|
||||
|
||||
ReVoltVPN uses Google AdMob to display rewarded video ads. On first launch, users in the European Economic Area (EEA) and United Kingdom are shown a consent dialog (provided by Google's User Messaging Platform) that lets them choose whether to allow personalized ads. This consent choice is stored on your device and can be changed at any time by reinstalling the app. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
|
||||
ReVoltVPN uses Google AdMob to display rewarded video ads. Where required — for users in the European Economic Area (EEA) and United Kingdom — a consent dialog provided by Google's User Messaging Platform is shown before the first ad is requested, letting you choose whether to allow personalized ads. This choice is stored on your device and can be reviewed at any time from the "Ad Consent" entry in the sidebar menu. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
|
||||
|
||||
## Your traffic
|
||||
|
||||
Your internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic. We only track total bytes transferred per session (for quota enforcement and blocking abusers) — no packet contents, no destination IPs, no DNS queries.
|
||||
Your internet traffic is routed through our server located in Finland. We do not inspect, log, or store your traffic. We only track total bytes transferred per session for quota enforcement — no packet contents, no destination IPs, no DNS queries.
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# ReVoltVPN
|
||||
|
||||
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.
|
||||
A free VPN app for Android. Open-source client, transparent infrastructure. Watch an ad, get 2 hours of full-speed traffic — no accounts, no logs, no subscriptions.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@ No email. No password. No payment. The ad pays for the server.
|
||||
## 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
|
||||
- **Obfuscation** — Xray Reality spoofs `some website's` TLS. To any DPI box, your tunnel looks like a browser visiting that website
|
||||
- **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
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- local_packages/**
|
||||
- whatsthis/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: true
|
||||
@@ -1,19 +1,25 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<!-- VpnService provides its own system notification; SPECIAL_USE is not needed -->
|
||||
<!-- Required by url_launcher on Android 11+ to open external browser -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
<application
|
||||
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
tools:node="remove"/>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="Revolt VPN"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -21,7 +27,6 @@
|
||||
android:allowBackup="false"
|
||||
android:extractNativeLibs="true">
|
||||
|
||||
<!-- AdMob App ID — injected at build time from android/admob.properties -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="${ADMOB_APP_ID}"/>
|
||||
@@ -35,17 +40,16 @@
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
android:value="2"/>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,159 @@
|
||||
package com.paladinvpn.app
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
// Channel id only — not the application id, which is com.paladinvpn.app.
|
||||
// Must match the string in lib/logic/updater.dart.
|
||||
private val installerChannel = "com.revoltvpn.app/installer"
|
||||
|
||||
// Must match lib/logic/notification_service.dart.
|
||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||
|
||||
private var notificationChannelReady = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, installerChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "getInstallerPackage") {
|
||||
result.success(installerPackage())
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "updateNotificationText") {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||
updateVpnNotification(title, text, action)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session countdown as the VPN notification's content text.
|
||||
*
|
||||
* notify() is per-package, so re-posting flutter_vless's notification id
|
||||
* replaces it wholesale — every field the plugin set has to be rebuilt
|
||||
* here or it is dropped, the Disconnect action most of all. Keep in sync
|
||||
* with XrayCoreManager.showNotification().
|
||||
*/
|
||||
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureNotificationChannel()
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
// Same intent shape as the plugin, so a tap brings the running task
|
||||
// forward instead of starting a second one.
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
launchIntent?.action = "FROM_DISCONNECT_BTN"
|
||||
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
// Tapping "Disconnect" stops the tunnel without opening the app.
|
||||
val stopIntent = Intent(this, XrayVPNService::class.java)
|
||||
stopIntent.putExtra(
|
||||
"COMMAND",
|
||||
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
|
||||
)
|
||||
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.notification_icon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.addAction(0, actionLabel, stopPendingIntent)
|
||||
.setColorized(true)
|
||||
.setColor(0xFF0D1117.toInt())
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
if (launchIntent != null) {
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(this, 0, launchIntent, flags)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
|
||||
} catch (_: Exception) {
|
||||
// The foreground notification may not be up yet — the update is cosmetic.
|
||||
}
|
||||
}
|
||||
|
||||
/** A binder round-trip, and the caller runs once a second — so, once per process. */
|
||||
private fun ensureNotificationChannel() {
|
||||
if (notificationChannelReady) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Revolt VPN Background Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
notificationChannelReady = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Package name of whatever installed us ("com.android.vending" for Play
|
||||
* Store), or null when unknown. Without this the Dart side always falls
|
||||
* back to sideload and points every user at GitHub releases.
|
||||
*/
|
||||
private fun installerPackage(): String? = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Must match flutter_vless's XrayCoreManager.
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0d1017" />
|
||||
<solid android:color="@color/splash_bg" />
|
||||
</shape>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_bg</item>
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,18 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_bg</item>
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,18 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
Flutter draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/splash_bg</item>
|
||||
<item name="android:navigationBarColor">@color/splash_bg</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,8 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
@@ -3,3 +3,6 @@ distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
@@ -15,7 +15,7 @@ class ConnectButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ConnectButtonState extends State<ConnectButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
bool _busy = false;
|
||||
DateTime _lastTap = DateTime.now();
|
||||
AnimationController? _pulse;
|
||||
@@ -29,6 +29,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||
_pulse = pulse;
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
});
|
||||
@@ -36,6 +37,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||
_pulse?.dispose();
|
||||
super.dispose();
|
||||
@@ -51,6 +53,20 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!mounted) return;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_pulse?.stop();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
final vpn = context.read<VpnConnection>();
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -65,7 +81,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
await timer.disconnect();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +101,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final ok = await vpn.connect();
|
||||
if (ok) {
|
||||
timer.start();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
@@ -105,15 +121,36 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
child: _pulseAnim != null
|
||||
? AnimatedBuilder(
|
||||
animation: _pulseAnim!,
|
||||
builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue),
|
||||
child: _buildInner(spinning),
|
||||
builder: (_, child) =>
|
||||
_buildCircle(isConnected, _pulseValue, child),
|
||||
)
|
||||
: _buildCircle(isConnected, spinning, 0),
|
||||
: _buildCircle(isConnected, 0, _buildInner(spinning)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircle(bool isConnected, bool spinning, double pulse) {
|
||||
Widget _buildInner(bool spinning) {
|
||||
if (spinning) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: AppColors.accent70,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircle(bool isConnected, double pulse, Widget? child) {
|
||||
final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0;
|
||||
final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
|
||||
|
||||
@@ -128,21 +165,17 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
width: isConnected ? 2.5 : 1.5,
|
||||
),
|
||||
boxShadow: isConnected
|
||||
? [BoxShadow(color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3)]
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
||||
blurRadius: glowRadius,
|
||||
spreadRadius: glowRadius * 0.3,
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: spinning
|
||||
? const Center(
|
||||
child: SizedBox(
|
||||
width: 36, height: 36,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent70),
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,10 +64,11 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
const Text(
|
||||
'Revolt VPN creates a secure VPN tunnel to protect your '
|
||||
'traffic. To enforce your data/time quota, we log your '
|
||||
'connection duration and data usage, linked to your '
|
||||
'account. We do not log or monitor the content of your '
|
||||
'traffic. We do not share your data with third parties.'
|
||||
'If you have more questions, visit our website.',
|
||||
'connection duration and data usage, linked to a randomly '
|
||||
'generated device ID — not to your identity. We do not log '
|
||||
'or monitor the content of your traffic. We do not share '
|
||||
'your data with third parties. The full policy is in the '
|
||||
'sidebar menu under "Privacy Policy".',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -37,11 +37,24 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
||||
_scheduleNext();
|
||||
lightningEnabled.addListener(_onEnabledChanged);
|
||||
if (lightningEnabled.value) _scheduleNext();
|
||||
}
|
||||
|
||||
void _onEnabledChanged() {
|
||||
if (lightningEnabled.value) {
|
||||
_scheduleNext();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_ctrl.stop();
|
||||
_bolt = null;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
lightningEnabled.removeListener(_onEnabledChanged);
|
||||
_timer?.cancel();
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
@@ -54,13 +67,13 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
}
|
||||
|
||||
void _strike() {
|
||||
if (!mounted) return;
|
||||
if (!mounted || !lightningEnabled.value) return;
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
|
||||
if (_random.nextDouble() < widget.doubleChance) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) {
|
||||
if (mounted && lightningEnabled.value) {
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
}
|
||||
@@ -78,7 +91,7 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!lightningEnabled) return const SizedBox.shrink();
|
||||
if (!lightningEnabled.value) return const SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, child) {
|
||||
|
||||
+39
-23
@@ -36,6 +36,7 @@ class _RainEffectState extends State<RainEffect>
|
||||
late final Ticker _ticker;
|
||||
final _drops = <_Drop>[];
|
||||
final _random = Random();
|
||||
final _repaint = ValueNotifier<int>(0);
|
||||
Duration _last = Duration.zero;
|
||||
|
||||
// Lifecycle
|
||||
@@ -44,47 +45,63 @@ class _RainEffectState extends State<RainEffect>
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (int i = 0; i < widget.dropCount; i++) {
|
||||
_drops.add(_Drop.spawn(_random, widget.minLength, widget.maxLength));
|
||||
_drops.add(
|
||||
_Drop.spawn(_random, widget.minLength, widget.maxLength, widget.color));
|
||||
}
|
||||
_ticker = createTicker(_onTick)..start();
|
||||
_ticker = createTicker(_onTick);
|
||||
rainEnabled.addListener(_onEnabledChanged);
|
||||
if (rainEnabled.value) _ticker.start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
rainEnabled.removeListener(_onEnabledChanged);
|
||||
_ticker.dispose();
|
||||
_repaint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onEnabledChanged() {
|
||||
if (rainEnabled.value) {
|
||||
// Ticker elapsed restarts at zero; a stale _last makes the first dt negative.
|
||||
_last = Duration.zero;
|
||||
_ticker.start();
|
||||
} else {
|
||||
_ticker.stop();
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
// Tick
|
||||
|
||||
void _onTick(Duration now) {
|
||||
double dt = 0.016; // ~60 fps fallback on first tick
|
||||
if (_last != Duration.zero) {
|
||||
dt = (now - _last).inMicroseconds / 1000000.0;
|
||||
dt = dt.clamp(0.0, 0.1);
|
||||
dt = dt.clamp(0.0, 0.1);
|
||||
}
|
||||
_last = now;
|
||||
|
||||
for (final d in _drops) {
|
||||
d.y += d.speed * dt;
|
||||
if (d.y > 1.08) {
|
||||
d.spawn(_random, widget.minLength, widget.maxLength);
|
||||
d.y = -0.06 - _random.nextDouble() * 0.08;
|
||||
d.spawn(_random, widget.minLength, widget.maxLength, widget.color);
|
||||
d.y = -0.06 - _random.nextDouble() * 0.08;
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
_repaint.value++;
|
||||
}
|
||||
|
||||
// Build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!rainEnabled) return const SizedBox.shrink();
|
||||
if (!rainEnabled.value) return const SizedBox.shrink();
|
||||
return CustomPaint(
|
||||
painter: _RainPainter(
|
||||
drops: _drops,
|
||||
color: widget.color,
|
||||
windDrift: widget.windDrift,
|
||||
repaint: _repaint,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
@@ -96,37 +113,37 @@ class _RainEffectState extends State<RainEffect>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _Drop {
|
||||
double x;
|
||||
double y;
|
||||
double speed;
|
||||
double length;
|
||||
double opacity;
|
||||
double x;
|
||||
double y;
|
||||
double speed;
|
||||
double length;
|
||||
Color color;
|
||||
|
||||
_Drop({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.speed,
|
||||
required this.length,
|
||||
required this.opacity,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
/// Create a drop at a random position anywhere on screen.
|
||||
factory _Drop.spawn(Random r, double minLen, double maxLen) {
|
||||
factory _Drop.spawn(Random r, double minLen, double maxLen, Color base) {
|
||||
return _Drop(
|
||||
x: r.nextDouble(),
|
||||
y: r.nextDouble(),
|
||||
speed: 0.30 + r.nextDouble() * 0.55,
|
||||
length: minLen + r.nextDouble() * (maxLen - minLen),
|
||||
opacity: 0.05 + r.nextDouble() * 0.12,
|
||||
color: base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-randomise all properties (call when recycling a drop to the top).
|
||||
void spawn(Random r, double minLen, double maxLen) {
|
||||
void spawn(Random r, double minLen, double maxLen, Color base) {
|
||||
x = r.nextDouble();
|
||||
speed = 0.30 + r.nextDouble() * 0.55;
|
||||
length = minLen + r.nextDouble() * (maxLen - minLen);
|
||||
opacity = 0.05 + r.nextDouble() * 0.12;
|
||||
color = base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,14 +153,13 @@ class _Drop {
|
||||
|
||||
class _RainPainter extends CustomPainter {
|
||||
final List<_Drop> drops;
|
||||
final Color color;
|
||||
final double windDrift;
|
||||
|
||||
_RainPainter({
|
||||
required this.drops,
|
||||
required this.color,
|
||||
required this.windDrift,
|
||||
});
|
||||
required Listenable repaint,
|
||||
}) : super(repaint: repaint);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
@@ -157,11 +173,11 @@ class _RainPainter extends CustomPainter {
|
||||
final ex = sx - windDrift; // slight leftward slant
|
||||
final ey = sy + d.length;
|
||||
|
||||
paint.color = color.withAlpha((d.opacity * 255).round());
|
||||
paint.color = d.color;
|
||||
canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RainPainter old) => true; // always animating
|
||||
bool shouldRepaint(_RainPainter old) => false; // repaint listenable drives frames
|
||||
}
|
||||
@@ -12,9 +12,7 @@ class SettingsButton extends StatelessWidget {
|
||||
|
||||
// Opens the settings page sliding in from the right — matches the
|
||||
// sidebar end-drawer animation so it feels like one continuous motion.
|
||||
static void open(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
static PageRoute<void> _route() => PageRouteBuilder<void>(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (_, __, ___) => const SettingsScreen(),
|
||||
@@ -23,15 +21,21 @@ class SettingsButton extends StatelessWidget {
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
).animate(
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
static void openWithNavigator(NavigatorState navigator) {
|
||||
if (!navigator.mounted) return;
|
||||
navigator.push(_route());
|
||||
}
|
||||
|
||||
static void open(BuildContext context) {
|
||||
openWithNavigator(Navigator.of(context, rootNavigator: true));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
@@ -126,8 +127,9 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
Navigator.of(context).pop();
|
||||
SettingsButton.open(context);
|
||||
SettingsButton.openWithNavigator(navigator);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -135,8 +137,12 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
icon: Icons.system_update_alt,
|
||||
label: 'Check for updates',
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
Updater.check(context);
|
||||
unawaited(
|
||||
Updater.check(navigator: navigator, messenger: messenger),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ class SpeedBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
final speed = timer.currentSpeedKBps > 0.5
|
||||
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
||||
return Selector<SessionTimer, double>(
|
||||
selector: (_, timer) => timer.currentSpeedKBps,
|
||||
builder: (context, speedKBps, _) {
|
||||
final speed = speedKBps > 0.5
|
||||
? '${speedKBps.toStringAsFixed(1)} KB/s'
|
||||
: 'Idle';
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -34,9 +34,10 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||
return Selector<SessionTimer, bool>(
|
||||
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
||||
builder: (context, visible, _) {
|
||||
if (!visible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
import 'package:revoltvpn/logic/consent_manager.dart';
|
||||
|
||||
class AdManager extends ChangeNotifier {
|
||||
static const bool adsEnabled = false;
|
||||
@@ -27,8 +28,29 @@ class AdManager extends ChangeNotifier {
|
||||
if (adsEnabled) preloadAd();
|
||||
}
|
||||
|
||||
static Future<void>? _sdkInit;
|
||||
static Future<void> ensureSdkInitialized() {
|
||||
if (!adsEnabled) return Future.value();
|
||||
return _sdkInit ??= _initSdk();
|
||||
}
|
||||
|
||||
static Future<void> _initSdk() async {
|
||||
try {
|
||||
await ConsentManager.requestConsentIfNeeded()
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('[AdManager] Consent init skipped: $e');
|
||||
}
|
||||
try {
|
||||
await MobileAds.instance.initialize().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('[AdManager] MobileAds init skipped: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> preloadAd() async {
|
||||
if (!adsEnabled) return true;
|
||||
await ensureSdkInitialized();
|
||||
if (_isAdLoaded) return true;
|
||||
if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false);
|
||||
|
||||
@@ -101,6 +123,8 @@ class AdManager extends ChangeNotifier {
|
||||
|
||||
if (!adsEnabled) return false;
|
||||
|
||||
await ensureSdkInitialized();
|
||||
|
||||
if (!_isAdLoaded || _rewardedAd == null) {
|
||||
final loaded = await preloadAd();
|
||||
if (!loaded || _rewardedAd == null) {
|
||||
|
||||
@@ -8,29 +8,11 @@ abstract final class AppConfig {
|
||||
// ── Server ───────────────────────────────────────────────────────────
|
||||
/// Your server's public IP. The VPN tunnel is pinned to this.
|
||||
static const String serverIp = '0.0.0.0'; // ◄── REPLACE
|
||||
|
||||
/// API base — routed through the tunnel to Flask via Xray "api" outbound.
|
||||
static const String hivemindApiBase = 'http://10.254.254.1:5000/api';
|
||||
|
||||
// ── Bootstrap (first-connect config fetch) ────────────────────────────
|
||||
/// Hardcoded Reality config. App connects with this, fetches the real
|
||||
/// per-session VLESS URL through the tunnel, then reconnects.
|
||||
static String get bootstrapVlessUrl =>
|
||||
'vless://$bootstrapUuid@$serverIp:8443'
|
||||
'?security=reality&type=xhttp&path=/revolt'
|
||||
'&pbk=$realityPbk&sni=www.github.com&sid=$realitySid&fp=chrome'
|
||||
'#ReVoltVPN';
|
||||
|
||||
/// Generate with `xray uuid` on the server. Must match xray_config_reality.json.
|
||||
static const String bootstrapUuid = '00000000-0000-0000-0000-000000000000'; // ◄── REPLACE
|
||||
|
||||
/// Server's Reality public key (from `xray x25519`).
|
||||
static const String realityPbk = 'REPLACE_WITH_YOUR_PUBLIC_KEY';
|
||||
|
||||
/// Must be in xray_config_reality.json shortIds[].
|
||||
static const String realitySid = 'abc123';
|
||||
static const String hivemindApiPublic = 'https://YOUR_DOMAIN/api'; // ◄── REPLACE
|
||||
|
||||
// ── Reality (VPN tunnel) ──────────────────────────────────────────────
|
||||
// Credentials (public key, shortId) come from /session/status, not from
|
||||
// here. Only the transport parameters below are compiled in.
|
||||
/// The server picks a random SNI per session from REALITY_SNI_POOL.
|
||||
/// The client does NOT hardcode one — server is always the source of truth.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -18,66 +17,6 @@ class HivemindService {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> directPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.post(uri, headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': _ua,
|
||||
}, body: body).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('GET', uri, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('POST', uri, body: body, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> _proxyRequest(String method, Uri uri,
|
||||
{String? body, Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final socket = await Socket.connect('127.0.0.1', 10808, timeout: timeout);
|
||||
try {
|
||||
final target = uri.toString();
|
||||
final headers = <String, String>{
|
||||
'Host': '${uri.host}:${uri.port}',
|
||||
'User-Agent': _ua,
|
||||
'Accept': '*/*',
|
||||
'Connection': 'close',
|
||||
};
|
||||
if (body != null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = body.length.toString();
|
||||
}
|
||||
|
||||
final reqBuf = StringBuffer();
|
||||
reqBuf.write('$method $target HTTP/1.1\r\n');
|
||||
headers.forEach((k, v) => reqBuf.write('$k: $v\r\n'));
|
||||
reqBuf.write('\r\n');
|
||||
if (body != null) reqBuf.write(body);
|
||||
|
||||
socket.write(reqBuf.toString());
|
||||
await socket.flush();
|
||||
|
||||
final response = await socket.fold<String>(
|
||||
'',
|
||||
(prev, chunk) => prev + String.fromCharCodes(chunk),
|
||||
).timeout(timeout);
|
||||
|
||||
final parts = response.split('\r\n\r\n');
|
||||
if (parts.length < 2) throw Exception('Invalid proxy response');
|
||||
final headerBlock = parts[0];
|
||||
final respBody = parts.sublist(1).join('\r\n\r\n');
|
||||
|
||||
final headerLines = headerBlock.split('\r\n');
|
||||
final statusParts = headerLines[0].split(' ');
|
||||
final statusCode = int.parse(statusParts[1]);
|
||||
|
||||
return http.Response(respBody, statusCode);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
}
|
||||
@@ -166,28 +105,8 @@ class HivemindService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> verifySession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = _tunnelUrl('/session/status?device_id=$deviceId');
|
||||
final response = await proxyGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['active'] == true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Idle sessions are culled server-side by the management loop
|
||||
// after 2 minutes of no /status polls (user disconnected).
|
||||
static void disconnectAndCleanup() {}
|
||||
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
|
||||
static Uri _tunnelUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiBase}$path');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
// ── lib/logic/notification_service.dart ─────────────────────────────────────
|
||||
// Bridge to the native VPN notification, which flutter_vless owns. This only
|
||||
// refreshes its content text with the session countdown, so a failure here is
|
||||
// never worth propagating to the session timer.
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.revoltvpn.app/notification');
|
||||
|
||||
static String? _lastText;
|
||||
|
||||
/// Sets the notification's content text to [text].
|
||||
///
|
||||
/// The native side rebuilds the whole notification, so skip the round-trip
|
||||
/// when the text has not changed.
|
||||
static Future<void> updateTimer(String text) async {
|
||||
if (text == _lastText) return;
|
||||
_lastText = text;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('updateNotificationText', {
|
||||
'title': 'Revolt VPN',
|
||||
'text': text,
|
||||
'disconnectLabel': 'Disconnect',
|
||||
});
|
||||
} catch (_) {
|
||||
// Cosmetic.
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces the next connect to re-post even if the countdown resumes on the
|
||||
/// same value.
|
||||
static void reset() => _lastText = null;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
// Server data model and ChangeNotifier — shared by StatusBar (shows current
|
||||
// server name/flag) and SelectionScreen (lets user pick a server).
|
||||
//
|
||||
// Each server is defined by its tunnel IP/port. Reality keys (public key,
|
||||
// shortId, fingerprint) come from AppConfig — all servers share the same
|
||||
// Reality keypair and bootstrap UUID (same operator, different locations).
|
||||
// Each server is defined by its tunnel IP/port. Reality credentials (public
|
||||
// key, shortId) come from /session/status per session; only the transport
|
||||
// parameters live in AppConfig.
|
||||
//
|
||||
// When multi-server ships:
|
||||
// - _all becomes a fetched list (from /api/servers or bundled config)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
import 'package:revoltvpn/logic/notification_service.dart';
|
||||
|
||||
class SessionTimer extends ChangeNotifier {
|
||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
int _tickCount = 0;
|
||||
bool _appBackgrounded = false;
|
||||
|
||||
final VpnConnection vpnConnection;
|
||||
|
||||
@@ -25,11 +27,14 @@ class SessionTimer extends ChangeNotifier {
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
static const int _pollIntervalSeconds = 5;
|
||||
static const int _backgroundPollIntervalSeconds = 30;
|
||||
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
DateTime? _lastSyncAt;
|
||||
|
||||
SessionTimer({required this.vpnConnection}) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||
}
|
||||
|
||||
@@ -77,11 +82,14 @@ class SessionTimer extends ChangeNotifier {
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_lastSyncAt = null;
|
||||
NotificationService.reset();
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
@@ -92,10 +100,9 @@ class SessionTimer extends ChangeNotifier {
|
||||
|
||||
void _tick(Timer t) {
|
||||
if (_isDisconnecting) return;
|
||||
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
||||
if (_remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
// A local clock: poll failures are about the network, not the clock.
|
||||
if (_hasSyncedOnce && _remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
@@ -112,11 +119,22 @@ class SessionTimer extends ChangeNotifier {
|
||||
}
|
||||
|
||||
_tickCount++;
|
||||
if (_tickCount % _pollIntervalSeconds == 0) {
|
||||
final pollInterval = _appBackgrounded
|
||||
? _backgroundPollIntervalSeconds
|
||||
: _pollIntervalSeconds;
|
||||
if (_tickCount % pollInterval == 0) {
|
||||
_syncWithHivemind();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Gated on a live tunnel. The plugin cancels the notification when the
|
||||
// service stops, and re-posting after that leaves an ongoing notification
|
||||
// the user cannot swipe away below Android 14.
|
||||
if (_hasSyncedOnce &&
|
||||
vpnConnection.status == VpnStatus.connected) {
|
||||
NotificationService.updateTimer(formatted);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||
@@ -133,6 +151,8 @@ class SessionTimer extends ChangeNotifier {
|
||||
_currentSpeedKBps = 0.0;
|
||||
_remainingSeconds = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_lastSyncAt = null;
|
||||
NotificationService.reset();
|
||||
notifyListeners();
|
||||
|
||||
await vpnConnection.disconnect();
|
||||
@@ -163,9 +183,17 @@ class SessionTimer extends ChangeNotifier {
|
||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||
|
||||
// Measure the real gap between polls: the interval is 5 s in the
|
||||
// foreground, 30 s in the background, and arbitrary on a resume sync.
|
||||
final now = DateTime.now();
|
||||
final lastSync = _lastSyncAt;
|
||||
_lastSyncAt = now;
|
||||
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && deltaBytes > 0) {
|
||||
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
|
||||
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
|
||||
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
|
||||
_currentSpeedKBps =
|
||||
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
|
||||
} else if (!_hasSyncedOnce) {
|
||||
_currentSpeedKBps = 0.0;
|
||||
}
|
||||
@@ -200,6 +228,7 @@ class SessionTimer extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_lastSyncAt = null;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
@@ -207,8 +236,23 @@ class SessionTimer extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
// Keep ticking, but slow the poll — the 5 s one wakes the radio.
|
||||
_appBackgrounded = true;
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_appBackgrounded = false;
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
_syncWithHivemind();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
vpnConnection.removeListener(_onVpnConnectionChanged);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
|
||||
+29
-9
@@ -61,8 +61,24 @@ class GitHubUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isTrustedReleaseUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
||||
return false;
|
||||
}
|
||||
// Host alone is not enough: anyone can host a release on github.com.
|
||||
return uri.path
|
||||
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
|
||||
}
|
||||
|
||||
static Future<bool> open() async {
|
||||
final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
||||
final cached = _cachedDownloadUrl;
|
||||
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
||||
? cached
|
||||
: AppConfig.githubReleasesUrl;
|
||||
if (cached != null && url != cached) {
|
||||
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
@@ -115,32 +131,35 @@ class Updater {
|
||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||
}
|
||||
|
||||
static Future<void> check(BuildContext context) async {
|
||||
static Future<void> check({
|
||||
required NavigatorState navigator,
|
||||
required ScaffoldMessengerState messenger,
|
||||
}) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersion = info.version;
|
||||
if (localVersion.isEmpty) {
|
||||
_showSnackBar(context, 'Could not determine app version',
|
||||
_showSnackBar(messenger, 'Could not determine app version',
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final latestVersion = await GitHubUpdater.fetchLatestVersion();
|
||||
if (latestVersion == null) {
|
||||
_showSnackBar(context, 'Could not reach GitHub', isError: true);
|
||||
_showSnackBar(messenger, 'Could not reach GitHub', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_compareVersions(localVersion, latestVersion) >= 0) {
|
||||
_showSnackBar(context, 'Up to date');
|
||||
_showSnackBar(messenger, 'Up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
final source = await installSource;
|
||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
if (!context.mounted) return;
|
||||
if (!navigator.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.bgCard,
|
||||
title: const Text('Update available',
|
||||
@@ -171,9 +190,10 @@ class Updater {
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSnackBar(BuildContext context, String message,
|
||||
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
||||
{bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
if (!messenger.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_vless/flutter_vless.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
|
||||
@@ -12,7 +12,7 @@ enum VpnStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool _cancelled = false;
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
@@ -33,12 +33,25 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
late final FlutterVless _vless;
|
||||
bool _initialized = false;
|
||||
final Completer<void> _readyCompleter = Completer<void>();
|
||||
Future<void> get ready => _readyCompleter.future;
|
||||
|
||||
VpnConnection() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
await _startEngine();
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Engine init failed: $e');
|
||||
} finally {
|
||||
if (!_readyCompleter.isCompleted) _readyCompleter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startEngine() async {
|
||||
if (kIsWeb) return;
|
||||
|
||||
_vless = FlutterVless(
|
||||
@@ -60,9 +73,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||
}
|
||||
|
||||
_checkHealth();
|
||||
_healthTimer =
|
||||
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
|
||||
_startHealthPolling();
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
@@ -177,22 +188,10 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
final parsed = FlutterVless.parse(realUrl);
|
||||
final configJson =
|
||||
jsonDecode(parsed.getFullConfiguration()) as Map<String, dynamic>;
|
||||
|
||||
// Inject HTTP inbound for tunnel-internal API calls.
|
||||
final inbounds = (configJson['inbounds'] as List?) ?? [];
|
||||
inbounds.add({
|
||||
'tag': 'http-proxy',
|
||||
'port': 10808,
|
||||
'listen': '127.0.0.1',
|
||||
'protocol': 'http',
|
||||
});
|
||||
configJson['inbounds'] = inbounds;
|
||||
|
||||
await _vless.startVless(
|
||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
||||
config: jsonEncode(configJson),
|
||||
config: parsed.getFullConfiguration(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
@@ -252,13 +251,37 @@ class VpnConnection extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startHealthPolling() {
|
||||
_healthTimer?.cancel();
|
||||
_checkHealthIfIdle();
|
||||
_healthTimer = Timer.periodic(
|
||||
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
|
||||
}
|
||||
|
||||
Future<void> _checkHealthIfIdle() async {
|
||||
// A live tunnel already proves reachability.
|
||||
if (_status == VpnStatus.connected) return;
|
||||
await _checkHealth();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
_serverReachable = await HivemindService.checkHealth();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_healthTimer?.cancel();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_startHealthPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_healthTimer?.cancel();
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
try {
|
||||
|
||||
+8
-17
@@ -2,18 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/logic/consent_manager.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/screens/intro.dart';
|
||||
|
||||
void main() async {
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
@@ -34,33 +30,28 @@ void main() async {
|
||||
return true;
|
||||
};
|
||||
|
||||
await ConsentManager.requestConsentIfNeeded();
|
||||
await MobileAds.instance.initialize();
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
ReVoltApp.appVersion = packageInfo.version;
|
||||
|
||||
runApp(const ReVoltApp());
|
||||
}
|
||||
|
||||
class ReVoltApp extends StatelessWidget {
|
||||
const ReVoltApp({super.key});
|
||||
|
||||
/// Set at startup from PackageInfo — used by sidebar and anywhere
|
||||
/// the app version needs to be displayed.
|
||||
static String appVersion = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => VpnConnection()),
|
||||
// lazy: false — the native VLESS engine takes real time to start, and
|
||||
// providers are lazy by default, so it used to begin initialising only
|
||||
// once MainScreen first read it, i.e. *after* the loading screen was
|
||||
// gone. Constructing it eagerly moves that work under the intro, which
|
||||
// now awaits VpnConnection.ready before navigating.
|
||||
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => AdManager()),
|
||||
ChangeNotifierProvider(create: (_) => ServerList()..init()),
|
||||
ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'ReVolt VPN',
|
||||
|
||||
+69
-17
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/main_screen.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key});
|
||||
@@ -11,11 +16,18 @@ class IntroScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
static const _minDisplay = Duration(milliseconds: 500);
|
||||
static const _bootTimeout = Duration(seconds: 8);
|
||||
|
||||
String _status = 'Starting secure engine…';
|
||||
bool _bootComplete = false;
|
||||
bool _navigated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scheduleNavigation();
|
||||
_boot();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -26,28 +38,54 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_navigateToMain();
|
||||
if (state == AppLifecycleState.resumed) _navigateIfReady();
|
||||
}
|
||||
|
||||
Future<void> _boot() async {
|
||||
final elapsed = Stopwatch()..start();
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
// The overlays read these in initState, so they must land before
|
||||
// MainScreen mounts.
|
||||
final settingsPrefs = Future.wait(
|
||||
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
||||
|
||||
try {
|
||||
await vpn.ready.timeout(_bootTimeout);
|
||||
} on TimeoutException {
|
||||
debugPrint('[Boot] Engine init exceeded ${_bootTimeout.inSeconds}s '
|
||||
'— continuing to the main screen anyway.');
|
||||
} catch (e) {
|
||||
debugPrint('[Boot] Engine init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleNavigation() {
|
||||
// Brief natural pause — just long enough to perceive the logo.
|
||||
Timer(const Duration(milliseconds: 1200), () {
|
||||
if (!mounted) return;
|
||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||
// App is backgrounded — defer navigation until resumed.
|
||||
return;
|
||||
}
|
||||
_navigateToMain();
|
||||
});
|
||||
}
|
||||
try {
|
||||
await settingsPrefs;
|
||||
} catch (e) {
|
||||
debugPrint('[Boot] Settings prefs load failed, using defaults: $e');
|
||||
}
|
||||
|
||||
void _navigateToMain() {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'Ready');
|
||||
|
||||
final remaining = _minDisplay - elapsed.elapsed;
|
||||
if (remaining > Duration.zero) await Future.delayed(remaining);
|
||||
|
||||
if (!mounted) return;
|
||||
_bootComplete = true;
|
||||
_navigateIfReady();
|
||||
}
|
||||
|
||||
void _navigateIfReady() {
|
||||
if (!mounted || _navigated || !_bootComplete) return;
|
||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||
return; // resume will call back in
|
||||
}
|
||||
_navigated = true;
|
||||
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 600),
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
pageBuilder: (_, __, ___) => const MainScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
@@ -98,6 +136,20 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
letterSpacing: 2.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Says what the spinner is actually waiting on.
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Text(
|
||||
_status,
|
||||
key: ValueKey(_status),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textDim,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 5),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
|
||||
// so nothing has to react to it changing. Default: false.
|
||||
bool hapticEnabled = false;
|
||||
|
||||
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
||||
/// only mounts once Settings is opened.
|
||||
Future<void> loadHapticPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
}
|
||||
|
||||
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
||||
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
||||
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
||||
class Haptics {
|
||||
Haptics._();
|
||||
|
||||
static Future<void> connectionChanged() async {
|
||||
if (!hapticEnabled) return;
|
||||
try {
|
||||
await HapticFeedback.mediumImpact();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Feedback unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> previewEnabled() async {
|
||||
try {
|
||||
await HapticFeedback.selectionClick();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Preview unavailable: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HapticFeedbackTile extends StatefulWidget {
|
||||
const HapticFeedbackTile({super.key});
|
||||
|
||||
@@ -20,12 +55,9 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
_load();
|
||||
}
|
||||
|
||||
// Loads persisted state and syncs the top-level bool.
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
hapticEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadHapticPref();
|
||||
if (mounted) setState(() => _on = hapticEnabled);
|
||||
}
|
||||
|
||||
// Writes to SharedPreferences AND updates the live bool so the connect
|
||||
@@ -34,6 +66,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('haptic_feedback_enabled', value);
|
||||
hapticEnabled = value;
|
||||
if (value) unawaited(Haptics.previewEnabled());
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// ── lib/screens/settings/in_settings/lightning.dart ─────────────────────────
|
||||
// Toggle for the lightning strike overlay on the main screen.
|
||||
//
|
||||
// Same architecture as haptic.dart: exports a top-level bool read synchronously
|
||||
// by lib/components/lightning.dart. Default: true (lightning is on — opt-out).
|
||||
|
||||
// lib/components/lightning.dart reads this in its build method.
|
||||
bool lightningEnabled = true;
|
||||
final ValueNotifier<bool> lightningEnabled = ValueNotifier(true);
|
||||
|
||||
Future<void> loadLightningPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
lightningEnabled.value = prefs.getBool('lightning_effect_enabled') ?? true;
|
||||
}
|
||||
|
||||
class LightningToggleTile extends StatefulWidget {
|
||||
const LightningToggleTile({super.key});
|
||||
@@ -28,16 +29,14 @@ class _LightningToggleTileState extends State<LightningToggleTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('lightning_effect_enabled') ?? true;
|
||||
lightningEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadLightningPref();
|
||||
if (mounted) setState(() => _on = lightningEnabled.value);
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('lightning_effect_enabled', value);
|
||||
lightningEnabled = value;
|
||||
lightningEnabled.value = value;
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// ── lib/screens/settings/in_settings/rain.dart ──────────────────────────────
|
||||
// Toggle for the animated rain overlay on the main screen.
|
||||
//
|
||||
// Same architecture as haptic.dart: exports a top-level bool read synchronously
|
||||
// by lib/components/rain.dart. Default: true (rain is on — opt-out).
|
||||
|
||||
// lib/components/rain.dart reads this in its build method.
|
||||
bool rainEnabled = true;
|
||||
final ValueNotifier<bool> rainEnabled = ValueNotifier(true);
|
||||
|
||||
Future<void> loadRainPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
rainEnabled.value = prefs.getBool('rain_effect_enabled') ?? true;
|
||||
}
|
||||
|
||||
class RainToggleTile extends StatefulWidget {
|
||||
const RainToggleTile({super.key});
|
||||
@@ -28,16 +29,14 @@ class _RainToggleTileState extends State<RainToggleTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getBool('rain_effect_enabled') ?? true;
|
||||
rainEnabled = value;
|
||||
if (mounted) setState(() => _on = value);
|
||||
await loadRainPref();
|
||||
if (mounted) setState(() => _on = rainEnabled.value);
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('rain_effect_enabled', value);
|
||||
rainEnabled = value;
|
||||
rainEnabled.value = value;
|
||||
setState(() => _on = value);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 3.2.1+24
|
||||
version: 3.3.0+26
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
Reference in new issue
Block a user