diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index 21ecad4..b490e9b 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; @@ -26,9 +27,18 @@ class BackupPage extends StatefulWidget { class _BackupPageState extends State { late final ConfigArchive _archive = widget.runtime.backupArchive; + Completer? _cancelled; + StreamIterator? _importStream; bool _includeAccount = false; bool _busy = false; bool _waitingForReload = false; + bool get _operationActive { + AppLog.instance.trace( + "lib/pages/backup_page.dart::_BackupPageState._operationActive.get", + ); + return mounted && _cancelled?.isCompleted == false; + } + String? _message; String? get _statusMessage { AppLog.instance.trace( @@ -39,6 +49,31 @@ class _BackupPageState extends State { : _message; } + @override + void dispose() { + AppLog.instance.trace( + "lib/pages/backup_page.dart::_BackupPageState.dispose", + ); + _cancelOperation(); + super.dispose(); + } + + void _cancelOperation() { + AppLog.instance.trace( + "lib/pages/backup_page.dart::_BackupPageState._cancelOperation", + ); + final cancelled = _cancelled; + if (cancelled != null && !cancelled.isCompleted) cancelled.complete(); + // Provider cleanup errors cannot be shown after leaving the page. + unawaited( + _importStream?.cancel().catchError((_) { + AppLog.instance.trace( + "lib/pages/backup_page.dart::_BackupPageState._cancelOperation.closure#1", + ); + }), + ); + } + Future _confirm(String title, Widget content, String action) { AppLog.instance.trace( "lib/pages/backup_page.dart::_BackupPageState._confirm", @@ -59,6 +94,7 @@ class _BackupPageState extends State { ); if (_busy || !mounted) return; + final cancelled = _cancelled = Completer(); setState(() { AppLog.instance.trace( "lib/pages/backup_page.dart::_BackupPageState._run.closure#1.closure#1", @@ -71,6 +107,7 @@ class _BackupPageState extends State { try { await action(); } catch (error, stack) { + if (cancelled.isCompleted) return; final expected = error is ConfigArchiveException || error is FormatException || @@ -126,6 +163,8 @@ class _BackupPageState extends State { }; }); } finally { + if (!cancelled.isCompleted) cancelled.complete(); + _cancelled = null; if (mounted) setState(() { AppLog.instance.trace( @@ -154,9 +193,12 @@ class _BackupPageState extends State { '仍要导出', )) return; - if (!mounted) return; - final bytes = await _archive.export(includeAccount: includeAccount); - if (!mounted) return; + if (!_operationActive) return; + final bytes = await _archive.export( + includeAccount: includeAccount, + cancelled: _cancelled?.future, + ); + if (!_operationActive) return; final date = DateTime.now().toIso8601String().substring(0, 10); final saved = await FilePicker.saveFile( fileName: 'XMPlus-$date${includeAccount ? '-含账号' : ''}.zip', @@ -164,7 +206,7 @@ class _BackupPageState extends State { mimeType: 'application/zip', dialogTitle: '保存备份', ); - if (saved != null && mounted) + if (saved != null && _operationActive) setState(() { AppLog.instance.trace( "lib/pages/backup_page.dart::_BackupPageState._export.closure#1.closure#1", @@ -188,21 +230,34 @@ class _BackupPageState extends State { type: FileType.custom, allowedExtensions: ['zip'], ); - if (file == null || !mounted) return; + if (file == null || !_operationActive) return; final length = await file.length(); + if (!_operationActive) return; if (length != null && length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); } // A bounded stream also handles providers which do not report file length. final bytes = BytesBuilder(copy: false); - await for (final chunk in file.readAsByteStream()) { - if (bytes.length + chunk.length > ConfigArchive.maxBytes) { - throw const ConfigArchiveException('备份超过 64 MiB'); + final stream = _importStream = StreamIterator(file.readAsByteStream()); + try { + while (await stream.moveNext()) { + if (!_operationActive) return; + final chunk = stream.current; + if (bytes.length + chunk.length > ConfigArchive.maxBytes) { + throw const ConfigArchiveException('备份超过 64 MiB'); + } + bytes.add(chunk); } - bytes.add(chunk); + } finally { + _importStream = null; + await stream.cancel(); } - final draft = await _archive.inspect(bytes.takeBytes()); - if (!mounted) return; + if (!_operationActive) return; + final draft = await _archive.inspect( + bytes.takeBytes(), + cancelled: _cancelled?.future, + ); + if (!_operationActive) return; final replacements = {}; for (final oldRoot in draft.missingDirectories) { if (!await _confirm( @@ -211,11 +266,11 @@ class _BackupPageState extends State { '选择目录', )) return; - if (!mounted) return; + if (!_operationActive) return; final selected = await FilePicker.getDirectoryPath( dialogTitle: '选择新的下载目录', ); - if (selected == null || !mounted) return; + if (selected == null || !_operationActive) return; replacements[oldRoot] = selected; } final accepted = await _confirm( @@ -236,7 +291,7 @@ class _BackupPageState extends State { ), '确认恢复', ); - if (!accepted || !mounted) return; + if (!accepted || !_operationActive) return; try { await widget.runtime.restoreBackup(draft, relocatedRoots: replacements); } catch (_) { @@ -266,14 +321,22 @@ class _BackupPageState extends State { @override Widget build(BuildContext context) { AppLog.instance.trace("lib/pages/backup_page.dart::_BackupPageState.build"); - return ListenableBuilder( - listenable: widget.runtime, - builder: (context, _) { + return PopScope( + onPopInvokedWithResult: (didPop, _) { AppLog.instance.trace( "lib/pages/backup_page.dart::_BackupPageState.build.closure#1", ); - return _content(context); + if (didPop) _cancelOperation(); }, + child: ListenableBuilder( + listenable: widget.runtime, + builder: (context, _) { + AppLog.instance.trace( + "lib/pages/backup_page.dart::_BackupPageState.build.closure#2", + ); + return _content(context); + }, + ), ); } diff --git a/lib/pages/developer_settings_page.dart b/lib/pages/developer_settings_page.dart index 1f7e308..fdfa7a4 100644 --- a/lib/pages/developer_settings_page.dart +++ b/lib/pages/developer_settings_page.dart @@ -30,6 +30,14 @@ class _DeveloperSettingsPageState extends State { String? _selected; String? _error; bool _busy = false; + bool _leaving = false; + + bool _canContinueExport() { + AppLog.instance.trace( + "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._canContinueExport", + ); + return mounted && !_leaving; + } @override void initState() { @@ -202,7 +210,7 @@ class _DeveloperSettingsPageState extends State { "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._export.closure#1", ); - if (_busy) return; + if (_busy || !_canContinueExport()) return; final session = _sessions.where((session) { AppLog.instance.trace( "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._export.closure#1.closure#1", @@ -221,19 +229,25 @@ class _DeveloperSettingsPageState extends State { try { if (clipboard) { final snapshot = await _log.snapshot(session.fileName); + if (!_canContinueExport()) return; if (!snapshot.canCopyToClipboard(android: Platform.isAndroid)) { throw StateError('日志过大,无法完整复制,请导出到文件'); } final text = await snapshot.readForClipboard( android: Platform.isAndroid, ); + if (!_canContinueExport()) return; await Clipboard.setData(ClipboardData(text: text)); - if (mounted) _message('日志已复制'); + if (_canContinueExport()) _message('日志已复制'); } else { - final destination = await exportLogSession(session); - if (destination != null && mounted) _message('日志已导出'); + final destination = await exportLogSession( + session, + canStart: _canContinueExport, + ); + if (destination != null && _canContinueExport()) _message('日志已导出'); } } catch (error, stack) { + if (!_canContinueExport()) return; if (!_log.errorWasRecorded(error)) { _log.write( AppLogLevel.warning, @@ -301,7 +315,7 @@ class _DeveloperSettingsPageState extends State { "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build", ); - return ListenableBuilder( + final content = ListenableBuilder( listenable: widget.runtime, builder: (context, _) { AppLog.instance.trace( @@ -515,5 +529,14 @@ class _DeveloperSettingsPageState extends State { ); }, ); + return PopScope( + child: content, + onPopInvokedWithResult: (didPop, _) { + AppLog.instance.trace( + "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build.closure#2", + ); + if (didPop) _leaving = true; + }, + ); } } diff --git a/lib/services/log_export.dart b/lib/services/log_export.dart index 27983ab..37a9fcc 100644 --- a/lib/services/log_export.dart +++ b/lib/services/log_export.dart @@ -8,16 +8,19 @@ import 'android_platform.dart'; import 'app_log.dart'; /// Exports only a session returned by the logger, never an arbitrary path. -/// A null result means the user cancelled the platform save dialog. -Future exportLogSession(LogSession session) { +/// A null result means the caller or the platform save dialog cancelled. +/// [canStart] gates preparation; an opened save dialog can still finish. +Future exportLogSession(LogSession session, {bool Function()? canStart}) { AppLog.instance.trace("lib/services/log_export.dart::exportLogSession"); return AppLog.instance.runWithErrorScope(() async { AppLog.instance.trace( "lib/services/log_export.dart::exportLogSession.closure#1", ); + if (canStart?.call() == false) return null; final log = AppLog.instance; final sessions = await log.sessions(); + if (canStart?.call() == false) return null; if (!sessions.any((candidate) { AppLog.instance.trace( "lib/services/log_export.dart::exportLogSession.closure#1.closure#1", @@ -35,6 +38,7 @@ Future exportLogSession(LogSession session) { throw ArgumentError.value(name, 'fileName', '日志文件名无效'); } final snapshot = await log.snapshot(name); + if (canStart?.call() == false) return null; if (Platform.isAndroid) { return AndroidPlatform.exportLogSnapshot(snapshot, name); } diff --git a/lib/storage/config_archive.dart b/lib/storage/config_archive.dart index c309189..80f11a7 100644 --- a/lib/storage/config_archive.dart +++ b/lib/storage/config_archive.dart @@ -1,6 +1,7 @@ // ZIP layout adapted from PiliPlus lib/utils/config_archive.dart, commit // d9e9626f1a3f89cd9ca85e18f61aaba02634e12a (GNU GPL v3). // Source: https://github.com/bggRGjQaUbCoE/PiliPlus +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -116,11 +117,33 @@ class ConfigArchive { return sha256.convert(utf8.encode(storage.directory)).toString(); } - Future> export({bool includeAccount = false}) async { + Future> export({ + bool includeAccount = false, + Future? cancelled, + }) async { AppLog.instance.trace( "lib/storage/config_archive.dart::ConfigArchive.export", ); + var stopped = false; + unawaited( + cancelled?.then((_) { + AppLog.instance.trace( + "lib/storage/config_archive.dart::ConfigArchive.export.closure#1", + ); + stopped = true; + }), + ); + void checkCancelled() { + AppLog.instance.trace( + "lib/storage/config_archive.dart::ConfigArchive.export.checkCancelled", + ); + if (stopped) throw const ConfigArchiveException('备份导出已取消'); + } + + // Observe an already completed cancellation before preparing the archive. + await Future.value(); + checkCancelled(); final state = storage.state; final tasks = { 'downloads': _clean(state['downloads']), @@ -137,11 +160,13 @@ class ConfigArchive { if (includeAccount) { final records = >[]; for (final entry in (state['accounts'] as Map).entries) { + checkCancelled(); final record = entry.value as Map; Map? pair; final ref = record['credentialRef']; if (ref is String) { final encoded = await secrets.read(ref); + checkCancelled(); if (encoded == null) throw const ConfigArchiveException('安全凭据缺失,未导出备份'); final credentials = Credentials.parse(encoded); @@ -162,6 +187,7 @@ class ConfigArchive { ...tasks, }; } + checkCancelled(); files['meta.json'] = { 'type': 'data_archive', 'formatVersion': 65536, @@ -187,11 +213,36 @@ class ConfigArchive { return bytes; } - Future inspect(List bytes) async { + Future inspect( + List bytes, { + Future? cancelled, + }) async { AppLog.instance.trace( "lib/storage/config_archive.dart::ConfigArchive.inspect", ); + var stopped = false; + XmlyClient? checkingAccount; + unawaited( + cancelled?.then((_) { + AppLog.instance.trace( + "lib/storage/config_archive.dart::ConfigArchive.inspect.closure#1", + ); + stopped = true; + // These clients belong to the preview, never to the active accounts. + checkingAccount?.close(); + }), + ); + void checkCancelled() { + AppLog.instance.trace( + "lib/storage/config_archive.dart::ConfigArchive.inspect.checkCancelled", + ); + if (stopped) throw const ConfigArchiveException('备份导入已取消'); + } + + // Observe an already completed cancellation before parsing the ZIP. + await Future.value(); + checkCancelled(); final Map payload; try { if (bytes.length > maxBytes || bytes.isEmpty) @@ -247,15 +298,17 @@ class ConfigArchive { // No mutation, SMS, claim or report: candidate verification is config only. final account = payload['account.json'] as Map?; for (final record in account?['accounts'] as List? ?? const []) { + checkCancelled(); if ((record as Map)['credentials'] == null) continue; final pair = Credentials.parse(record['credentials']); - final client = XmlyClient(credentials: pair); + final client = checkingAccount = XmlyClient(credentials: pair); try { final quota = await client.queryQuota(); if (quota.remainingSeconds < 0) throw const ConfigArchiveException('备份账号无法核验'); } finally { client.close(); + checkingAccount = null; } } final missing = {}; @@ -264,6 +317,7 @@ class ConfigArchive { "lib/storage/config_archive.dart::ConfigArchive.inspect.roots", ); + checkCancelled(); if (node is Map) { for (final entry in node.entries) { if (_rootKeys.contains(entry.key) && @@ -282,6 +336,7 @@ class ConfigArchive { } await roots(payload); + checkCancelled(); final sameOrigin = payload['meta.json']['origin'] == _origin; final summary = [ if (payload.containsKey('setting.json')) '替换应用设置',