feat: add app update checks and standalone release publishing panel

This commit is contained in:
Codex
2026-10-24 13:50:42 +08:00
parent 437ed14dbe
commit 75e0b5587d
27 changed files with 1566 additions and 7 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.5';
const appBuildNumber = 23;
const appVersion = '0.7.6';
const appBuildNumber = 24;
+61
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -18,6 +20,8 @@ import '../features/school/school_selection_page.dart';
import '../features/school/undergraduate/session.dart';
import '../features/school/undergraduate/exam_query_picker.dart';
import '../domain/undergraduate_exam_query.dart';
import '../services/app_update_service.dart';
import '../features/updates/update_prompt.dart';
class HepanApp extends StatelessWidget {
const HepanApp({
@@ -29,6 +33,8 @@ class HepanApp extends StatelessWidget {
this.undergraduateBuilder,
this.notificationSelection,
this.feedbackSender,
this.updateService,
this.openUpdateDownload = openUpdateInBrowser,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -37,6 +43,8 @@ class HepanApp extends StatelessWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -53,6 +61,8 @@ class HepanApp extends StatelessWidget {
undergraduateBuilder: undergraduateBuilder,
notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
updateService: updateService,
openUpdateDownload: openUpdateDownload,
),
);
}
@@ -66,6 +76,8 @@ class _Shell extends StatefulWidget {
this.undergraduateBuilder,
this.notificationSelection,
required this.feedbackSender,
this.updateService,
required this.openUpdateDownload,
});
final AppController controller;
final Widget Function()? schoolBuilder;
@@ -74,6 +86,8 @@ class _Shell extends StatefulWidget {
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender;
final AppUpdateService? updateService;
final UpdateDownloadOpener openUpdateDownload;
@override
State<_Shell> createState() => _ShellState();
}
@@ -81,6 +95,40 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0;
bool _openingSchool = false;
bool _checkingUpdate = false;
bool _updatePromptOpen = false;
Timer? _startupUpdate;
Future<void> _checkUpdate({bool silent = false}) async {
if (_checkingUpdate || _updatePromptOpen || widget.updateService == null) {
return;
}
setState(() => _checkingUpdate = true);
try {
final release = await widget.updateService!.check();
if (mounted) setState(() => _checkingUpdate = false);
if (!mounted || ModalRoute.of(context)?.isCurrent != true) return;
if (release != null) {
_updatePromptOpen = true;
await showUpdatePrompt(
context,
release,
openDownload: widget.openUpdateDownload,
);
} else if (!silent) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('当前已是最新版本')));
}
} on UpdateCheckException catch (error) {
if (!silent && mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(error.message)));
}
} finally {
_updatePromptOpen = false;
if (mounted) setState(() => _checkingUpdate = false);
}
}
// Restore the saved school; legacy graduate imports infer their source.
SupportedSchool? get _knownSchool =>
widget.controller.snapshot.selectedSchool ??
@@ -102,10 +150,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
widget.notificationSelection?.addListener(_selectNotification);
WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification());
if (widget.updateService != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_startupUpdate = Timer(const Duration(seconds: 2), () {
if (mounted) unawaited(_checkUpdate(silent: true));
});
});
}
}
@override
void dispose() {
_startupUpdate?.cancel();
WidgetsBinding.instance.removeObserver(this);
widget.notificationSelection?.removeListener(_selectNotification);
super.dispose();
@@ -400,6 +457,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
ProfilePage(
controller: widget.controller,
feedbackSender: widget.feedbackSender,
onCheckUpdates: widget.updateService == null
? null
: () => _checkUpdate(),
checkingUpdates: _checkingUpdate,
onLogin: () => _school(selectSchool: true),
schoolName: _knownSchool?.label,
onUpdateTerm: () => _school(chooseTerm: true),
@@ -24,6 +24,8 @@ class ProfilePage extends StatelessWidget {
this.logoutLabel = '退出学校登录',
this.onUpdateTerm,
this.feedbackSender = const UnconfiguredFeedbackSender(),
this.onCheckUpdates,
this.checkingUpdates = false,
});
final AppController controller;
final VoidCallback onLogin;
@@ -32,6 +34,8 @@ class ProfilePage extends StatelessWidget {
final VoidCallback? onUpdateTerm;
final String authStatus, logoutLabel;
final FeedbackSender feedbackSender;
final VoidCallback? onCheckUpdates;
final bool checkingUpdates;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
@@ -231,6 +235,32 @@ class ProfilePage extends StatelessWidget {
),
),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
ListTile(
key: const Key('check-app-update'),
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.system_update_rounded,
color: river,
size: 23,
),
title: const Text(
'检查更新',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
checkingUpdates ? '正在检查…' : '当前版本 $appVersion',
style: const TextStyle(fontSize: 11),
),
trailing: checkingUpdates
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: checkingUpdates ? null : onCheckUpdates,
),
],
),
),
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../../app/app_version.dart';
import '../../services/app_update_service.dart';
Future<void> showUpdatePrompt(
BuildContext context,
AppRelease release, {
UpdateDownloadOpener openDownload = openUpdateInBrowser,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('发现新版本 ${release.version}'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前版本 $appVersion', style: TextStyle(fontSize: 12)),
const SizedBox(height: 16),
Text(release.notes, style: const TextStyle(height: 1.6)),
const SizedBox(height: 16),
const Text(
'将打开浏览器下载,安装时请保留现有应用和课表。',
style: TextStyle(fontSize: 12),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('稍后'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('立即更新'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
bool opened;
try {
opened = await openDownload(release.downloadUri);
} catch (_) {
opened = false;
}
if (!opened && context.mounted) {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('未能打开浏览器'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('可以复制下方链接到浏览器下载:'),
const SizedBox(height: 12),
SelectableText(release.downloadUri.toString()),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
],
),
);
}
}
+4
View File
@@ -7,6 +7,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart';
import 'app/app_version.dart';
import 'services/diagnostic_log.dart';
import 'services/app_update_service.dart';
import 'app/hepan_app.dart';
import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart';
@@ -22,11 +23,13 @@ Widget buildApplication(
ValueNotifier<String?>? notificationSelection,
SchoolSession? school,
UndergraduateSession? undergraduate,
AppUpdateService? updateService,
}) => HepanApp(
controller: controller,
notificationSelection: notificationSelection,
school: school,
undergraduate: undergraduate,
updateService: updateService,
undergraduateBuilder: undergraduate == null
? null
: () => UndergraduateAuthPage(
@@ -63,6 +66,7 @@ Future<void> main() async {
notificationSelection: selection,
school: SchoolSession(),
undergraduate: UndergraduateSession(),
updateService: AppUpdateService(),
);
},
onHomeReady: () {
+182
View File
@@ -0,0 +1,182 @@
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;
}
}