Files
TPSchedule/mobile/lib/services/app_update_service.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;
}
}