Compare commits
6
Commits
v3.3.7
...
f28379f73f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28379f73f | ||
|
|
8260c7cc0a | ||
|
|
62e968f133 | ||
|
|
571c07bca8 | ||
|
|
73d76c04f2 | ||
|
|
e751a9d6e4 |
No files matched your search
+62
-31
@@ -5,17 +5,52 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
- **The background server check while disconnected.** The app no longer contacts
|
||||
the server every 30 seconds while you are not connected to it. Nothing about
|
||||
the connected session changes.
|
||||
|
||||
### Changed
|
||||
- **The status bar no longer guesses whether the server is up.** It reads
|
||||
"Ready" while disconnected and shows "Online" once a session has synced, so a
|
||||
green dot now means a session that is actually working rather than a
|
||||
reachability check made beforehand.
|
||||
|
||||
## [3.3.8] — 2026-10-17
|
||||
|
||||
### Changed
|
||||
- **Reconnecting always starts a fresh session.** In SOCKS5 mode, the
|
||||
**Reconnect** button used to re-attach to the session you already had. It now
|
||||
ends that session and starts a new one, exactly as the connect button does —
|
||||
one way to begin a session instead of two, and you get a full window back
|
||||
rather than the remainder of a broken one.
|
||||
|
||||
### Fixed
|
||||
- **SOCKS5 "Reconnect" works again.** In 3.3.7 it ended your session and then
|
||||
asked for the session it had just ended, failing with "Session not activated".
|
||||
|
||||
---
|
||||
|
||||
## [3.3.7] — 2026-10-17
|
||||
|
||||
> **Known issue — fixed in 3.3.8.** In SOCKS5 mode, the **Reconnect**
|
||||
> button in Settings ends your session instead of re-attaching to it, and then
|
||||
> fails with "Session not activated". It only appears when a proxy session has
|
||||
> been orphaned, and you lose the rest of that session rather than anything
|
||||
> worse. Workaround: connect again from the main screen. The "SOCKS5 Reconnect"
|
||||
> entry below describes the intended behaviour, which this build does not have.
|
||||
|
||||
### 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.
|
||||
deadline it already enforces, so it arrives even with the app closed, and a
|
||||
support top-up moves it with the deadline. It does not promise an exact number
|
||||
of minutes: on a deeply sleeping phone Android can hold it back past the end
|
||||
of the session, in which case it is skipped rather than shown late.
|
||||
- **A "disconnected — not protected" notification** when a session ends without
|
||||
you asking: the 2-hour expiry, the data limit, or the server ending the
|
||||
session.
|
||||
- **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.
|
||||
@@ -28,36 +63,32 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
||||
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.
|
||||
VPN service's own deadline, which keeps running regardless.
|
||||
|
||||
### 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.
|
||||
- **The countdown no longer over-reports after the phone sleeps**, and a clock
|
||||
moved backwards can no longer stretch a session.
|
||||
- **A session that expired while the phone was asleep is recognised on waking**,
|
||||
by wall clock rather than by time the device spent awake.
|
||||
- **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.
|
||||
- **The VPN recovers 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. It now usually comes back on the
|
||||
first retry, without dropping the connection.
|
||||
- **Disconnecting with the app's own button now releases your session on the
|
||||
server** instead of leaving it open until it expires.
|
||||
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
||||
requesting a new one — see the known issue above; this does not work in 3.3.7.
|
||||
- **A core that fails to start now logs why.** Its output was being discarded,
|
||||
so the only trace was an exit code.
|
||||
|
||||
---
|
||||
### Groundwork
|
||||
- **Rewarded-ad plumbing.** Ads are switched off in this build, so none of this
|
||||
is reachable yet: the ad's verification nonce is now carried through to the
|
||||
connect call rather than being minted twice, and the client waits longer
|
||||
(~36 s) for Google's callback. Reviewed, not yet exercised end to end.
|
||||
|
||||
## [3.3.6] — 2026-10-08
|
||||
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last updated:** September 2026
|
||||
**Last updated:** October 2026
|
||||
|
||||
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
||||
|
||||
@@ -12,7 +12,7 @@ When you first open the app, we show a brief summary of this policy before you c
|
||||
|
||||
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.
|
||||
- A randomly generated device ID created on your phone. It is stored on your device and reused between sessions, and it is not linked to your identity in any way.
|
||||
- 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
|
||||
@@ -27,14 +27,14 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
|
||||
|
||||
## Operational logs
|
||||
|
||||
Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
||||
Server logs are written to the system journal. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
||||
|
||||
## What we do not collect
|
||||
|
||||
- We do not collect your name, email, or any personal information.
|
||||
- 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.
|
||||
- We do not sell your data. We do not share it with anyone, apart from what Google's ad SDK collects for ad delivery — see “Ads & Consent” below.
|
||||
|
||||
## Ads & Consent
|
||||
|
||||
|
||||
@@ -49,15 +49,14 @@ class StatusBar extends StatelessWidget {
|
||||
builder: (context, vpn, timer, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||
// Rule 60: reachability is known only from a session sync that
|
||||
// succeeded, never from a probe. While disconnected the app knows
|
||||
// nothing about the server, so it claims nothing either way.
|
||||
final serverHealthy = isConnected && timer.hasSyncedOnce;
|
||||
|
||||
final statusLabel = isConnected
|
||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
: (isConnecting ? 'Connecting…' : 'Ready');
|
||||
|
||||
final detail = _detail(vpn, timer);
|
||||
|
||||
|
||||
@@ -146,14 +146,13 @@ class HivemindService {
|
||||
_expectedNonce = null;
|
||||
}
|
||||
|
||||
// Fetches this device's VLESS URL.
|
||||
// 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)
|
||||
// Fetches this device's VLESS URL. Every session starts the same way, so
|
||||
// there is always a nonce to bind it to: either the one the ad minted, or one
|
||||
// we mint here before firing the bypass (rule 87). The re-attach path that
|
||||
// used to accept any nonce is gone - see rule 4.
|
||||
static Future<String> fetchConfigDirectly({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
String? adNonce,
|
||||
bool resume = false,
|
||||
}) async {
|
||||
// Capture the generation before the first await. Otherwise a disconnect
|
||||
// that lands while secure storage is resolving the device ID can be lost
|
||||
@@ -162,11 +161,11 @@ class HivemindService {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
_throwIfCancelled(callId);
|
||||
|
||||
final nonce = resume ? null : (adNonce ?? newNonce());
|
||||
final nonce = adNonce ?? newNonce();
|
||||
_expectedNonce = nonce;
|
||||
|
||||
if (!resume && adNonce == null) {
|
||||
await _runConfiguredBypass(deviceId, nonce!);
|
||||
if (adNonce == null) {
|
||||
await _runConfiguredBypass(deviceId, nonce);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
|
||||
@@ -198,18 +197,6 @@ class HivemindService {
|
||||
throw Exception('Session not activated. Server callback may have timed out.');
|
||||
}
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final response = await controlGet(
|
||||
_publicUrl('/health'),
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static String newNonce() {
|
||||
final random = Random.secure();
|
||||
final high = random.nextInt(0x7FFFFFFF);
|
||||
@@ -229,10 +216,9 @@ class HivemindService {
|
||||
}
|
||||
}
|
||||
|
||||
// nonce == null accepts whatever nonce the server holds (resume path).
|
||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||
String deviceId,
|
||||
String? nonce,
|
||||
String nonce,
|
||||
) async {
|
||||
final statusUri = _publicUrl('/session/status').replace(
|
||||
queryParameters: {'device_id': deviceId},
|
||||
@@ -245,9 +231,7 @@ class HivemindService {
|
||||
|
||||
final serverNonce = decoded['nonce'];
|
||||
if (_expectedNonce != nonce) return null;
|
||||
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
|
||||
return null;
|
||||
}
|
||||
if (serverNonce is! String || serverNonce != nonce) return null;
|
||||
|
||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||
return null;
|
||||
|
||||
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Future<void> start() async {
|
||||
_sessionEpoch++;
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_lastEndMessage = null;
|
||||
NotificationService.reset();
|
||||
|
||||
if (resetSupportClaim) {
|
||||
_supportStateEpoch++;
|
||||
_supportRewardClaimed = false;
|
||||
_supportRewardStateLoaded = true;
|
||||
unawaited(_persistSupportRewardState(false));
|
||||
}
|
||||
// Every session is new, so the once-per-session top-up resets with it.
|
||||
_supportStateEpoch++;
|
||||
_supportRewardClaimed = false;
|
||||
_supportRewardStateLoaded = true;
|
||||
unawaited(_persistSupportRewardState(false));
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
|
||||
@@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
bool _adoptedRunningRuntime = false;
|
||||
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||
|
||||
bool _serverReachable = false;
|
||||
bool get serverReachable => _serverReachable;
|
||||
|
||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||
ConnectionMode get activeMode => _activeMode;
|
||||
|
||||
@@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Timer? _healthTimer;
|
||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||
|
||||
SecureSocksSession? _lastSecureSocks;
|
||||
@@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(_checkHealth());
|
||||
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
unawaited(_checkHealth());
|
||||
});
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
if (!_disposed) {
|
||||
@@ -200,7 +191,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> connect({String? adNonce, bool resume = false}) async {
|
||||
Future<bool> connect({String? adNonce}) async {
|
||||
if (_disposed ||
|
||||
_userDisconnecting ||
|
||||
_status == VpnStatus.connected ||
|
||||
@@ -266,7 +257,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
try {
|
||||
realUrl = await HivemindService.fetchConfigDirectly(
|
||||
adNonce: adNonce,
|
||||
resume: resume,
|
||||
onAttempt: (attempt, total) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return;
|
||||
_setStatus(
|
||||
@@ -473,14 +463,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
final reachable = await HivemindService.checkHealth();
|
||||
if (_disposed || _status == VpnStatus.connected) return;
|
||||
_serverReachable = reachable;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
@@ -488,7 +470,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
_healthTimer?.cancel();
|
||||
_networkSubscription?.cancel();
|
||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
widget.onChanged?.call(next);
|
||||
}
|
||||
|
||||
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
||||
// session already held. No ad, no new session.
|
||||
// Recovers an orphaned proxy by ending the session and starting a new one,
|
||||
// exactly as the connect button does. Re-attaching to the session already
|
||||
// held was the one place a session began without an ad and without a nonce
|
||||
// to bind it to; a fresh one is the same path everywhere and hands back a
|
||||
// full window rather than the remainder of a broken one.
|
||||
Future<void> _reconnect() async {
|
||||
if (_reconnecting) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
final ad = context.read<AdManager>();
|
||||
|
||||
setState(() => _reconnecting = true);
|
||||
try {
|
||||
await timer.disconnect();
|
||||
if (!mounted) return;
|
||||
if (await vpn.connect(resume: true)) {
|
||||
final adNonce = await ad.showAd('main');
|
||||
if (adNonce == null && AdManager.adsEnabled) return;
|
||||
if (!mounted) return;
|
||||
if (await vpn.connect(adNonce: adNonce)) {
|
||||
if (!mounted) return;
|
||||
await timer.start(resetSupportClaim: false);
|
||||
await timer.start();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
+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.3.7+33
|
||||
version: 3.3.8+34
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
@@ -116,7 +116,7 @@ void main() {
|
||||
// /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 disconnectEntry = timer.indexOf('Future<void> disconnect({');
|
||||
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||
expect(release, greaterThan(disconnectEntry));
|
||||
@@ -131,4 +131,57 @@ void main() {
|
||||
reason: 'exactly one call site',
|
||||
);
|
||||
});
|
||||
|
||||
test('every session starts the same way - no re-attach path', () {
|
||||
final mode =
|
||||
File('lib/screens/settings/in_settings/connection_mode.dart')
|
||||
.readAsStringSync();
|
||||
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||
|
||||
// SOCKS5 Reconnect used to re-attach to the session already held. It was
|
||||
// the only place a session began without an ad and without a nonce binding
|
||||
// it, and it needed three parameters and a carve-out in rule 4 to support.
|
||||
// Reconnect now ends the session and starts a fresh one, like the button.
|
||||
expect(mode, contains("ad.showAd('main')"));
|
||||
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
|
||||
expect(mode, isNot(contains('resume: true')));
|
||||
expect(mode, isNot(contains('release: false')));
|
||||
expect(mode, isNot(contains('resetSupportClaim')));
|
||||
|
||||
// The special cases that only existed for it are gone.
|
||||
expect(hivemind, isNot(contains('bool resume')));
|
||||
expect(vpn, isNot(contains('bool resume')));
|
||||
expect(timer, isNot(contains('bool release')));
|
||||
expect(timer, isNot(contains('resetSupportClaim')));
|
||||
|
||||
// So the nonce is now unconditional: no path accepts whatever the server
|
||||
// happens to hold.
|
||||
expect(
|
||||
hivemind,
|
||||
contains('if (serverNonce is! String || serverNonce != nonce) return null;'),
|
||||
reason: 'the nonce check must not be skippable',
|
||||
);
|
||||
});
|
||||
|
||||
test('nothing probes the control plane while disconnected - rule 60', () {
|
||||
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
||||
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||
final bar = File('lib/components/status_bar.dart').readAsStringSync();
|
||||
|
||||
// The runtime excludes the app from its own tunnel
|
||||
// (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe
|
||||
// was a cleartext-SNI metronome to the API host - which resolves to the
|
||||
// tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3,
|
||||
// in exchange for a green dot before the user had connected to anything.
|
||||
expect(vpn, isNot(contains('_healthTimer')));
|
||||
expect(vpn, isNot(contains('_checkHealth')));
|
||||
expect(hivemind, isNot(contains('checkHealth')));
|
||||
expect(hivemind, isNot(contains("'/health'")));
|
||||
|
||||
// Reachability is passive now: a session sync that succeeded, nothing else.
|
||||
expect(bar, isNot(contains('serverReachable')));
|
||||
expect(bar, contains('isConnected && timer.hasSyncedOnce'));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user