183 lines
5.4 KiB
Dart
183 lines
5.4 KiB
Dart
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<int> body;
|
|
}
|
|
|
|
abstract interface class UpdateTransport {
|
|
Future<UpdateHttpResponse> get(Uri endpoint);
|
|
}
|
|
|
|
class IoUpdateTransport implements UpdateTransport {
|
|
const IoUpdateTransport();
|
|
@override
|
|
Future<UpdateHttpResponse> get(Uri endpoint) async {
|
|
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
|
Future<UpdateHttpResponse> 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 = <int>[];
|
|
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<AppRelease?>? _pending;
|
|
|
|
Future<AppRelease?> 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<void>(
|
|
(_) {
|
|
_pending = null;
|
|
},
|
|
onError: (Object _, StackTrace _) {
|
|
_pending = null;
|
|
},
|
|
),
|
|
);
|
|
return future;
|
|
}
|
|
|
|
Future<AppRelease?> _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<bool> Function(Uri uri);
|
|
|
|
Future<bool> openUpdateInBrowser(Uri uri) async {
|
|
if (updateDownloadUri(uri.toString()) == null) return false;
|
|
try {
|
|
return await const MethodChannel('com.tpschedule.app/updates')
|
|
.invokeMethod<bool>('openDownload', {'url': uri.toString()}) ??
|
|
false;
|
|
} on PlatformException {
|
|
return false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
}
|
|
}
|