Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8617835b1 | ||
|
|
550402a663 | ||
|
|
52e8cd8ca1 | ||
|
|
437d998663 | ||
|
|
e67f0b93ee | ||
|
|
00b84dde61 | ||
|
|
296b087db6 | ||
|
|
eab43b0420 | ||
|
|
e7ef251882 | ||
|
|
4e5c7a02db | ||
|
|
30d96a4049 | ||
|
|
66789d49c1 | ||
|
|
8ba3b6c543 | ||
|
|
d1ea13b01a | ||
|
|
34722c751b | ||
|
|
dd1082c8de | ||
|
|
742f37683e | ||
|
|
37753c6396 |
No files matched your search
@@ -5,6 +5,60 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.3.7] — 2026-10-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A warning before a session ends.** The VPN service schedules it off the same
|
||||||
|
deadline it already enforces, so it still arrives when the app is closed, and
|
||||||
|
a support top-up moves it with the deadline. It does not promise an exact
|
||||||
|
number of minutes: on a sleeping phone Android may hold it back, and the app
|
||||||
|
would rather say "soon" than say "5 minutes" with one left.
|
||||||
|
- **"Disconnected — not protected" notification** when a session ends without
|
||||||
|
you asking: the 2-hour expiry, the data limit, the server ending the
|
||||||
|
session, or the tunnel stopping unexpectedly.
|
||||||
|
- **SOCKS5 mode now asks for the notification permission.** It never did,
|
||||||
|
because it never raises the VPN consent dialog, so on Android 13+ these
|
||||||
|
alerts would have been dropped before they were shown.
|
||||||
|
- **The app now shows what went wrong.** Connect errors, the reason a session
|
||||||
|
ended, and progress while connecting appear under the status bar instead of
|
||||||
|
the button silently going back to idle.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A control-plane outage no longer tears down a working tunnel.** The app used
|
||||||
|
to disconnect itself after about two minutes of failed status polls, putting
|
||||||
|
you back on your real IP; it now keeps the tunnel up and shows
|
||||||
|
"Can't reach the server — still connected". Expiry is still enforced by the
|
||||||
|
VPN service's own deadline and by the server.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's
|
||||||
|
server-side-verification nonce and the nonce the connect flow waited for were
|
||||||
|
minted separately, so the client rejected the very session the ad created and
|
||||||
|
failed with "Session not activated". The ad's nonce is now passed through to
|
||||||
|
the connect call, the fake bypass callback is skipped when a real ad was
|
||||||
|
watched, and the client polls longer (~36 s) for Google's callback.
|
||||||
|
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
||||||
|
requesting a new one, so repairing a lost proxy no longer needs (or grants)
|
||||||
|
a fresh session.
|
||||||
|
- **The end-of-session warning now actually arrives when the app is closed.**
|
||||||
|
It was scheduled only on an inexact alarm, and a sleeping phone could hold
|
||||||
|
that alarm back past the end of the session it was warning about — so in the
|
||||||
|
one case the warning exists for, it never came. It now also runs off the VPN
|
||||||
|
service's own timer, the same one that already ends the session on time.
|
||||||
|
- **Disconnecting from Android's VPN settings no longer leaves a countdown in
|
||||||
|
the notification shade.** It used to sit there frozen, showing time remaining
|
||||||
|
on a tunnel that was already gone.
|
||||||
|
- **The VPN reconnects itself after the tunnel core stops.** Recovery never
|
||||||
|
worked: every attempt failed on the same internal error, and each one left
|
||||||
|
another copy of the core running in the background. One restart now brings it
|
||||||
|
back without dropping the connection.
|
||||||
|
- **Disconnecting now releases your session on the server** instead of leaving
|
||||||
|
it open until it expires on its own.
|
||||||
|
- **A core that fails to start now says why.** Its output was being thrown
|
||||||
|
away, so the only trace was an exit code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.3.6] — 2026-10-08
|
## [3.3.6] — 2026-10-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import android.os.VibratorManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
@@ -61,14 +62,32 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "updateNotificationText") {
|
when (call.method) {
|
||||||
val text = call.argument<String>("text") ?: ""
|
"updateNotificationText" -> {
|
||||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
val text = call.argument<String>("text") ?: ""
|
||||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||||
updateVpnNotification(title, text, action)
|
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||||
result.success(null)
|
updateVpnNotification(title, text, action)
|
||||||
} else {
|
result.success(null)
|
||||||
result.notImplemented()
|
}
|
||||||
|
// Session ends only Dart sees; the service posts the rest.
|
||||||
|
"showDisconnectedAlert" -> {
|
||||||
|
RevoltAlerts.postDisconnected(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// The countdown re-post is ongoing and non-dismissible, so
|
||||||
|
// whoever posted it last has to take it down. A tick that
|
||||||
|
// lands after the service cancelled its own foreground
|
||||||
|
// notification otherwise strands a frozen countdown that
|
||||||
|
// claims a protected session forever. Harmless while the
|
||||||
|
// service is still in the foreground - the platform keeps
|
||||||
|
// its notification regardless.
|
||||||
|
"cancelNotification" -> {
|
||||||
|
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
|
.cancel(NOTIFICATION_ID)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ class _ConnectButtonState extends State<ConnectButton>
|
|||||||
|
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
final adWatched = await ad.showAd('main');
|
final adNonce = await ad.showAd('main');
|
||||||
if (!adWatched && AdManager.adsEnabled) return;
|
if (adNonce == null && AdManager.adsEnabled) return;
|
||||||
|
|
||||||
final ok = await vpn.connect();
|
final ok = await vpn.connect(adNonce: adNonce);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await timer.start();
|
await timer.start();
|
||||||
HapticSettings.success();
|
HapticSettings.success();
|
||||||
|
|||||||
@@ -5,6 +5,41 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
import 'package:revoltvpn/logic/session_timer.dart';
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
|
// The one line worth showing right now, and whether it is a problem.
|
||||||
|
class _StatusDetail {
|
||||||
|
const _StatusDetail(this.text, {this.isProblem = false});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final bool isProblem;
|
||||||
|
}
|
||||||
|
|
||||||
|
_StatusDetail? _detail(VpnConnection vpn, SessionTimer timer) {
|
||||||
|
final error = vpn.errorMessage;
|
||||||
|
if (error != null && error.isNotEmpty) {
|
||||||
|
return _StatusDetail(error.replaceAll('\n', ' '), isProblem: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vpn.status == VpnStatus.connecting ||
|
||||||
|
vpn.status == VpnStatus.disconnecting) {
|
||||||
|
return _StatusDetail(vpn.statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control plane only - the tunnel still carries traffic.
|
||||||
|
if (vpn.status == VpnStatus.connected && timer.serverUnreachable) {
|
||||||
|
return const _StatusDetail(
|
||||||
|
'Can\'t reach the server — still connected',
|
||||||
|
isProblem: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vpn.status != VpnStatus.connected) {
|
||||||
|
final ended = timer.lastEndMessage;
|
||||||
|
if (ended != null) return _StatusDetail(ended, isProblem: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
class StatusBar extends StatelessWidget {
|
class StatusBar extends StatelessWidget {
|
||||||
const StatusBar({super.key});
|
const StatusBar({super.key});
|
||||||
|
|
||||||
@@ -24,6 +59,8 @@ class StatusBar extends StatelessWidget {
|
|||||||
? 'Connecting…'
|
? 'Connecting…'
|
||||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||||
|
|
||||||
|
final detail = _detail(vpn, timer);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -96,6 +133,21 @@ class StatusBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Errors and session-end reasons used to render nowhere.
|
||||||
|
if (detail != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
detail.text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: detail.isProblem
|
||||||
|
? AppColors.red
|
||||||
|
: AppColors.textDim,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class _SupportButtonState extends State<SupportButton> {
|
|||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final rewarded = await ad.showAd('support');
|
final rewarded = await ad.showAd('support') != null;
|
||||||
if (rewarded) {
|
if (rewarded) {
|
||||||
await timer.markSupportRewardClaimed();
|
await timer.markSupportRewardClaimed();
|
||||||
await timer.syncNow();
|
await timer.syncNow();
|
||||||
|
|||||||
+14
-17
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||||
@@ -97,13 +96,16 @@ class AdManager extends ChangeNotifier {
|
|||||||
|
|
||||||
// ── Show ad (or debug bypass) ─────────────────────────────────────
|
// ── Show ad (or debug bypass) ─────────────────────────────────────
|
||||||
|
|
||||||
Future<bool> showAd(String adType) async {
|
// Returns the nonce put in the SSV custom_data, null if unrewarded. connect()
|
||||||
// Debug bypass: fire fake AdMob callback so support ads work in dev.
|
// must reuse it: minting a second nonce there rejects the ad's own session.
|
||||||
// The server's ADMOB_BYPASS must be True for this to succeed.
|
Future<String?> showAd(String adType) async {
|
||||||
if (!adsEnabled && kDebugMode) {
|
if (!adsEnabled) {
|
||||||
|
// Main connect fires its own bypass in fetchConfigDirectly (rule 87).
|
||||||
|
if (adType != 'support' || !kDebugMode) return null;
|
||||||
|
|
||||||
|
// Needs ADMOB_BYPASS=True server-side.
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
final nonce = HivemindService.newNonce();
|
||||||
HivemindService.setExpectedNonce(nonce);
|
|
||||||
try {
|
try {
|
||||||
final customData = jsonEncode({
|
final customData = jsonEncode({
|
||||||
'device_id': deviceId,
|
'device_id': deviceId,
|
||||||
@@ -115,29 +117,24 @@ class AdManager extends ChangeNotifier {
|
|||||||
'?signature=test&key_id=test'
|
'?signature=test&key_id=test'
|
||||||
'&custom_data=${Uri.encodeComponent(customData)}');
|
'&custom_data=${Uri.encodeComponent(customData)}');
|
||||||
await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||||
return true;
|
return nonce;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adsEnabled) return false;
|
|
||||||
|
|
||||||
await ensureSdkInitialized();
|
await ensureSdkInitialized();
|
||||||
|
|
||||||
if (!_isAdLoaded || _rewardedAd == null) {
|
if (!_isAdLoaded || _rewardedAd == null) {
|
||||||
final loaded = await preloadAd();
|
final loaded = await preloadAd();
|
||||||
if (!loaded || _rewardedAd == null) {
|
if (!loaded || _rewardedAd == null) {
|
||||||
debugPrint('[AdManager] Cannot show ad, failed to load.');
|
debugPrint('[AdManager] Cannot show ad, failed to load.');
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
|
final nonce = HivemindService.newNonce();
|
||||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
HivemindService.setExpectedNonce(nonce);
|
|
||||||
debugPrint('[AdManager] Ad nonce: $nonce');
|
|
||||||
|
|
||||||
final ssvOptions = ServerSideVerificationOptions(
|
final ssvOptions = ServerSideVerificationOptions(
|
||||||
customData: jsonEncode({
|
customData: jsonEncode({
|
||||||
@@ -182,7 +179,7 @@ class AdManager extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return rewardCompleter.future;
|
return await rewardCompleter.future ? nonce : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -76,18 +76,84 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<http.Response> controlPost(
|
||||||
|
Uri uri,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
Duration timeout = const Duration(seconds: 5),
|
||||||
|
}) async {
|
||||||
|
final client = http.Client();
|
||||||
|
final request = http.Request('POST', uri)
|
||||||
|
..headers['User-Agent'] = _ua
|
||||||
|
..headers['Content-Type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body)
|
||||||
|
..followRedirects = false;
|
||||||
|
|
||||||
|
Future<http.Response> send() async {
|
||||||
|
final streamed = await client.send(request);
|
||||||
|
final declaredLength = streamed.contentLength;
|
||||||
|
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||||
|
throw const FormatException('Control response is too large.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = <int>[];
|
||||||
|
await for (final chunk in streamed.stream) {
|
||||||
|
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||||
|
throw const FormatException('Control response is too large.');
|
||||||
|
}
|
||||||
|
bytes.addAll(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.Response.bytes(bytes, streamed.statusCode,
|
||||||
|
headers: streamed.headers, request: request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await send().timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {
|
||||||
|
client.close();
|
||||||
|
throw TimeoutException('Control request timed out.', timeout);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hands the session back so the server can drop the VLESS client instead of
|
||||||
|
// leaving it provisioned until the window runs out.
|
||||||
|
//
|
||||||
|
// Only the user-initiated disconnect calls this. An expiry, a hard cap or a
|
||||||
|
// server-ended session is already gone server-side, and /session/stop takes
|
||||||
|
// nothing but a device_id as proof (hivemind/api.py notes this), so a
|
||||||
|
// transient client error must never be allowed to destroy a live session.
|
||||||
|
// Best effort throughout: the session expires on its own regardless.
|
||||||
|
static Future<void> releaseSession() async {
|
||||||
|
try {
|
||||||
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
|
await controlPost(
|
||||||
|
_publicUrl('/session/stop'),
|
||||||
|
{'device_id': deviceId},
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Nothing to recover: the server expires the session anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void cancel() {
|
static void cancel() {
|
||||||
_currentCallId++;
|
_currentCallId++;
|
||||||
_expectedNonce = null;
|
_expectedNonce = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setExpectedNonce(String nonce) {
|
// Fetches this device's VLESS URL.
|
||||||
_expectedNonce = nonce;
|
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
|
||||||
}
|
// resume — re-attach to the session already held; any nonce accepted
|
||||||
|
// neither — ads off: mint a nonce and fire the bypass (rule 87)
|
||||||
static Future<String> fetchConfigDirectly({
|
static Future<String> fetchConfigDirectly({
|
||||||
void Function(int attempt, int total)? onAttempt,
|
void Function(int attempt, int total)? onAttempt,
|
||||||
bool skipAdBypass = false,
|
String? adNonce,
|
||||||
|
bool resume = false,
|
||||||
}) async {
|
}) async {
|
||||||
// Capture the generation before the first await. Otherwise a disconnect
|
// Capture the generation before the first await. Otherwise a disconnect
|
||||||
// that lands while secure storage is resolving the device ID can be lost
|
// that lands while secure storage is resolving the device ID can be lost
|
||||||
@@ -96,15 +162,15 @@ class HivemindService {
|
|||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
|
|
||||||
final nonce = _newNonce();
|
final nonce = resume ? null : (adNonce ?? newNonce());
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
|
|
||||||
if (!skipAdBypass) {
|
if (!resume && adNonce == null) {
|
||||||
await _runConfiguredBypass(deviceId, nonce);
|
await _runConfiguredBypass(deviceId, nonce!);
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = 5;
|
final maxAttempts = adNonce != null ? 10 : 5;
|
||||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
onAttempt?.call(attempt, maxAttempts);
|
onAttempt?.call(attempt, maxAttempts);
|
||||||
@@ -122,7 +188,9 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
await Future.delayed(_pollBackoff[attempt - 1]);
|
await Future.delayed(
|
||||||
|
_pollBackoff[min(attempt - 1, _pollBackoff.length - 1)],
|
||||||
|
);
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +210,7 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _newNonce() {
|
static String newNonce() {
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
final high = random.nextInt(0x7FFFFFFF);
|
final high = random.nextInt(0x7FFFFFFF);
|
||||||
final low = random.nextInt(0x7FFFFFFF);
|
final low = random.nextInt(0x7FFFFFFF);
|
||||||
@@ -161,9 +229,10 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonce == null accepts whatever nonce the server holds (resume path).
|
||||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||||
String deviceId,
|
String deviceId,
|
||||||
String nonce,
|
String? nonce,
|
||||||
) async {
|
) async {
|
||||||
final statusUri = _publicUrl('/session/status').replace(
|
final statusUri = _publicUrl('/session/status').replace(
|
||||||
queryParameters: {'device_id': deviceId},
|
queryParameters: {'device_id': deviceId},
|
||||||
@@ -175,9 +244,8 @@ class HivemindService {
|
|||||||
if (decoded is! Map<String, dynamic>) return null;
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
final serverNonce = decoded['nonce'];
|
final serverNonce = decoded['nonce'];
|
||||||
if (_expectedNonce != nonce ||
|
if (_expectedNonce != nonce) return null;
|
||||||
serverNonce is! String ||
|
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
|
||||||
serverNonce != nonce) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same id as the service's own alert, so a double post replaces.
|
||||||
|
static Future<void> showDisconnectedAlert() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('showDisconnectedAlert');
|
||||||
|
} catch (_) {
|
||||||
|
// Informational only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes down the countdown notification.
|
||||||
|
///
|
||||||
|
/// [updateTimer] re-posts an ongoing, non-dismissible notification, so a tick
|
||||||
|
/// that lands after the VPN service has cancelled its own foreground copy
|
||||||
|
/// leaves a frozen countdown on screen that nobody owns. Call this once the
|
||||||
|
/// tunnel is actually down.
|
||||||
|
static Future<void> cancelTimer() async {
|
||||||
|
_lastText = null;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('cancelNotification');
|
||||||
|
} catch (_) {
|
||||||
|
// Cosmetic.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Forces the next connect to re-post even if the countdown resumes on the
|
/// Forces the next connect to re-post even if the countdown resumes on the
|
||||||
/// same value.
|
/// same value.
|
||||||
static void reset() => _lastText = null;
|
static void reset() => _lastText = null;
|
||||||
|
|||||||
@@ -36,14 +36,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
static const int _maxConsecutiveFailures = 3;
|
static const int _maxConsecutiveFailures = 3;
|
||||||
static const int _maxOfflineSeconds = 120;
|
|
||||||
int _offlineSeconds = 0;
|
|
||||||
|
|
||||||
static const int _pollIntervalSeconds = 60;
|
static const int _pollIntervalSeconds = 60;
|
||||||
static const int _backgroundPollIntervalSeconds = 60;
|
static const int _backgroundPollIntervalSeconds = 60;
|
||||||
|
|
||||||
int _lastUsedBytes = 0;
|
int _lastUsedBytes = 0;
|
||||||
Stopwatch? _sinceLastSuccessfulSync;
|
Stopwatch? _sinceLastSuccessfulSync;
|
||||||
|
// Wall clock, because the Stopwatch is CLOCK_MONOTONIC and stops across
|
||||||
|
// device suspend - exactly when the RTC_WAKEUP expiry alarm fires.
|
||||||
|
DateTime? _deadlineAt;
|
||||||
double _currentSpeedKBps = 0.0;
|
double _currentSpeedKBps = 0.0;
|
||||||
|
|
||||||
SessionTimer({required this.vpnConnection}) {
|
SessionTimer({required this.vpnConnection}) {
|
||||||
@@ -67,6 +68,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
int get usedBytes => _usedBytes;
|
int get usedBytes => _usedBytes;
|
||||||
double get currentSpeedKBps => _currentSpeedKBps;
|
double get currentSpeedKBps => _currentSpeedKBps;
|
||||||
|
|
||||||
|
// Display state only - an unreachable API never tears down a live tunnel.
|
||||||
|
bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures;
|
||||||
|
|
||||||
|
// Why the last session ended, if the user did not end it. Cleared on connect.
|
||||||
|
String? _lastEndMessage;
|
||||||
|
String? get lastEndMessage => _lastEndMessage;
|
||||||
|
|
||||||
String get formatted {
|
String get formatted {
|
||||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||||
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||||
@@ -124,16 +132,35 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (!hadActiveSession || _isDisconnecting) return;
|
if (!hadActiveSession || _isDisconnecting) return;
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.error) {
|
if (vpnConnection.status == VpnStatus.error) {
|
||||||
unawaited(_doDisconnect('VPN entered error state'));
|
unawaited(_doDisconnect(
|
||||||
|
'VPN entered error state',
|
||||||
|
endedMessage: 'The tunnel stopped. You are not protected.',
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.disconnected) {
|
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||||
unawaited(_doDisconnect('VPN tunnel dropped'));
|
// The native deadline beats the 1 s tick, so expiry lands here, not in
|
||||||
|
// _tick. postAlert is false: the service already posted, and a
|
||||||
|
// notification-Disconnect tap is the user asking.
|
||||||
|
final deadline = _deadlineAt;
|
||||||
|
final expired = _hasSyncedOnce &&
|
||||||
|
deadline != null &&
|
||||||
|
DateTime.now().isAfter(
|
||||||
|
deadline.subtract(const Duration(seconds: 5)),
|
||||||
|
);
|
||||||
|
unawaited(_doDisconnect(
|
||||||
|
'VPN tunnel dropped',
|
||||||
|
endedMessage:
|
||||||
|
expired ? 'Your session ended. You are not protected.' : null,
|
||||||
|
postAlert: false,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> start() async {
|
// resetSupportClaim is false when re-attaching to a session already held:
|
||||||
|
// the top-up is once per server session and this flag is what enforces it.
|
||||||
|
Future<void> start({bool resetSupportClaim = true}) async {
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
_remainingSeconds = 0;
|
_remainingSeconds = 0;
|
||||||
_remainingAtLastSync = 0;
|
_remainingAtLastSync = 0;
|
||||||
@@ -141,19 +168,22 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_lastUsedBytes = 0;
|
_lastUsedBytes = 0;
|
||||||
_sinceLastSuccessfulSync?.stop();
|
_sinceLastSuccessfulSync?.stop();
|
||||||
_sinceLastSuccessfulSync = null;
|
_sinceLastSuccessfulSync = null;
|
||||||
|
_deadlineAt = null;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
_tickCount = 0;
|
_tickCount = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
_appBackgrounded = false;
|
_appBackgrounded = false;
|
||||||
|
_lastEndMessage = null;
|
||||||
NotificationService.reset();
|
NotificationService.reset();
|
||||||
|
|
||||||
_supportStateEpoch++;
|
if (resetSupportClaim) {
|
||||||
_supportRewardClaimed = false;
|
_supportStateEpoch++;
|
||||||
_supportRewardStateLoaded = true;
|
_supportRewardClaimed = false;
|
||||||
unawaited(_persistSupportRewardState(false));
|
_supportRewardStateLoaded = true;
|
||||||
|
unawaited(_persistSupportRewardState(false));
|
||||||
|
}
|
||||||
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
@@ -169,16 +199,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_reconcileElapsedTime();
|
_reconcileElapsedTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
|
||||||
_offlineSeconds++;
|
|
||||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
|
||||||
unawaited(_doDisconnect('Server unreachable'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||||
unawaited(_doDisconnect('Session expired'));
|
unawaited(_doDisconnect(
|
||||||
|
'Session expired',
|
||||||
|
endedMessage: 'Your session ended. You are not protected.',
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,15 +241,27 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||||
|
// Only here, not in _doDisconnect: this is the one path the user actually
|
||||||
|
// asked for. Unawaited so a slow or unreachable API never holds up the
|
||||||
|
// teardown.
|
||||||
|
unawaited(HivemindService.releaseSession());
|
||||||
await _doDisconnect(reason);
|
await _doDisconnect(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doDisconnect(String reason) async {
|
// endedMessage is what the UI shows; postAlert raises the notification.
|
||||||
|
// A user-requested disconnect passes neither.
|
||||||
|
Future<void> _doDisconnect(
|
||||||
|
String reason, {
|
||||||
|
String? endedMessage,
|
||||||
|
bool postAlert = true,
|
||||||
|
}) async {
|
||||||
if (_isDisconnecting) return;
|
if (_isDisconnecting) return;
|
||||||
_isDisconnecting = true;
|
_isDisconnecting = true;
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
debugPrint('[Timer] Disconnecting: $reason');
|
debugPrint('[Timer] Disconnecting: $reason');
|
||||||
|
|
||||||
|
_lastEndMessage = endedMessage;
|
||||||
|
_deadlineAt = null;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
@@ -237,6 +274,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
await vpnConnection.disconnect();
|
await vpnConnection.disconnect();
|
||||||
|
// After the service is down, so the cancel is not swallowed by a still
|
||||||
|
// foreground notification. Without it a revoke leaves the countdown on
|
||||||
|
// screen, frozen, claiming a session that no longer protects anything.
|
||||||
|
await NotificationService.cancelTimer();
|
||||||
|
if (endedMessage != null && postAlert) {
|
||||||
|
unawaited(NotificationService.showDisconnectedAlert());
|
||||||
|
}
|
||||||
|
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -282,7 +326,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!activeValue) {
|
if (!activeValue) {
|
||||||
await _doDisconnect('Server ended session');
|
await _doDisconnect(
|
||||||
|
'Server ended session',
|
||||||
|
endedMessage:
|
||||||
|
'The server ended your session. You are not protected.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +346,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
|
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
|
||||||
_remainingAtLastSync = expiresValue.toInt();
|
_remainingAtLastSync = expiresValue.toInt();
|
||||||
_remainingSeconds = _remainingAtLastSync;
|
_remainingSeconds = _remainingAtLastSync;
|
||||||
|
_deadlineAt = DateTime.now().add(
|
||||||
|
Duration(seconds: _remainingAtLastSync),
|
||||||
|
);
|
||||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||||
|
|
||||||
final deltaBytes = _usedBytes - _lastUsedBytes;
|
final deltaBytes = _usedBytes - _lastUsedBytes;
|
||||||
@@ -313,7 +364,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_sinceLastSuccessfulSync = Stopwatch()..start();
|
_sinceLastSuccessfulSync = Stopwatch()..start();
|
||||||
|
|
||||||
if (data['cap_exhausted'] == true) {
|
if (data['cap_exhausted'] == true) {
|
||||||
await _doDisconnect('Data cap reached');
|
await _doDisconnect(
|
||||||
|
'Data cap reached',
|
||||||
|
endedMessage: 'Data limit reached. You are not protected.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,12 +376,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Timer] Failed to arm native session deadline: $e');
|
debugPrint('[Timer] Failed to arm native session deadline: $e');
|
||||||
await _doDisconnect('Native session deadline unavailable');
|
await _doDisconnect(
|
||||||
|
'Native session deadline unavailable',
|
||||||
|
endedMessage:
|
||||||
|
'Could not arm the session timer. You are not protected.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
|
||||||
_hasSyncedOnce = true;
|
_hasSyncedOnce = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} else {
|
} else {
|
||||||
@@ -364,6 +421,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final elapsed = clock.elapsed.inSeconds;
|
final elapsed = clock.elapsed.inSeconds;
|
||||||
_remainingSeconds =
|
_remainingSeconds =
|
||||||
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
||||||
|
|
||||||
|
// The Stopwatch over-reports after device suspend. Take the smaller of
|
||||||
|
// the two, never the wall clock alone - a backward clock must not extend.
|
||||||
|
final deadline = _deadlineAt;
|
||||||
|
if (deadline != null) {
|
||||||
|
final byWallClock = deadline.difference(DateTime.now()).inSeconds;
|
||||||
|
if (byWallClock < _remainingSeconds) {
|
||||||
|
_remainingSeconds = byWallClock.clamp(0, 1 << 31).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> connect({bool skipAdBypass = false}) async {
|
Future<bool> connect({String? adNonce, bool resume = false}) async {
|
||||||
if (_disposed ||
|
if (_disposed ||
|
||||||
_userDisconnecting ||
|
_userDisconnecting ||
|
||||||
_status == VpnStatus.connected ||
|
_status == VpnStatus.connected ||
|
||||||
@@ -224,6 +224,17 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proxy mode skips requestPermission below (VPN consent it never needs),
|
||||||
|
// so it has to ask for POST_NOTIFICATIONS on its own.
|
||||||
|
if (!kIsWeb && _activeMode == ConnectionMode.proxy) {
|
||||||
|
try {
|
||||||
|
await _nativeControl.invokeMethod<void>('requestNotificationPermission');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VPN] Notification permission request failed: $e');
|
||||||
|
}
|
||||||
|
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||||
final ok = await _vless.requestPermission();
|
final ok = await _vless.requestPermission();
|
||||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||||
@@ -254,7 +265,8 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
String realUrl;
|
String realUrl;
|
||||||
try {
|
try {
|
||||||
realUrl = await HivemindService.fetchConfigDirectly(
|
realUrl = await HivemindService.fetchConfigDirectly(
|
||||||
skipAdBypass: skipAdBypass,
|
adNonce: adNonce,
|
||||||
|
resume: resume,
|
||||||
onAttempt: (attempt, total) {
|
onAttempt: (attempt, total) {
|
||||||
if (!_isCurrentConnect(connectEpoch)) return;
|
if (!_isCurrentConnect(connectEpoch)) return;
|
||||||
_setStatus(
|
_setStatus(
|
||||||
|
|||||||
@@ -67,10 +67,8 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
widget.onChanged?.call(next);
|
widget.onChanged?.call(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recovers from an orphaned proxy: tear the runtime down and start a fresh
|
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
||||||
/// session so a readable set of credentials exists again. No rewarded ad —
|
// session already held. No ad, no new session.
|
||||||
/// the user is repairing a connection they already paid for, not opening a
|
|
||||||
/// new one.
|
|
||||||
Future<void> _reconnect() async {
|
Future<void> _reconnect() async {
|
||||||
if (_reconnecting) return;
|
if (_reconnecting) return;
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
@@ -80,9 +78,9 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
try {
|
try {
|
||||||
await timer.disconnect();
|
await timer.disconnect();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (await vpn.connect()) {
|
if (await vpn.connect(resume: true)) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await timer.start();
|
await timer.start(resetSupportClaim: false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
+22
@@ -240,6 +240,28 @@ class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.Activity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST_NOTIFICATIONS without VpnService.prepare, for proxy mode.
|
||||||
|
"requestNotificationPermission" -> {
|
||||||
|
val currentActivity = activity
|
||||||
|
if (currentActivity == null) {
|
||||||
|
result.error("NO_ACTIVITY", "Activity is not attached", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ActivityCompat.checkSelfPermission(
|
||||||
|
currentActivity,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
currentActivity,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
REQUEST_CODE_POST_NOTIFICATIONS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
"requestPermission" -> {
|
"requestPermission" -> {
|
||||||
val currentActivity = activity
|
val currentActivity = activity
|
||||||
if (currentActivity == null) {
|
if (currentActivity == null) {
|
||||||
|
|||||||
+64
-6
@@ -11,6 +11,8 @@ import android.content.pm.PackageManager
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CountDownTimer
|
import android.os.CountDownTimer
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@@ -37,6 +39,13 @@ object XrayCoreManager {
|
|||||||
|
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val TAG = "XrayCoreManager"
|
private const val TAG = "XrayCoreManager"
|
||||||
|
|
||||||
|
// The core's output is kept only for its first seconds, and only to explain
|
||||||
|
// a non-zero exit. A core that has been up longer than this may be carrying
|
||||||
|
// traffic, and its log can name destinations, which must never reach the
|
||||||
|
// device log (rule 95) - so past the window the buffer is dropped.
|
||||||
|
private const val STARTUP_LOG_WINDOW_MS = 10_000L
|
||||||
|
private const val STARTUP_LOG_MAX_LINES = 40
|
||||||
private var xrayProcess: Process? = null
|
private var xrayProcess: Process? = null
|
||||||
private var countDownTimer: CountDownTimer? = null
|
private var countDownTimer: CountDownTimer? = null
|
||||||
private var seconds = 0
|
private var seconds = 0
|
||||||
@@ -347,12 +356,29 @@ object XrayCoreManager {
|
|||||||
// Monitor process in a separate thread to detect crash
|
// Monitor process in a separate thread to detect crash
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
val startupLog = ArrayDeque<String>()
|
||||||
process.inputStream.bufferedReader().use { reader ->
|
process.inputStream.bufferedReader().use { reader ->
|
||||||
reader.forEachLine { _ -> }
|
reader.forEachLine { line ->
|
||||||
|
if (System.currentTimeMillis() - startedAt <= STARTUP_LOG_WINDOW_MS) {
|
||||||
|
if (startupLog.size >= STARTUP_LOG_MAX_LINES) startupLog.removeFirst()
|
||||||
|
startupLog.addLast(line)
|
||||||
|
} else if (startupLog.isNotEmpty()) {
|
||||||
|
startupLog.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val exitCode = process.waitFor()
|
val exitCode = process.waitFor()
|
||||||
Log.e(TAG, "Xray process exited with code $exitCode")
|
if (exitCode != 0 && startupLog.isNotEmpty()) {
|
||||||
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"Xray process exited with code $exitCode; startup output:\n" +
|
||||||
|
startupLog.joinToString("\n")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Xray process exited with code $exitCode")
|
||||||
|
}
|
||||||
if (xrayProcess === process &&
|
if (xrayProcess === process &&
|
||||||
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
|
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
|
||||||
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||||
@@ -383,6 +409,18 @@ object XrayCoreManager {
|
|||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start Xray process", e)
|
Log.e(TAG, "Failed to start Xray process", e)
|
||||||
|
// The process may already be running: pb.start() succeeds before
|
||||||
|
// anything below it can throw. Leaving it alive leaked one live
|
||||||
|
// core per recovery attempt. Clear the field BEFORE destroying so
|
||||||
|
// the monitor thread's `xrayProcess === process` guard fails and
|
||||||
|
// recovery is not re-entered for a core we are killing here.
|
||||||
|
val orphan = xrayProcess
|
||||||
|
xrayProcess = null
|
||||||
|
try {
|
||||||
|
orphan?.destroy()
|
||||||
|
} catch (destroyError: Exception) {
|
||||||
|
Log.w(TAG, "Failed to destroy partially started Xray process", destroyError)
|
||||||
|
}
|
||||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -429,7 +467,25 @@ object XrayCoreManager {
|
|||||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountDownTimer builds a Handler, so it only constructs on a looper thread.
|
||||||
|
// Core recovery runs on a bare Thread (XrayVPNService.handleXrayCoreExit),
|
||||||
|
// where this threw "Can't create handler inside thread ... that has not
|
||||||
|
// called Looper.prepare()" and took the whole restart down with it.
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private fun runOnMain(block: () -> Unit) {
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startTimer(context: Context) {
|
private fun startTimer(context: Context) {
|
||||||
|
// Captured now: a stop that lands while the post is queued must not
|
||||||
|
// leave a timer ticking for a generation that is already gone.
|
||||||
|
val token = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN
|
||||||
|
runOnMain { startTimerOnMain(context, token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTimerOnMain(context: Context, token: String?) {
|
||||||
|
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return
|
||||||
countDownTimer?.cancel()
|
countDownTimer?.cancel()
|
||||||
seconds = 0
|
seconds = 0
|
||||||
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
|
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
|
||||||
@@ -454,9 +510,11 @@ object XrayCoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun stopTimer() {
|
private fun stopTimer() {
|
||||||
countDownTimer?.cancel()
|
runOnMain {
|
||||||
countDownTimer = null
|
countDownTimer?.cancel()
|
||||||
seconds = 0
|
countDownTimer = null
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
|
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
|
||||||
|
|||||||
+144
-3
@@ -16,6 +16,7 @@ import android.util.Log
|
|||||||
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
||||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -44,6 +45,14 @@ class XrayVPNService : VpnService() {
|
|||||||
private var sessionDeadlineToken: String? = null
|
private var sessionDeadlineToken: String? = null
|
||||||
private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
|
private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
|
||||||
|
|
||||||
|
// The warning's own backstop, mirroring the deadline's. The alarm alone is
|
||||||
|
// not enough: it is inexact on purpose (no SCHEDULE_EXACT_ALARM), and with
|
||||||
|
// the app closed on a dozing device it was measured never arriving at all,
|
||||||
|
// while the deadline's Handler fired within a second of its target.
|
||||||
|
private val sessionWarningRunnable = Runnable {
|
||||||
|
handleSessionWarningAlarm(sessionDeadlineToken.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,13 @@ class XrayVPNService : VpnService() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must stay above the command dispatch: an intent with no COMMAND
|
||||||
|
// falls through to the final else and stops the tunnel.
|
||||||
|
if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) {
|
||||||
|
handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
val command = if (Build.VERSION.SDK_INT >= 33) {
|
val command = if (Build.VERSION.SDK_INT >= 33) {
|
||||||
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
||||||
} else {
|
} else {
|
||||||
@@ -82,7 +98,7 @@ class XrayVPNService : VpnService() {
|
|||||||
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
|
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
stopAll(requestedToken)
|
stopAll(requestedToken, userRequested = true)
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +137,7 @@ class XrayVPNService : VpnService() {
|
|||||||
XrayCoreManager.stopCore(this)
|
XrayCoreManager.stopCore(this)
|
||||||
}
|
}
|
||||||
cleanup(clearDeadline = true)
|
cleanup(clearDeadline = true)
|
||||||
|
RevoltAlerts.cancelAll(this)
|
||||||
shuttingDownIntentionally = false
|
shuttingDownIntentionally = false
|
||||||
currentConfig = config
|
currentConfig = config
|
||||||
|
|
||||||
@@ -418,14 +435,21 @@ class XrayVPNService : VpnService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) {
|
// Anything that stops a live tunnel unasked must say so. isRunning is read
|
||||||
|
// before cleanup() clears it, which keeps a failed connect silent.
|
||||||
|
private fun stopAll(
|
||||||
|
confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty(),
|
||||||
|
userRequested: Boolean = false,
|
||||||
|
) {
|
||||||
if (shuttingDownIntentionally) return
|
if (shuttingDownIntentionally) return
|
||||||
|
val alertUser = isRunning && !userRequested
|
||||||
shuttingDownIntentionally = true
|
shuttingDownIntentionally = true
|
||||||
cleanup(clearDeadline = true)
|
cleanup(clearDeadline = true)
|
||||||
currentConfig = null
|
currentConfig = null
|
||||||
XrayCoreManager.stopCore(this, confirmationToken)
|
XrayCoreManager.stopCore(this, confirmationToken)
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
if (alertUser) RevoltAlerts.postDisconnected(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
|
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
|
||||||
@@ -451,17 +475,91 @@ class XrayVPNService : VpnService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline)
|
||||||
|
|
||||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||||
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
||||||
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advisory, unlike the deadline itself: nothing here fails closed. Dart
|
||||||
|
// re-pushes the deadline every sync, so this runs about once a minute.
|
||||||
|
private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) {
|
||||||
|
if (runtimeToken.isEmpty()) return
|
||||||
|
|
||||||
|
val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS
|
||||||
|
// Inside the window already (or the short bootstrap deadline). Cancel
|
||||||
|
// nothing here: with no dedupe on the re-push, cancelling on this
|
||||||
|
// branch deletes the armed warning and never re-arms it.
|
||||||
|
if (warnAt <= System.currentTimeMillis() + WARNING_REARM_GRACE_MS) return
|
||||||
|
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
// A warning on screen now belongs to an older deadline (top-up).
|
||||||
|
RevoltAlerts.cancelExpiryWarning(this)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
val pendingIntent = sessionWarningPendingIntent(runtimeToken)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
// Inexact on purpose - keeps this off SCHEDULE_EXACT_ALARM.
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP,
|
||||||
|
warnAt,
|
||||||
|
pendingIntent,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to schedule session-expiry warning", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postDelayed runs on uptimeMillis, so a truly suspended device still
|
||||||
|
// fires this late - handleSessionWarningAlarm then rejects it on the
|
||||||
|
// window check rather than claiming "5 minutes" with seconds left.
|
||||||
|
// Whenever the process is merely backgrounded, which is the common
|
||||||
|
// case, this is what actually delivers the warning.
|
||||||
|
val warnDelay = warnAt - System.currentTimeMillis()
|
||||||
|
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||||
|
if (warnDelay > 0L) {
|
||||||
|
deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSessionWarningAlarm(alarmToken: String) {
|
||||||
|
val config = currentConfig
|
||||||
|
if (config == null) {
|
||||||
|
// Woke a process with no live runtime; nothing to warn about.
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) {
|
||||||
|
Log.w(TAG, "Ignoring session warning for stale runtime generation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val deadline = sessionDeadlineElapsed ?: return
|
||||||
|
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||||
|
if (remaining < WARNING_FLOOR_MS ||
|
||||||
|
remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS
|
||||||
|
) {
|
||||||
|
// Too late, or the deadline moved. Saying "5 minutes" with
|
||||||
|
// seconds left is worse than silence.
|
||||||
|
Log.w(TAG, "Ignoring session warning outside the warning window")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RevoltAlerts.postExpiryWarning(this)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearSessionDeadline() {
|
private fun clearSessionDeadline() {
|
||||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||||
sessionDeadlineElapsed = null
|
sessionDeadlineElapsed = null
|
||||||
sessionDeadlineToken = null
|
sessionDeadlineToken = null
|
||||||
clearPersistedDeadline()
|
clearPersistedDeadline()
|
||||||
cancelSessionExpiryAlarm()
|
cancelSessionExpiryAlarm()
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
RevoltAlerts.cancelExpiryWarning(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enforceSessionDeadline() {
|
private fun enforceSessionDeadline() {
|
||||||
@@ -561,8 +659,12 @@ class XrayVPNService : VpnService() {
|
|||||||
} else {
|
} else {
|
||||||
clearPersistedDeadline()
|
clearPersistedDeadline()
|
||||||
cancelSessionExpiryAlarm()
|
cancelSessionExpiryAlarm()
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
RevoltAlerts.cancelExpiryWarning(this)
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
// This arm does not go through stopAll, so it posts for itself.
|
||||||
|
RevoltAlerts.postDisconnected(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +709,37 @@ class XrayVPNService : VpnService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cancelSessionWarningAlarm() {
|
||||||
|
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||||
|
try {
|
||||||
|
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
// Look it up rather than build it: FLAG_UPDATE_CURRENT would
|
||||||
|
// rewrite the live alarm's RUNTIME_TOKEN to "" before cancelling.
|
||||||
|
existingSessionWarningPendingIntent()?.let { alarmManager.cancel(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to cancel session-warning alarm", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun existingSessionWarningPendingIntent(): PendingIntent? {
|
||||||
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
|
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||||
|
val flags = PendingIntent.FLAG_NO_CREATE or
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
|
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity is component + request code, not extras, so the warning needs
|
||||||
|
// its own code or it clobbers the expiry alarm.
|
||||||
|
private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent {
|
||||||
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
|
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||||
|
.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
|
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
||||||
val intent = Intent(this, XrayVPNService::class.java)
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
||||||
@@ -628,7 +761,7 @@ class XrayVPNService : VpnService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onRevoke() {
|
override fun onRevoke() {
|
||||||
stopAll()
|
stopAll(userRequested = true)
|
||||||
super.onRevoke()
|
super.onRevoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +814,14 @@ class XrayVPNService : VpnService() {
|
|||||||
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
||||||
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
||||||
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
||||||
|
private const val EXTRA_SESSION_WARNING = "SESSION_WARNING"
|
||||||
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
|
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
|
||||||
|
private const val SESSION_WARNING_REQUEST_CODE = 1002
|
||||||
|
// Drift allowed before a warning is treated as belonging elsewhere.
|
||||||
|
private const val WARNING_SLACK_MS = 60_000L
|
||||||
|
// Don't re-arm for a deadline that moved by round-trip jitter alone.
|
||||||
|
private const val WARNING_REARM_GRACE_MS = 15_000L
|
||||||
|
// Below this the warning has lost its point.
|
||||||
|
private const val WARNING_FLOOR_MS = 60_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
package com.github.tfox.flutter_vless.xray.utils
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
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 android.util.Log
|
||||||
|
|
||||||
|
// local_packages/.../xray/utils/RevoltAlerts.kt
|
||||||
|
//
|
||||||
|
// The two session alerts. Both XrayVPNService (:RunSoLibXrayDaemon) and
|
||||||
|
// MainActivity post them, sharing ids so a second post replaces. Separate from
|
||||||
|
// the foreground-service notification (id 1, IMPORTANCE_LOW): these must be
|
||||||
|
// noticed, and must be dismissible.
|
||||||
|
object RevoltAlerts {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "REVOLT_VPN_ALERTS"
|
||||||
|
const val WARNING_NOTIFICATION_ID = 2
|
||||||
|
const val DISCONNECTED_NOTIFICATION_ID = 3
|
||||||
|
|
||||||
|
const val WARNING_LEAD_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
private const val TAG = "RevoltAlerts"
|
||||||
|
private const val LAUNCH_REQUEST_CODE = 2001
|
||||||
|
|
||||||
|
// Neither line names a number or a mechanism, because this notification
|
||||||
|
// cannot honour either. The caller accepts it anywhere from WARNING_FLOOR_MS
|
||||||
|
// to WARNING_LEAD_MS + WARNING_SLACK_MS remaining, so "5 minutes" can be
|
||||||
|
// off by four of them; and "watch an ad" is a dead end in a release build,
|
||||||
|
// where the support bypass is still kDebugMode-gated (rule 87). Put the
|
||||||
|
// number back once the warning is punctual, and the ad back once the
|
||||||
|
// support button works without a debug build.
|
||||||
|
fun postExpiryWarning(context: Context) {
|
||||||
|
post(
|
||||||
|
context,
|
||||||
|
WARNING_NOTIFICATION_ID,
|
||||||
|
"Revolt VPN disconnects soon",
|
||||||
|
"Open the app to extend your session.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postDisconnected(context: Context) {
|
||||||
|
post(
|
||||||
|
context,
|
||||||
|
DISCONNECTED_NOTIFICATION_ID,
|
||||||
|
"Revolt VPN disconnected",
|
||||||
|
"Your traffic is no longer protected. Tap to reconnect.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID)
|
||||||
|
|
||||||
|
fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID)
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
cancelExpiryWarning(context)
|
||||||
|
cancelDisconnected(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(context: Context, id: Int) {
|
||||||
|
try {
|
||||||
|
manager(context)?.cancel(id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to cancel alert $id", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(context: Context, id: Int, title: String, text: String) {
|
||||||
|
if (!canPost(context)) {
|
||||||
|
Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val manager = manager(context) ?: return
|
||||||
|
ensureChannel(manager)
|
||||||
|
|
||||||
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(context, CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH)
|
||||||
|
}
|
||||||
|
|
||||||
|
val icon = context.resources
|
||||||
|
.getIdentifier("notification_icon", "drawable", context.packageName)
|
||||||
|
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
|
||||||
|
|
||||||
|
builder
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||||
|
.setSmallIcon(icon)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOngoing(false)
|
||||||
|
// A replacing post must not sound again.
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
|
||||||
|
launchIntent(context)?.let { builder.setContentIntent(it) }
|
||||||
|
|
||||||
|
manager.notify(id, builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to post alert $id", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canPost(context: Context): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||||
|
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun manager(context: Context): NotificationManager? =
|
||||||
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
|
|
||||||
|
// Importance is fixed at creation, so ask for HIGH up front.
|
||||||
|
private fun ensureChannel(manager: NotificationManager) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Session alerts",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
)
|
||||||
|
channel.description = "Warns before a session ends and when protection stops."
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchIntent(context: Context): PendingIntent? {
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?: return null
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||||
|
return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.3.6+32
|
version: 3.3.7+33
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
// Source assertions, in the style of release_verification_regression_test.dart.
|
||||||
|
// The behaviours below were all found broken on a device and fixed on
|
||||||
|
// 2026-10-17 (internal/DEVICE_TEST_2026-10-17.md). None of them can be covered
|
||||||
|
// by a Dart unit test - they live in the vendored Android runtime, and the one
|
||||||
|
// that does not needs a live tunnel - but every one of them regresses silently
|
||||||
|
// and looks like tidying when it does. Pin the shape instead of the behaviour.
|
||||||
|
|
||||||
|
const _runtime = 'local_packages/flutter_vless_android-1.1.5/android/src/main/'
|
||||||
|
'kotlin/com/github/tfox/flutter_vless/xray';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Xray core recovery survives running off a bare thread', () {
|
||||||
|
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||||
|
|
||||||
|
// startTimer builds a CountDownTimer, which builds a Handler. Recovery runs
|
||||||
|
// on a plain Thread, so constructing it inline threw Looper.prepare() and
|
||||||
|
// killed every restart attempt.
|
||||||
|
expect(core, contains('Handler(Looper.getMainLooper())'));
|
||||||
|
expect(core, contains('runOnMain'));
|
||||||
|
expect(
|
||||||
|
core,
|
||||||
|
contains('runOnMain { startTimerOnMain(context, token) }'),
|
||||||
|
reason: 'startTimer must not construct the CountDownTimer inline',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The post must not outlive the generation that scheduled it.
|
||||||
|
expect(core, contains('if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return'));
|
||||||
|
|
||||||
|
// pb.start() succeeds before the throw, so the catch has to kill the
|
||||||
|
// process or each retry strands another live core.
|
||||||
|
expect(core, contains('val orphan = xrayProcess'));
|
||||||
|
expect(
|
||||||
|
core.indexOf('xrayProcess = null'),
|
||||||
|
lessThan(core.indexOf('orphan?.destroy()')),
|
||||||
|
reason: 'clear the field before destroying, so the monitor thread\'s '
|
||||||
|
'identity guard fails and recovery is not re-entered',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed Xray core still reports why, without logging destinations', () {
|
||||||
|
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||||
|
|
||||||
|
expect(core, contains('STARTUP_LOG_WINDOW_MS'));
|
||||||
|
expect(core, contains('STARTUP_LOG_MAX_LINES'));
|
||||||
|
expect(
|
||||||
|
core,
|
||||||
|
isNot(contains('reader.forEachLine { _ -> }')),
|
||||||
|
reason: 'discarding the core output leaves exit 23 undiagnosable',
|
||||||
|
);
|
||||||
|
// Rule 95: a core that has been up long enough to carry traffic must not
|
||||||
|
// have its log retained.
|
||||||
|
expect(core, contains('startupLog.clear()'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the expiry warning has the same backstop as the deadline', () {
|
||||||
|
final service = File('$_runtime/service/XrayVPNService.kt').readAsStringSync();
|
||||||
|
|
||||||
|
// The alarm alone is inexact on purpose (no SCHEDULE_EXACT_ALARM) and Doze
|
||||||
|
// deferred it past the end of the session it was warning about.
|
||||||
|
expect(service, contains('sessionWarningRunnable'));
|
||||||
|
expect(
|
||||||
|
service,
|
||||||
|
contains('deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)'),
|
||||||
|
reason: 'without this the warning never arrives when the app is closed',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
service,
|
||||||
|
contains('deadlineHandler.removeCallbacks(sessionWarningRunnable)'),
|
||||||
|
reason: 'a cancelled warning must drop its Handler callback too',
|
||||||
|
);
|
||||||
|
expect(service, isNot(contains('SCHEDULE_EXACT_ALARM"')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the warning promises neither a number of minutes nor an ad', () {
|
||||||
|
final alerts = File('$_runtime/utils/RevoltAlerts.kt').readAsStringSync();
|
||||||
|
|
||||||
|
// It is accepted anywhere from WARNING_FLOOR_MS to LEAD + SLACK remaining,
|
||||||
|
// so a figure can be off by minutes; and the support bypass is still
|
||||||
|
// kDebugMode-gated (rule 87), so an ad is a dead end in release.
|
||||||
|
expect(alerts, isNot(contains('disconnects in 5 minutes')));
|
||||||
|
expect(alerts, isNot(contains('Watch an ad')));
|
||||||
|
expect(alerts, contains('Revolt VPN disconnects soon'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the countdown notification is taken down when the tunnel stops', () {
|
||||||
|
final service = File('lib/logic/notification_service.dart').readAsStringSync();
|
||||||
|
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||||
|
final activity =
|
||||||
|
File('android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt')
|
||||||
|
.readAsStringSync();
|
||||||
|
|
||||||
|
// updateTimer re-posts an ongoing, non-dismissible notification every tick,
|
||||||
|
// so a tick after the service stops strands a frozen countdown.
|
||||||
|
expect(service, contains('cancelTimer'));
|
||||||
|
expect(activity, contains('"cancelNotification"'));
|
||||||
|
expect(timer, contains('await NotificationService.cancelTimer()'));
|
||||||
|
|
||||||
|
// Must run after the service is down, or the platform swallows the cancel.
|
||||||
|
expect(
|
||||||
|
timer.indexOf('await vpnConnection.disconnect();'),
|
||||||
|
lessThan(timer.indexOf('await NotificationService.cancelTimer()')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a user-requested disconnect hands the session back', () {
|
||||||
|
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||||
|
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||||
|
|
||||||
|
expect(hivemind, contains('releaseSession'));
|
||||||
|
expect(hivemind, contains("_publicUrl('/session/stop')"));
|
||||||
|
|
||||||
|
// /session/stop takes a device_id as its only proof of ownership, so it
|
||||||
|
// must never be reachable from an error path. disconnect() is called only
|
||||||
|
// by the connect button; _doDisconnect is every other ending.
|
||||||
|
final disconnectEntry = timer.indexOf('Future<void> disconnect(');
|
||||||
|
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||||
|
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||||
|
expect(release, greaterThan(disconnectEntry));
|
||||||
|
expect(
|
||||||
|
release,
|
||||||
|
lessThan(doDisconnect),
|
||||||
|
reason: 'releaseSession belongs in disconnect(), not _doDisconnect',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
'HivemindService.releaseSession()'.allMatches(timer).length,
|
||||||
|
1,
|
||||||
|
reason: 'exactly one call site',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user