feat: add app update checks and standalone release publishing panel
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.7.5';
|
||||
const appBuildNumber = 23;
|
||||
const appVersion = '0.7.6';
|
||||
const appBuildNumber = 24;
|
||||
|
||||
@@ -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('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user