diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 34f125d..736525b 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -23,6 +23,7 @@ AppException? _dioToAppException(DioException error) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: + case DioExceptionType.transformTimeout: return NetworkException.timeout(technicalDetails: error.message); case DioExceptionType.connectionError: return NetworkException(technicalDetails: error.message); diff --git a/lib/api/marianumconnect/errors/marianumconnect_error.dart b/lib/api/marianumconnect/errors/marianumconnect_error.dart index 4160cc4..18fa4b7 100644 --- a/lib/api/marianumconnect/errors/marianumconnect_error.dart +++ b/lib/api/marianumconnect/errors/marianumconnect_error.dart @@ -14,6 +14,7 @@ AppException mapMarianumConnectError(DioException error) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: + case DioExceptionType.transformTimeout: return NetworkException.timeout(technicalDetails: error.message); case DioExceptionType.connectionError: return NetworkException(technicalDetails: error.message); diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 226b078..6ecd836 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -12,4 +12,17 @@ class PendingShare { bool get hasFiles => filePaths.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty; bool get isEmpty => !hasFiles && !hasText; + + /// True when [other] carries the same payload. The iOS Share Extension + /// fires two `open(url)` requests per share (see ShareViewController), so + /// the same share can arrive twice on the media stream — receivedAt is + /// deliberately ignored here so such duplicates compare equal. + bool contentEquals(PendingShare other) { + if (text != other.text) return false; + if (filePaths.length != other.filePaths.length) return false; + for (var i = 0; i < filePaths.length; i++) { + if (filePaths[i] != other.filePaths[i]) return false; + } + return true; + } } diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart index 855ec18..27b429d 100644 --- a/lib/share_intent/share_intent_listener.dart +++ b/lib/share_intent/share_intent_listener.dart @@ -25,7 +25,7 @@ class ShareIntentListener { try { final initial = await ReceiveSharingIntent.instance.getInitialMedia(); final share = _toPendingShare(initial); - if (share != null) pending.value = share; + if (share != null) _publish(share); await ReceiveSharingIntent.instance.reset(); } catch (e) { debugPrint('ShareIntentListener.initialize failed: $e'); @@ -37,13 +37,24 @@ class ShareIntentListener { _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( (items) { final share = _toPendingShare(items); - if (share != null) pending.value = share; + if (share != null) _publish(share); }, onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'), ); } + /// The iOS Share Extension fires two `open(url)` requests per share, so the + /// same payload can arrive twice in quick succession. Publishing the + /// duplicate would re-trigger the share-flow navigation, pop the already + /// open ShareTargetPage and thereby delete the temp files of the share that + /// is still in flight — swallow it instead. + void _publish(PendingShare share) { + final current = pending.value; + if (current != null && current.contentEquals(share)) return; + pending.value = share; + } + /// Cancels the warm-share subscription. The singleton survives, so a /// subsequent [attach] re-subscribes. void detach() { @@ -53,8 +64,15 @@ class ShareIntentListener { /// Discards the current share and removes any temp files the plugin copied /// into the app cache. Idempotent. - void clear() { + /// + /// Pass [ifCurrent] from UI that owns a specific share (e.g. the + /// ShareTargetPage pop handler): the call then only acts while that share + /// is still the pending one. Without the guard, popping a stale share page + /// after a new share arrived would delete the new share's temp files before + /// its upload ran. + void clear({PendingShare? ifCurrent}) { final current = pending.value; + if (ifCurrent != null && !identical(current, ifCurrent)) return; pending.value = null; if (current != null) { for (final path in current.filePaths) { diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 273557c..40fc630 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -187,11 +187,23 @@ class _FilesUploadDialogState extends State { '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; }); + // A vanished source file would otherwise surface as a cryptic + // "Content-Length must contain only digits" HttpException, because + // statSync reports size -1 for missing files. + final fileStat = FileStat.statSync(filePath); + if (fileStat.type == FileSystemEntityType.notFound || fileStat.size < 0) { + _showUploadError( + 'Die Datei "$fileName" ist nicht mehr verfügbar. ' + 'Bitte wähle sie erneut aus.', + ); + return; + } + final HttpClientResponse uploadTask; try { uploadTask = await webdavClient.putFile( File(filePath), - FileStat.statSync(filePath), + fileStat, PathUri.parse(fullRemotePath), onProgress: (progress) { setState(() { diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 48b84eb..9e7472a 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -197,7 +197,7 @@ void _setExternalDraftAndOpenChat( final settings = context.read(); settings.val(write: true).talkSettings.drafts[room.token] = share.text!; } - ShareIntentListener.instance.clear(); + ShareIntentListener.instance.clear(ifCurrent: share); _finishWithChat(context, room); } diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index dc67c37..1c94522 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -212,13 +212,18 @@ Future _externalUploadFlow( screen: FilesUploadDialog( filePaths: share.filePaths, remotePath: targetPath.join('/'), - onUploadFinished: (_) => _afterExternalUploaded(context, targetPath), + onUploadFinished: (_) => + _afterExternalUploaded(context, targetPath, share), ), ); } -void _afterExternalUploaded(BuildContext context, List targetPath) { - ShareIntentListener.instance.clear(); +void _afterExternalUploaded( + BuildContext context, + List targetPath, + PendingShare share, +) { + ShareIntentListener.instance.clear(ifCurrent: share); if (!context.mounted) return; _finishWithFolder(context, targetPath); } diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart index 17257a7..5064570 100644 --- a/lib/view/pages/share_intent/share_target_page.dart +++ b/lib/view/pages/share_intent/share_target_page.dart @@ -40,7 +40,7 @@ class ShareTargetPage extends StatelessWidget { @override Widget build(BuildContext context) => PopScope( onPopInvokedWithResult: (didPop, _) { - if (didPop) ShareIntentListener.instance.clear(); + if (didPop) ShareIntentListener.instance.clear(ifCurrent: share); }, child: Scaffold( appBar: AppBar(title: Text(_appBarTitle())), diff --git a/test/share_intent/pending_share_test.dart b/test/share_intent/pending_share_test.dart new file mode 100644 index 0000000..0e88987 --- /dev/null +++ b/test/share_intent/pending_share_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/share_intent/pending_share.dart'; + +PendingShare _share({ + List filePaths = const [], + String? text, + DateTime? receivedAt, +}) => PendingShare( + filePaths: filePaths, + text: text, + receivedAt: receivedAt ?? DateTime(2026, 1, 1), +); + +void main() { + group('PendingShare.contentEquals', () { + test('equal payloads compare equal regardless of receivedAt', () { + final a = _share( + filePaths: ['/tmp/a.jpg', '/tmp/b.jpg'], + text: 'hi', + receivedAt: DateTime(2026, 1, 1), + ); + final b = _share( + filePaths: ['/tmp/a.jpg', '/tmp/b.jpg'], + text: 'hi', + receivedAt: DateTime(2026, 1, 1, 0, 0, 1), + ); + + expect(a.contentEquals(b), isTrue); + expect(b.contentEquals(a), isTrue); + }); + + test('differing text is not equal', () { + final a = _share(filePaths: ['/tmp/a.jpg'], text: 'hi'); + final b = _share(filePaths: ['/tmp/a.jpg'], text: 'ho'); + + expect(a.contentEquals(b), isFalse); + }); + + test('null text vs text is not equal', () { + final a = _share(filePaths: ['/tmp/a.jpg']); + final b = _share(filePaths: ['/tmp/a.jpg'], text: 'hi'); + + expect(a.contentEquals(b), isFalse); + }); + + test('differing file lists are not equal', () { + final a = _share(filePaths: ['/tmp/a.jpg']); + final b = _share(filePaths: ['/tmp/b.jpg']); + final c = _share(filePaths: ['/tmp/a.jpg', '/tmp/b.jpg']); + + expect(a.contentEquals(b), isFalse); + expect(a.contentEquals(c), isFalse); + }); + + test('file order matters', () { + final a = _share(filePaths: ['/tmp/a.jpg', '/tmp/b.jpg']); + final b = _share(filePaths: ['/tmp/b.jpg', '/tmp/a.jpg']); + + expect(a.contentEquals(b), isFalse); + }); + + test('empty shares compare equal', () { + expect(_share().contentEquals(_share()), isTrue); + }); + }); +}