Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 81 additions & 18 deletions lib/pages/backup_page.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
Expand Down Expand Up @@ -26,9 +27,18 @@ class BackupPage extends StatefulWidget {

class _BackupPageState extends State<BackupPage> {
late final ConfigArchive _archive = widget.runtime.backupArchive;
Completer<void>? _cancelled;
StreamIterator<Uint8List>? _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(
Expand All @@ -39,6 +49,31 @@ class _BackupPageState extends State<BackupPage> {
: _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<bool> _confirm(String title, Widget content, String action) {
AppLog.instance.trace(
"lib/pages/backup_page.dart::_BackupPageState._confirm",
Expand All @@ -59,6 +94,7 @@ class _BackupPageState extends State<BackupPage> {
);

if (_busy || !mounted) return;
final cancelled = _cancelled = Completer<void>();
setState(() {
AppLog.instance.trace(
"lib/pages/backup_page.dart::_BackupPageState._run.closure#1.closure#1",
Expand All @@ -71,6 +107,7 @@ class _BackupPageState extends State<BackupPage> {
try {
await action();
} catch (error, stack) {
if (cancelled.isCompleted) return;
final expected =
error is ConfigArchiveException ||
error is FormatException ||
Expand Down Expand Up @@ -126,6 +163,8 @@ class _BackupPageState extends State<BackupPage> {
};
});
} finally {
if (!cancelled.isCompleted) cancelled.complete();
_cancelled = null;
if (mounted)
setState(() {
AppLog.instance.trace(
Expand Down Expand Up @@ -154,17 +193,20 @@ class _BackupPageState extends State<BackupPage> {
'仍要导出',
))
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',
bytes: Uint8List.fromList(bytes),
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",
Expand All @@ -188,21 +230,34 @@ class _BackupPageState extends State<BackupPage> {
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 = <String, String>{};
for (final oldRoot in draft.missingDirectories) {
if (!await _confirm(
Expand All @@ -211,11 +266,11 @@ class _BackupPageState extends State<BackupPage> {
'选择目录',
))
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(
Expand All @@ -236,7 +291,7 @@ class _BackupPageState extends State<BackupPage> {
),
'确认恢复',
);
if (!accepted || !mounted) return;
if (!accepted || !_operationActive) return;
try {
await widget.runtime.restoreBackup(draft, relocatedRoots: replacements);
} catch (_) {
Expand Down Expand Up @@ -266,14 +321,22 @@ class _BackupPageState extends State<BackupPage> {
@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);
},
),
);
}

Expand Down
33 changes: 28 additions & 5 deletions lib/pages/developer_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
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() {
Expand Down Expand Up @@ -202,7 +210,7 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
"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",
Expand All @@ -221,19 +229,25 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
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,
Expand Down Expand Up @@ -301,7 +315,7 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
"lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build",
);

return ListenableBuilder(
final content = ListenableBuilder(
listenable: widget.runtime,
builder: (context, _) {
AppLog.instance.trace(
Expand Down Expand Up @@ -515,5 +529,14 @@ class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
);
},
);
return PopScope(
child: content,
onPopInvokedWithResult: (didPop, _) {
AppLog.instance.trace(
"lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build.closure#2",
);
if (didPop) _leaving = true;
},
);
}
}
8 changes: 6 additions & 2 deletions lib/services/log_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uri?> 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<Uri?> 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",
Expand All @@ -35,6 +38,7 @@ Future<Uri?> 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);
}
Expand Down
Loading