feat(app): add secure routing and runtime resilience
Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup. Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
1 parent
371db8da9f
commit
8ead3ff249
30 files changed
+3959
-484
No files matched your search
+160
-50
@@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed }
|
||||
|
||||
enum InstallSource { playStore, sideload }
|
||||
|
||||
Uri? _trustedHttpsUri(String raw, Set<String> allowedHosts) {
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
class _ParsedVersion {
|
||||
final List<int> core;
|
||||
final List<String> preRelease;
|
||||
|
||||
const _ParsedVersion(this.core, this.preRelease);
|
||||
|
||||
static _ParsedVersion? tryParse(String input) {
|
||||
var value = input.trim();
|
||||
if (value.startsWith('v') || value.startsWith('V')) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
value = value.split('+').first;
|
||||
final dash = value.indexOf('-');
|
||||
final corePart = dash < 0 ? value : value.substring(0, dash);
|
||||
final prePart = dash < 0 ? '' : value.substring(dash + 1);
|
||||
final rawCore = corePart.split('.');
|
||||
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
||||
return null;
|
||||
}
|
||||
final core = rawCore.map(int.parse).toList(growable: false);
|
||||
while (core.length < 3) {
|
||||
core.add(0);
|
||||
}
|
||||
final pre = prePart.isEmpty
|
||||
? const <String>[]
|
||||
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
||||
return _ParsedVersion(core, pre);
|
||||
}
|
||||
|
||||
int compareTo(_ParsedVersion other) {
|
||||
final length = core.length > other.core.length ? core.length : other.core.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final left = i < core.length ? core[i] : 0;
|
||||
final right = i < other.core.length ? other.core[i] : 0;
|
||||
if (left != right) return left.compareTo(right);
|
||||
}
|
||||
|
||||
if (preRelease.isEmpty && other.preRelease.isEmpty) return 0;
|
||||
if (preRelease.isEmpty) return 1;
|
||||
if (other.preRelease.isEmpty) return -1;
|
||||
|
||||
final preLength = preRelease.length > other.preRelease.length
|
||||
? preRelease.length
|
||||
: other.preRelease.length;
|
||||
for (var i = 0; i < preLength; i++) {
|
||||
if (i >= preRelease.length) return -1;
|
||||
if (i >= other.preRelease.length) return 1;
|
||||
final left = preRelease[i];
|
||||
final right = other.preRelease[i];
|
||||
final leftNum = int.tryParse(left);
|
||||
final rightNum = int.tryParse(right);
|
||||
if (leftNum != null && rightNum != null) {
|
||||
if (leftNum != rightNum) return leftNum.compareTo(rightNum);
|
||||
continue;
|
||||
}
|
||||
if (leftNum != null) return -1;
|
||||
if (rightNum != null) return 1;
|
||||
final lexical = left.compareTo(right);
|
||||
if (lexical != 0) return lexical;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PlayStoreUpdater {
|
||||
PlayStoreUpdater._();
|
||||
|
||||
@@ -20,9 +92,13 @@ class PlayStoreUpdater {
|
||||
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
|
||||
|
||||
static Future<bool> open() async {
|
||||
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
||||
if (uri == null) {
|
||||
debugPrint('[Updater] Refusing invalid Play Store URL.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(_url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open Play Store: $e');
|
||||
return false;
|
||||
@@ -36,13 +112,14 @@ class GitHubUpdater {
|
||||
static String? _cachedDownloadUrl;
|
||||
|
||||
static Future<String?> fetchLatestVersion() async {
|
||||
final url =
|
||||
'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest';
|
||||
final apiUri = Uri.https(
|
||||
'api.github.com',
|
||||
'/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest',
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'})
|
||||
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
@@ -50,38 +127,48 @@ class GitHubUpdater {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_cachedDownloadUrl = data['html_url'] as String?;
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('[Updater] Invalid GitHub release payload.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = data['tag_name'] as String? ?? '0.0.0';
|
||||
return tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
final htmlUrl = decoded['html_url'];
|
||||
if (htmlUrl is String &&
|
||||
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) {
|
||||
_cachedDownloadUrl = htmlUrl;
|
||||
} else {
|
||||
_cachedDownloadUrl = 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;
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] GitHub API error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isTrustedReleaseUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
||||
static Future<bool> 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;
|
||||
}
|
||||
// Host alone is not enough: anyone can host a release on github.com.
|
||||
return uri.path
|
||||
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
|
||||
}
|
||||
|
||||
static Future<bool> open() async {
|
||||
final cached = _cachedDownloadUrl;
|
||||
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
||||
? cached
|
||||
: AppConfig.githubReleasesUrl;
|
||||
if (cached != null && url != cached) {
|
||||
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open GitHub: $e');
|
||||
return false;
|
||||
@@ -116,14 +203,13 @@ class Updater {
|
||||
}
|
||||
|
||||
static int _compareVersions(String a, String b) {
|
||||
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final diff = (aParts.length > i ? aParts[i] : 0) -
|
||||
(bParts.length > i ? bParts[i] : 0);
|
||||
if (diff != 0) return diff;
|
||||
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;
|
||||
}
|
||||
return 0;
|
||||
return left.compareTo(right);
|
||||
}
|
||||
|
||||
static String _versionLabel(String v, bool isLocal) {
|
||||
@@ -131,15 +217,27 @@ class Updater {
|
||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||
}
|
||||
|
||||
static Future<void> check({
|
||||
static Future<void> check(BuildContext context) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
return checkWithHandles(
|
||||
navigator: navigator,
|
||||
messenger: messenger,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> checkWithHandles({
|
||||
required NavigatorState navigator,
|
||||
required ScaffoldMessengerState messenger,
|
||||
}) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersion = info.version;
|
||||
if (localVersion.isEmpty) {
|
||||
_showSnackBar(messenger, 'Could not determine app version',
|
||||
isError: true);
|
||||
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
||||
_showSnackBar(
|
||||
messenger,
|
||||
'Could not determine app version',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,15 +253,18 @@ class Updater {
|
||||
}
|
||||
|
||||
final source = await installSource;
|
||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
final storeName =
|
||||
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
if (!navigator.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.bgCard,
|
||||
title: const Text('Update available',
|
||||
style: TextStyle(color: AppColors.textWhite)),
|
||||
title: const Text(
|
||||
'Update available',
|
||||
style: TextStyle(color: AppColors.textWhite),
|
||||
),
|
||||
content: Text(
|
||||
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
||||
'You have ${_versionLabel(localVersion, true)}.',
|
||||
@@ -172,8 +273,10 @@ class Updater {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later',
|
||||
style: TextStyle(color: AppColors.slate70)),
|
||||
child: const Text(
|
||||
'Later',
|
||||
style: TextStyle(color: AppColors.slate70),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -182,24 +285,31 @@ class Updater {
|
||||
? PlayStoreUpdater.open()
|
||||
: GitHubUpdater.open();
|
||||
},
|
||||
child: Text('Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent)),
|
||||
child: Text(
|
||||
'Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
||||
{bool isError = false}) {
|
||||
static void _showSnackBar(
|
||||
ScaffoldMessengerState messenger,
|
||||
String message, {
|
||||
bool isError = false,
|
||||
}) {
|
||||
if (!messenger.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18),
|
||||
Icon(
|
||||
isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(message),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user