diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index e1f3d8a..a09a1fb 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -13,6 +13,7 @@ class HivemindService { static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36'; + static const int _maxControlResponseBytes = 256 * 1024; static const _pollBackoff = [ Duration(seconds: 1), Duration(seconds: 2), @@ -27,7 +28,58 @@ class HivemindService { return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); } - static void cancel() => _currentCallId++; + static Future controlGet( + Uri uri, { + Duration timeout = const Duration(seconds: 5), + }) async { + final client = http.Client(); + final request = http.Request('GET', uri) + ..headers['User-Agent'] = _ua + ..followRedirects = false; + + Future read() 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 = []; + 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, + isRedirect: streamed.isRedirect, + persistentConnection: streamed.persistentConnection, + reasonPhrase: streamed.reasonPhrase, + request: request, + ); + } + + try { + return await read().timeout( + timeout, + onTimeout: () { + client.close(); + throw TimeoutException('Control request timed out.', timeout); + }, + ); + } finally { + client.close(); + } + } + + static void cancel() { + _currentCallId++; + _expectedNonce = null; + } static void setExpectedNonce(String nonce) { _expectedNonce = nonce; @@ -37,13 +89,19 @@ class HivemindService { void Function(int attempt, int total)? onAttempt, bool skipAdBypass = false, }) async { - final deviceId = await CryptoService.getDeviceId(); + // Capture the generation before the first await. Otherwise a disconnect + // that lands while secure storage is resolving the device ID can be lost + // and the cancelled call can resume network work with a fresh generation. final callId = ++_currentCallId; + final deviceId = await CryptoService.getDeviceId(); + _throwIfCancelled(callId); + final nonce = _newNonce(); _expectedNonce = nonce; if (!skipAdBypass) { await _runConfiguredBypass(deviceId, nonce); + _throwIfCancelled(callId); } const maxAttempts = 5; @@ -53,6 +111,7 @@ class HivemindService { try { final session = await _fetchActiveSession(deviceId, nonce); + _throwIfCancelled(callId); if (session != null) { _expectedNonce = null; return session.toVlessUrl(); @@ -73,7 +132,7 @@ class HivemindService { static Future checkHealth() async { try { - final response = await directGet( + final response = await controlGet( _publicUrl('/health'), timeout: const Duration(seconds: 3), ); @@ -109,15 +168,15 @@ class HivemindService { final statusUri = _publicUrl('/session/status').replace( queryParameters: {'device_id': deviceId}, ); - final response = await directGet(statusUri); + final response = await controlGet(statusUri); if (response.statusCode != 200) return null; final decoded = jsonDecode(response.body); if (decoded is! Map) return null; final serverNonce = decoded['nonce']; - if (_expectedNonce != null && - serverNonce is String && + if (_expectedNonce != nonce || + serverNonce is! String || serverNonce != nonce) { return null; } @@ -141,6 +200,13 @@ class HivemindService { } class _HivemindSessionConfig { + static final RegExp _uuidPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + static final RegExp _shortIdPattern = + RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false); + final String uuid; final String host; final int port; @@ -162,50 +228,174 @@ class _HivemindSessionConfig { }); factory _HivemindSessionConfig.fromJson(Map json) { - final uuid = _requiredString(json, 'vless_uuid'); - final host = _stringOr(json['vless_ip'], AppConfig.serverIp); - final port = _portOr(json['vless_port'], 443); - final sni = _requiredString(json, 'reality_sni'); + final uuid = _requiredString(json, 'vless_uuid', maxLength: 64); + if (!_uuidPattern.hasMatch(uuid)) { + throw const FormatException('Invalid VLESS UUID'); + } + + // The API may rotate credentials and Reality parameters, but it must never + // choose the tunnel destination. Keep that trust anchor compiled into the + // app so an API/domain compromise can cause denial of service, not redirect + // VPN traffic to an attacker-controlled server. + final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp'); + final advertisedHost = json['vless_ip']; + if (advertisedHost != null) { + if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) { + throw const FormatException( + 'Session VLESS host does not match compiled server pin', + ); + } + } + final host = pinnedHost; + + final advertisedPort = json['vless_port']; + if (advertisedPort != null && + (advertisedPort is! num || advertisedPort.toInt() != 443)) { + throw const FormatException('Session VLESS port does not match compiled pin'); + } + const port = 443; + final sni = _validatedHost( + _requiredString(json, 'reality_sni', maxLength: 253), + 'reality_sni', + ); + final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128); + final shortId = _stringOr(json['reality_sid'], '').trim(); + if (!_shortIdPattern.hasMatch(shortId)) { + throw const FormatException('Invalid Reality short ID'); + } + + final fingerprint = + _boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp'); + final path = + _boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path'); + if (!path.startsWith('/')) { + throw const FormatException('Invalid XHTTP path'); + } return _HivemindSessionConfig( uuid: uuid, host: host, port: port, - publicKey: _stringOr(json['reality_pbk'], ''), - shortId: _stringOr(json['reality_sid'], ''), + publicKey: publicKey, + shortId: shortId, sni: sni, - fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp), - path: _stringOr(json['xhttp_path'], AppConfig.vlessPath), + fingerprint: fingerprint, + path: path, ); } String toVlessUrl() { - return 'vless://$uuid@$host:$port' - '?security=${AppConfig.vlessSecurity}' - '&type=${AppConfig.vlessType}' - '&path=$path' - '&pbk=${Uri.encodeComponent(publicKey)}' - '&sni=${Uri.encodeComponent(sni)}' - '&sid=${Uri.encodeComponent(shortId)}' - '&fp=${Uri.encodeComponent(fingerprint)}' - '#Revolt VPN'; + return Uri( + scheme: 'vless', + userInfo: uuid, + host: host, + port: port, + queryParameters: { + 'security': AppConfig.vlessSecurity, + 'type': AppConfig.vlessType, + 'path': path, + 'pbk': publicKey, + 'sni': sni, + 'sid': shortId, + 'fp': fingerprint, + }, + fragment: 'Revolt VPN', + ).toString(); } - static String _requiredString(Map json, String key) { + static String _requiredString( + Map json, + String key, { + int maxLength = 4096, + }) { final value = json[key]; - if (value is String && value.trim().isNotEmpty) return value.trim(); - throw FormatException('Missing required session field: $key'); + if (value is! String) { + throw FormatException('Missing required session field: $key'); + } + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed.length > maxLength) { + throw FormatException('Invalid session field: $key'); + } + return trimmed; } static String _stringOr(Object? value, String fallback) { - return value is String && value.isNotEmpty ? value : fallback; + return value is String && value.trim().isNotEmpty ? value.trim() : fallback; } - static int _portOr(Object? value, int fallback) { - final parsed = value is num ? value.toInt() : fallback; - if (parsed <= 0 || parsed > 65535) { - throw const FormatException('Invalid VLESS port'); + static String _boundedString( + Object? value, + String fallback, + int maxLength, + String field, + ) { + final result = _stringOr(value, fallback); + if (result.isEmpty || result.length > maxLength) { + throw FormatException('Invalid session field: $field'); } - return parsed; + return result; + } + + static String _validatedHost(String input, String field) { + final value = input.trim(); + if (value.isEmpty || + value.length > 253 || + value.contains(RegExp(r'[\s/@?#]'))) { + throw FormatException('Invalid session host: $field'); + } + try { + final probe = Uri(scheme: 'https', host: value); + if (probe.host.isEmpty) { + throw FormatException('Invalid session host: $field'); + } + } on FormatException { + throw FormatException('Invalid session host: $field'); + } + return value; + } + + static String _validatedPublicIp(String input, String field) { + final value = input.trim().toLowerCase(); + if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value; + throw FormatException('Invalid public IP pin: $field'); + } + + static bool _isPublicIpv4(String value) { + final parts = value.split('.'); + if (parts.length != 4) return false; + final octets = []; + for (final part in parts) { + if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false; + final parsed = int.tryParse(part); + if (parsed == null || parsed < 0 || parsed > 255) return false; + octets.add(parsed); + } + final a = octets[0]; + final b = octets[1]; + if (a == 0 || a == 10 || a == 127 || a >= 224) return false; + if (a == 100 && b >= 64 && b <= 127) return false; + if (a == 169 && b == 254) return false; + if (a == 172 && b >= 16 && b <= 31) return false; + if (a == 192 && b == 168) return false; + if (a == 192 && b == 0 && octets[2] == 2) return false; + if (a == 198 && b == 51 && octets[2] == 100) return false; + if (a == 203 && b == 0 && octets[2] == 113) return false; + return true; + } + + static bool _isPublicIpv6(String value) { + if (!value.contains(':') || value.contains('.')) return false; + try { + final parsed = Uri.parse('https://[$value]/'); + if (parsed.host.isEmpty || !parsed.host.contains(':')) return false; + } on FormatException { + return false; + } + if (value == '::' || value == '::1') return false; + if (value.startsWith('fc') || value.startsWith('fd')) return false; + if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false; + if (value.startsWith('ff')) return false; + if (value.startsWith('2001:db8:') || value == '2001:db8::') return false; + return true; } } diff --git a/lib/logic/local_socks_tester.dart b/lib/logic/local_socks_tester.dart index 6e405bc..b672e88 100644 --- a/lib/logic/local_socks_tester.dart +++ b/lib/logic/local_socks_tester.dart @@ -20,13 +20,13 @@ abstract final class LocalSocksTester { /// Fast local readiness probe used while starting the VPN. /// - /// This stops after SOCKS5 authentication. Startup must not depend on an - /// unrelated external host being reachable. + /// This stops after authenticated SOCKS5 negotiation. Startup must not depend + /// on an unrelated external host being reachable. static Future testListener({ String host = '127.0.0.1', - int port = 10807, - String? username, - String? password, + required int port, + required String username, + required String password, }) async { final stopwatch = Stopwatch()..start(); Socket? socket; @@ -86,26 +86,40 @@ abstract final class LocalSocksTester { } } - /// Full user-facing test: authenticated local SOCKS handshake plus outbound - /// CONNECT. Credentials are optional so the helper remains usable for legacy - /// no-auth diagnostic builds. + /// Full user-facing test: authenticated local SOCKS handshake plus an + /// outbound TCP CONNECT through the active ReVolt route. static Future test({ String host = '127.0.0.1', - int port = 10807, - String? username, - String? password, - String targetHost = 'paladinvpn.duckdns.org', + required int port, + required String username, + required String password, + String targetHost = 'example.com', int targetPort = 443, }) async { + const totalTimeout = Duration(seconds: 8); final stopwatch = Stopwatch()..start(); Socket? socket; _SocketReader? reader; + Duration remaining() { + final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds; + if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout); + return Duration(microseconds: micros); + } + try { + if (targetPort <= 0 || targetPort > 65535) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'SOCKS5 test target is invalid.', + ); + } + socket = await Socket.connect( host, port, - timeout: const Duration(seconds: 3), + timeout: remaining(), ); socket.setOption(SocketOption.tcpNoDelay, true); reader = _SocketReader(socket); @@ -115,6 +129,7 @@ abstract final class LocalSocksTester { socket, username: username, password: password, + timeout: remaining(), ); if (!authenticated) { return const LocalSocksTestResult( @@ -145,7 +160,7 @@ abstract final class LocalSocksTester { ]); await socket.flush(); - final replyHead = await reader.readExactly(4); + final replyHead = await reader.readExactly(4, timeout: remaining()); if (replyHead[0] != 0x05 || replyHead[1] != 0x00) { return const LocalSocksTestResult( ok: false, @@ -155,7 +170,7 @@ abstract final class LocalSocksTester { ); } - await _consumeAddress(reader, replyHead[3]); + await _consumeAddress(reader, replyHead[3], timeout: remaining()); stopwatch.stop(); return LocalSocksTestResult( ok: true, @@ -189,23 +204,10 @@ abstract final class LocalSocksTester { static Future _authenticate( _SocketReader reader, Socket socket, { - required String? username, - required String? password, + required String username, + required String password, Duration timeout = const Duration(seconds: 4), }) async { - final passwordAuth = username != null && password != null; - if ((username == null) != (password == null)) return false; - - socket.add(passwordAuth - ? const [0x05, 0x01, 0x02] - : const [0x05, 0x01, 0x00]); - await socket.flush(); - - final greeting = await reader.readExactly(2, timeout: timeout); - if (greeting[0] != 0x05) return false; - if (!passwordAuth) return greeting[1] == 0x00; - if (greeting[1] != 0x02) return false; - final userBytes = utf8.encode(username); final passBytes = utf8.encode(password); if (userBytes.isEmpty || @@ -215,6 +217,15 @@ abstract final class LocalSocksTester { return false; } + // ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC + // 1929 username/password auth makes a future credential-plumbing regression + // fail closed instead of silently falling back to method 0x00. + socket.add(const [0x05, 0x01, 0x02]); + await socket.flush(); + + final greeting = await reader.readExactly(2, timeout: timeout); + if (greeting[0] != 0x05 || greeting[1] != 0x02) return false; + socket.add([ 0x01, userBytes.length, @@ -229,18 +240,24 @@ abstract final class LocalSocksTester { } static Future _consumeAddress( - _SocketReader reader, int addressType) async { + _SocketReader reader, + int addressType, { + required Duration timeout, + }) async { if (addressType == 0x01) { - await reader.readExactly(4 + 2); + await reader.readExactly(4 + 2, timeout: timeout); return; } if (addressType == 0x04) { - await reader.readExactly(16 + 2); + await reader.readExactly(16 + 2, timeout: timeout); return; } if (addressType == 0x03) { - final length = (await reader.readExactly(1)).first; - await reader.readExactly(length + 2); + final length = (await reader.readExactly(1, timeout: timeout)).first; + if (length == 0) { + throw const FormatException('Invalid SOCKS5 domain length'); + } + await reader.readExactly(length + 2, timeout: timeout); return; } throw const FormatException('Unsupported SOCKS5 address type'); @@ -257,8 +274,15 @@ class _SocketReader { int count, { Duration timeout = const Duration(seconds: 4), }) async { + final elapsed = Stopwatch()..start(); while (_buffer.length < count) { - final hasNext = await _iterator.moveNext().timeout(timeout); + final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds; + if (remainingMicros <= 0) { + throw TimeoutException('Socket read timed out', timeout); + } + final hasNext = await _iterator.moveNext().timeout( + Duration(microseconds: remainingMicros), + ); if (!hasNext) { throw const SocketException('Socket closed early'); } diff --git a/lib/logic/secure_socks_session.dart b/lib/logic/secure_socks_session.dart index 2a88247..8c31a31 100644 --- a/lib/logic/secure_socks_session.dart +++ b/lib/logic/secure_socks_session.dart @@ -39,40 +39,29 @@ class SecureSocksSession { final username = _token(16); final password = _token(32); - final sourceInbounds = decoded['inbounds']; - final inbounds = []; - if (sourceInbounds is List) { - for (final value in sourceInbounds) { - if (value is Map) { - final protocol = value['protocol']?.toString().toLowerCase(); - // ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP - // listeners so a stale no-auth proxy cannot remain reachable beside - // the authenticated session listener. - if (protocol == 'socks' || protocol == 'http') continue; - } - inbounds.add(value); - } - } - - inbounds.add({ - 'tag': inboundTag, - 'port': port, - 'listen': '127.0.0.1', - 'protocol': 'socks', - 'settings': { - 'auth': 'password', - 'udp': true, - 'ip': '127.0.0.1', - 'users': >[ - {'user': username, 'pass': password}, - ], + // ReVolt owns local ingress. Never accept listeners supplied by a remote + // or imported Xray config; the client exposes exactly one authenticated + // loopback SOCKS5 listener for the current ephemeral session. + decoded['inbounds'] = >[ + { + 'tag': inboundTag, + 'port': port, + 'listen': '127.0.0.1', + 'protocol': 'socks', + 'settings': { + 'auth': 'password', + 'udp': true, + 'ip': '127.0.0.1', + 'users': >[ + {'user': username, 'pass': password}, + ], + }, + 'sniffing': { + 'enabled': true, + 'destOverride': ['http', 'tls'], + }, }, - 'sniffing': { - 'enabled': true, - 'destOverride': ['http', 'tls'], - }, - }); - decoded['inbounds'] = inbounds; + ]; return SecureSocksSession._( port: port, diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart index f3e55bb..6094896 100644 --- a/lib/logic/updater.dart +++ b/lib/logic/updater.dart @@ -1,15 +1,14 @@ import 'dart:convert'; import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/app_colors.dart'; - -enum UpdateStatus { upToDate, updateAvailable, checkFailed } +import 'package:revoltvpn/logic/app_config.dart'; +import 'package:url_launcher/url_launcher.dart'; enum InstallSource { playStore, sideload } @@ -40,10 +39,12 @@ class _ParsedVersion { if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) { return null; } - final core = rawCore.map(int.parse).toList(growable: false); + + final core = rawCore.map(int.parse).toList(); while (core.length < 3) { core.add(0); } + final pre = prePart.isEmpty ? const [] : prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false); @@ -93,10 +94,7 @@ class PlayStoreUpdater { static Future open() async { final uri = _trustedHttpsUri(_url, const {'play.google.com'}); - if (uri == null) { - debugPrint('[Updater] Refusing invalid Play Store URL.'); - return false; - } + if (uri == null) return false; try { return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (e) { @@ -121,38 +119,24 @@ class GitHubUpdater { final response = await http .get(apiUri, headers: {'Accept': 'application/vnd.github+json'}) .timeout(const Duration(seconds: 8)); - - if (response.statusCode != 200) { - debugPrint('[Updater] GitHub API returned ${response.statusCode}'); - return null; - } + if (response.statusCode != 200) return null; final decoded = jsonDecode(response.body); - if (decoded is! Map) { - debugPrint('[Updater] Invalid GitHub release payload.'); - return null; - } + if (decoded is! Map) return null; final htmlUrl = decoded['html_url']; - if (htmlUrl is String && - _trustedHttpsUri(htmlUrl, const {'github.com'}) != null) { - _cachedDownloadUrl = htmlUrl; - } else { - _cachedDownloadUrl = null; - } + _cachedDownloadUrl = htmlUrl is String && + _trustedHttpsUri(htmlUrl, const {'github.com'}) != null + ? htmlUrl + : null; final rawTag = decoded['tag_name']; if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) { - debugPrint('[Updater] Invalid release tag.'); return null; } final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag; - if (_ParsedVersion.tryParse(normalized) == null) { - debugPrint('[Updater] Release tag is not a supported version: $rawTag'); - return null; - } - return normalized; + return _ParsedVersion.tryParse(normalized) == null ? null : normalized; } catch (e) { debugPrint('[Updater] GitHub API error: $e'); return null; @@ -162,11 +146,7 @@ class GitHubUpdater { static Future open() async { final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl; final uri = _trustedHttpsUri(raw, const {'github.com'}); - if (uri == null) { - debugPrint('[Updater] Refusing untrusted GitHub update URL.'); - return false; - } - + if (uri == null) return false; try { return await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (e) { @@ -179,36 +159,32 @@ class GitHubUpdater { class Updater { Updater._(); + static const MethodChannel _installerChannel = + MethodChannel('com.revoltvpn.app/installer'); static InstallSource? _cachedSource; static Future get installSource async { if (_cachedSource != null) return _cachedSource!; if (kIsWeb || !Platform.isAndroid) { - _cachedSource = InstallSource.sideload; - return _cachedSource!; + return _cachedSource = InstallSource.sideload; } try { - final result = await MethodChannel('com.revoltvpn.app/installer') - .invokeMethod('getInstallerPackage'); - _cachedSource = (result == 'com.android.vending') + final result = + await _installerChannel.invokeMethod('getInstallerPackage'); + return _cachedSource = result == 'com.android.vending' ? InstallSource.playStore : InstallSource.sideload; } catch (e) { debugPrint('[Updater] Installer check failed: $e'); - _cachedSource = InstallSource.sideload; + return _cachedSource = InstallSource.sideload; } - - return _cachedSource!; } static int _compareVersions(String a, String b) { final left = _ParsedVersion.tryParse(a); final right = _ParsedVersion.tryParse(b); - if (left == null || right == null) { - // Invalid local metadata should not invent an ordering. - return 0; - } + if (left == null || right == null) return 0; return left.compareTo(right); } @@ -218,11 +194,9 @@ class Updater { } static Future check(BuildContext context) { - final navigator = Navigator.of(context, rootNavigator: true); - final messenger = ScaffoldMessenger.of(context); return checkWithHandles( - navigator: navigator, - messenger: messenger, + navigator: Navigator.of(context, rootNavigator: true), + messenger: ScaffoldMessenger.of(context), ); } @@ -230,14 +204,46 @@ class Updater { required NavigatorState navigator, required ScaffoldMessengerState messenger, }) async { + final source = await installSource; + if (source == InstallSource.playStore) { + if (!navigator.mounted) return; + await showDialog( + context: navigator.context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.bgCard, + title: const Text( + 'Updates are managed by Play Store', + style: TextStyle(color: AppColors.textWhite), + ), + content: const Text( + 'Open Play Store to check whether an update is available for this install.', + style: TextStyle(color: AppColors.slate70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + PlayStoreUpdater.open(); + }, + child: const Text( + 'Open Play Store', + style: TextStyle(color: AppColors.accent), + ), + ), + ], + ), + ); + return; + } + final info = await PackageInfo.fromPlatform(); final localVersion = info.version; if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) { - _showSnackBar( - messenger, - 'Could not determine app version', - isError: true, - ); + _showSnackBar(messenger, 'Could not determine app version', isError: true); return; } @@ -252,12 +258,8 @@ class Updater { return; } - final source = await installSource; - final storeName = - source == InstallSource.playStore ? 'Play Store' : 'GitHub'; if (!navigator.mounted) return; - - showDialog( + await showDialog( context: navigator.context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.bgCard, @@ -266,7 +268,7 @@ class Updater { style: TextStyle(color: AppColors.textWhite), ), content: Text( - '${_versionLabel(latestVersion, false)} is available on $storeName.\n' + '${_versionLabel(latestVersion, false)} is available on GitHub.\n' 'You have ${_versionLabel(localVersion, true)}.', style: const TextStyle(color: AppColors.slate70), ), @@ -281,13 +283,11 @@ class Updater { TextButton( onPressed: () { Navigator.pop(ctx); - source == InstallSource.playStore - ? PlayStoreUpdater.open() - : GitHubUpdater.open(); + GitHubUpdater.open(); }, - child: Text( - 'Open $storeName', - style: const TextStyle(color: AppColors.accent), + child: const Text( + 'Open GitHub', + style: TextStyle(color: AppColors.accent), ), ), ],