import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart' show MethodChannel, PlatformException, MissingPluginException; import '../app/app_version.dart'; class UpdateCheckException implements Exception { const UpdateCheckException(this.message); final String message; } Uri? updateDownloadUri(String value) { if (value.length > 2048 || RegExp(r'[\s\x00-\x1f\x7f]').hasMatch(value)) { return null; } final uri = Uri.tryParse(value); return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty && uri.userInfo.isEmpty ? uri : null; } class AppRelease { const AppRelease({ required this.version, required this.buildNumber, required this.notes, required this.downloadUri, }); final String version, notes; final int buildNumber; final Uri downloadUri; } class UpdateHttpResponse { const UpdateHttpResponse(this.status, this.body); final int status; final List body; } abstract interface class UpdateTransport { Future get(Uri endpoint); } class IoUpdateTransport implements UpdateTransport { const IoUpdateTransport(); @override Future get(Uri endpoint) async { final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); Future fetch() async { final request = await client.getUrl(endpoint); request.followRedirects = false; request.headers.set('Accept', 'application/json'); request.headers.set('Cache-Control', 'no-cache'); final response = await request.close(); final bytes = []; await for (final chunk in response) { if (bytes.length + chunk.length > AppUpdateService.maxBytes) { throw const UpdateCheckException('更新信息过大,请稍后重试'); } bytes.addAll(chunk); } return UpdateHttpResponse(response.statusCode, bytes); } try { return await fetch().timeout(const Duration(seconds: 12)); } finally { client.close(force: true); } } } class AppUpdateService { AppUpdateService({ this.transport = const IoUpdateTransport(), this.currentBuild = appBuildNumber, this.endpoint = const String.fromEnvironment( 'UPDATE_ENDPOINT', defaultValue: 'https://app.glowtps.cn/v1/updates/android', ), }); static const maxBytes = 64 * 1024; final UpdateTransport transport; final int currentBuild; final String endpoint; Future? _pending; Future check() { if (_pending != null) return _pending!; final future = _check(); _pending = future; // Attach error handling here too, so cleanup never creates an unhandled // future when startup intentionally suppresses an unavailable server. unawaited( future.then( (_) { _pending = null; }, onError: (Object _, StackTrace _) { _pending = null; }, ), ); return future; } Future _check() async { try { final uri = updateDownloadUri(endpoint); if (uri == null || uri.fragment.isNotEmpty) { throw const UpdateCheckException('更新服务地址无效'); } final response = await transport .get(uri) .timeout(const Duration(seconds: 13)); if (response.status != 200 || response.body.length > maxBytes) { throw const UpdateCheckException('暂时无法获取更新,请稍后重试'); } final data = jsonDecode(utf8.decode(response.body)); if (data is! Map || data['schema_version'] != 1 || data['application_id'] != 'com.tpschedule.app' || !data.containsKey('latest')) { throw const FormatException(); } final latest = data['latest']; if (latest == null) return null; if (latest is! Map) throw const FormatException(); final version = latest['version'], build = latest['build_number'], notes = latest['notes'], url = latest['download_url']; if (version is! String || !RegExp(r'^\d{1,4}\.\d{1,4}\.\d{1,4}$').hasMatch(version) || build is! int || build < 1 || build > 2100000000 || notes is! String || notes.trim().isEmpty || notes.runes.length > 8000 || url is! String) { throw const FormatException(); } final download = updateDownloadUri(url); if (download == null) throw const FormatException(); if (build <= currentBuild) return null; return AppRelease( version: version, buildNumber: build, notes: notes.trim(), downloadUri: download, ); } on UpdateCheckException { rethrow; } on FormatException { throw const UpdateCheckException('更新信息格式异常,请稍后重试'); } catch (_) { throw const UpdateCheckException('无法连接更新服务,请检查网络后重试'); } } } typedef UpdateDownloadOpener = Future Function(Uri uri); Future openUpdateInBrowser(Uri uri) async { if (updateDownloadUri(uri.toString()) == null) return false; try { return await const MethodChannel('com.tpschedule.app/updates') .invokeMethod('openDownload', {'url': uri.toString()}) ?? false; } on PlatformException { return false; } on MissingPluginException { return false; } }