diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 33962f27c61..31493dad528 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -368,6 +368,10 @@ class _PendingAttachment { } Future _deleteXFile(XFile file) async { + if (file is RetainedAttachmentFile) { + await file.dispose(); + return; + } final path = file.path; if (path.isEmpty) return; try { diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 9235231be40..603dd02a751 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -814,8 +814,13 @@ class ComposeBar extends HookConsumerWidget { uploadError.value = null; try { final picked = await pick(); - if (picked == null || !context.mounted) return; - queueAttachment(picked, kind); + if (picked == null) return; + final owned = picked is RetainedAttachmentFile; + if (!context.mounted) { + if (owned) await picked.dispose(); + return; + } + queueAttachment(picked, kind, deleteAfterUse: owned); } catch (error) { if (context.mounted) { uploadError.value = _formatUploadError(error); diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index ac6c6125123..63c238dad97 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; +import 'dart:typed_data'; import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; @@ -444,23 +445,42 @@ class MediaUploadService { if (!_allowedAudioMimeTypes.contains(mimeType)) { throw Exception('unsupported voice note type: $mimeType'); } + return _uploadPackagedAudio( + voiceNote, + duration: duration, + onProgress: onProgress, + cancellationToken: cancellationToken, + ); + } + + Future _uploadPackagedAudio( + XFile voiceNote, { + Duration? duration, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _throwIfCancelled(cancellationToken); + final length = await voiceNote.length(); + if (length == 0) throw Exception('Voice note is empty.'); + if (length > _maxFileSizeBytes) { + throw Exception('Voice note is too large. Maximum is 100MB.'); + } String? packagedPath; try { packagedPath = await _packageVoiceNoteForUpload(voiceNote.path); _throwIfCancelled(cancellationToken); - final bytes = await File(packagedPath).readAsBytes(); - if (bytes.isEmpty) throw Exception('Voice note is empty.'); - if (bytes.length > _maxFileSizeBytes) { - throw Exception('Voice note is too large. Maximum is 100MB.'); - } + final bytes = await _readAttachmentBytes(XFile(packagedPath)); + _throwIfCancelled(cancellationToken); final descriptor = await _uploadPreparedBytes( bytes, mimeType: 'video/mp4', onProgress: onProgress, cancellationToken: cancellationToken, ); + final filename = _voiceNoteMp4Filename(voiceNote.name); + if (duration == null) return descriptor.withFilename(filename); return descriptor.withVoiceNoteMetadata( - filename: _voiceNoteMp4Filename(voiceNote.name), + filename: filename, fallbackDurationSeconds: duration.inMilliseconds / 1000, ); } finally { @@ -480,7 +500,12 @@ class MediaUploadService { if (pickAttachmentFile == null) { throw Exception("File attachments aren't available on this device."); } - return pickAttachmentFile(); + final pickedFile = await pickAttachmentFile(); + if (pickedFile == null) return null; + // Document pickers can return an iOS Inbox URL whose lifetime is shorter + // than the composer draft. Capture the contents before displaying a chip + // that promises the file is ready to send. + return RetainedAttachmentFile.capture(pickedFile); } /// Uploads [pickedFile] as a size-limited generic attachment. @@ -490,16 +515,21 @@ class MediaUploadService { UploadCancellationToken? cancellationToken, }) async { _throwIfCancelled(cancellationToken); - final length = await pickedFile.length(); - if (length == 0) { - throw Exception('File is empty.'); - } - if (length > _maxFileSizeBytes) { - throw Exception( - 'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + // Imported AAC recordings need the same sanitized, fast-start envelope as + // in-app recordings. Never fall back to uploading a rejected raw container. + final name = pickedFile.name.toLowerCase(); + final mimeType = pickedFile.mimeType?.split(';').first.trim().toLowerCase(); + if (name.endsWith('.m4a') || + name.endsWith('.aac') || + _allowedAudioMimeTypes.contains(mimeType) || + mimeType == 'audio/x-m4a') { + return _uploadPackagedAudio( + pickedFile, + onProgress: onProgress, + cancellationToken: cancellationToken, ); } - final bytes = await pickedFile.readAsBytes(); + final bytes = await _readAttachmentBytes(pickedFile); _throwIfCancelled(cancellationToken); final descriptor = await _uploadPreparedBytes( bytes, diff --git a/mobile/lib/shared/relay/media_upload/helpers.dart b/mobile/lib/shared/relay/media_upload/helpers.dart index c8dd4504d5a..4f496053cd2 100644 --- a/mobile/lib/shared/relay/media_upload/helpers.dart +++ b/mobile/lib/shared/relay/media_upload/helpers.dart @@ -1,5 +1,75 @@ part of '../media_upload.dart'; +/// A composer-owned document copy, released when sent or discarded. +class RetainedAttachmentFile extends XFile { + final Directory _directory; + + RetainedAttachmentFile._(super.path, this._directory, String? mimeType) + : super(mimeType: mimeType); + + /// Copies a picker result without retaining large recordings in memory. + static Future capture(XFile source) async { + final directory = await Directory.systemTemp.createTemp('buzz-attachment-'); + try { + final file = File( + '${directory.path}/${_safeAttachmentFilename(source.name)}', + ); + final output = await file.open(mode: FileMode.write); + try { + await for (final chunk in _attachmentChunks(source)) { + await output.writeFrom(chunk); + } + } finally { + await output.close(); + } + return RetainedAttachmentFile._(file.path, directory, source.mimeType); + } catch (_) { + await directory.delete(recursive: true); + rethrow; + } + } + + /// Removes only this attachment's owned copy, never the picked original. + Future dispose() async { + try { + await _directory.delete(recursive: true); + } on PathNotFoundException { + // Removal can race composer disposal after a completed send. + } + } +} + +Future _readAttachmentBytes(XFile file) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in _attachmentChunks(file)) { + builder.add(chunk); + } + return builder.takeBytes(); +} + +Stream _attachmentChunks(XFile file) async* { + try { + final length = await file.length(); + if (length > _maxFileSizeBytes) { + throw Exception('File is too large. Maximum is 100MB.'); + } + var size = 0; + // Bound the read too: a provider's file can change after its size check. + await for (final chunk in file.openRead()) { + size += chunk.length; + if (size > _maxFileSizeBytes) { + throw Exception('File is too large. Maximum is 100MB.'); + } + yield chunk; + } + if (size == 0) throw Exception('File is empty.'); + } on FileSystemException { + throw Exception( + 'This file is no longer available. Save a copy in Files, then attach it again.', + ); + } +} + bool _startsWith(Uint8List bytes, List prefix) { if (bytes.length < prefix.length) return false; for (var i = 0; i < prefix.length; i++) { diff --git a/mobile/test/shared/relay/imported_audio_upload_test.dart b/mobile/test/shared/relay/imported_audio_upload_test.dart new file mode 100644 index 00000000000..057fe86d433 --- /dev/null +++ b/mobile/test/shared/relay/imported_audio_upload_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/media_upload.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'imported recording survives failed preparation, cancel, and retry', + () async { + final directory = await Directory.systemTemp.createTemp('audio-import-'); + addTearDown(() => directory.delete(recursive: true)); + final source = File('${directory.path}/회의.M4A'); + await source.writeAsBytes([1, 2, 3]); + final package = File('${directory.path}/prepared.mp4'); + var failPreparation = true; + UploadCancellationToken? token; + final uploads = >[]; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + nsec: nostr.Keys.generate().nsec, + pickAttachmentFile: () async => XFile(source.path), + packageVoiceNoteForUpload: (path) async { + expect(await File(path).readAsBytes(), [1, 2, 3]); + if (failPreparation) { + throw PlatformException(code: 'transcode_failed'); + } + await package.writeAsBytes([4, 5, 6]); + token?.cancel(); + return package.path; + }, + httpClient: MockClient((request) async { + expect(request.headers['Content-Type'], 'video/mp4'); + uploads.add(request.bodyBytes); + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/recording.mp4', + 'sha256': 'a' * 64, + 'size': request.bodyBytes.length, + 'type': 'video/mp4', + 'uploaded': 1, + 'duration': 120.5, + }), + 200, + ); + }), + ); + addTearDown(service.dispose); + final retained = + (await service.pickAttachmentFile())! as RetainedAttachmentFile; + addTearDown(retained.dispose); + await source.delete(); + await expectLater( + service.uploadFile(retained), + throwsA(isA()), + ); + expect(uploads, isEmpty); + expect(await File(retained.path).exists(), isTrue); + + failPreparation = false; + token = UploadCancellationToken(); + await expectLater( + service.uploadFile(retained, cancellationToken: token), + throwsA(isA()), + ); + expect(uploads, isEmpty); + expect(await package.exists(), isFalse); + expect(await File(retained.path).exists(), isTrue); + + token = null; + final descriptor = await service.uploadFile(retained); + expect(uploads.single, [4, 5, 6]); + expect(descriptor.filename, 'voice-note-회의.mp4'); + expect(descriptor.duration, 120.5); + expect( + descriptor.toMarkdownImage(), + '[voice-note-회의.mp4](${descriptor.url})', + ); + expect(await package.exists(), isFalse); + expect(await File(retained.path).readAsBytes(), [1, 2, 3]); + }, + ); + + test( + 'routes supported audio metadata and bounds input/output before upload', + () async { + final directory = await Directory.systemTemp.createTemp('audio-bounds-'); + addTearDown(() => directory.delete(recursive: true)); + final source = File('${directory.path}/source'); + final package = File('${directory.path}/package.mp4'); + var packagingCount = 0; + var uploads = 0; + var oversizedOutput = false; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + nsec: nostr.Keys.generate().nsec, + packageVoiceNoteForUpload: (path) async { + packagingCount++; + await package.writeAsBytes([4]); + if (oversizedOutput) { + final handle = await package.open(mode: FileMode.append); + await handle.truncate(100 * 1024 * 1024 + 1); + await handle.close(); + } + return package.path; + }, + httpClient: MockClient((request) async { + uploads++; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/file', + 'sha256': 'a' * 64, + 'size': request.bodyBytes.length, + 'type': request.headers['Content-Type'], + 'uploaded': 1, + }), + 200, + ); + }), + ); + addTearDown(service.dispose); + for (final (name, mime, shouldPackage) in [ + ('memo.m4a', null, true), + ('memo.AAC', null, true), + ('memo', 'audio/mp4', true), + ('memo', 'audio/x-m4a', true), + ('memo', 'audio/aac', true), + ('memo.mp3', 'audio/mpeg', false), + ('video.mp4', 'video/mp4', false), + ('memo.txt', null, false), + ]) { + final file = File('${directory.path}/$name'); + await file.writeAsBytes([1]); + final before = packagingCount; + final descriptor = await service.uploadFile( + XFile(file.path, mimeType: mime), + ); + expect(packagingCount - before, shouldPackage ? 1 : 0, reason: name); + expect(descriptor.duration, isNull); // Do not invent a zero duration. + expect(await file.readAsBytes(), [1]); + } + await source.writeAsBytes([]); + final audio = XFile(source.path, mimeType: 'audio/mp4'); + final before = packagingCount; + final sent = uploads; + await expectLater(service.uploadFile(audio), throwsException); + final handle = await source.open(mode: FileMode.append); + await handle.truncate(100 * 1024 * 1024 + 1); + await handle.close(); + await expectLater(service.uploadFile(audio), throwsException); + expect(packagingCount, before); + await source.writeAsBytes([1]); + oversizedOutput = true; + await expectLater(service.uploadFile(audio), throwsException); + expect(uploads, sent); + expect(await package.exists(), isFalse); + expect(await source.readAsBytes(), [1]); + }, + ); +} diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 61db8725696..ab34eb63510 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1158,6 +1158,70 @@ void main() { ); }); + test( + 'retains picked files through source removal and allows recovery', + () async { + final directory = await Directory.systemTemp.createTemp('attachment-'); + addTearDown(() => directory.delete(recursive: true)); + final source = File('${directory.path}/Meeting: planning notes.txt'); + final contents = [1, 2, 3, 4]; + await source.writeAsBytes(contents); + final uploads = >[]; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickAttachmentFile: () async => XFile(source.path), + httpClient: http_testing.MockClient((request) async { + uploads.add(request.bodyBytes); + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/recording.m4a', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': request.bodyBytes.length, + 'type': 'application/octet-stream', + 'uploaded': 1, + }), + HttpStatus.ok, + ); + }), + ); + addTearDown(service.dispose); + final picked = await service.pickAttachmentFile(); + final retained = picked!; + if (retained is RetainedAttachmentFile) { + addTearDown(retained.dispose); + } + await source.delete(); + final uploaded = await service.uploadFile(retained); + expect(uploads.single, contents); + expect(uploaded.filename, 'Meeting: planning notes.txt'); + + await expectLater( + service.pickAttachmentFile(), + throwsA( + isA().having( + (e) => e.toString(), + 'recovery', + contains('Save a copy in Files, then attach it again'), + ), + ), + ); + expect(uploads, hasLength(1)); + await source.writeAsBytes([5, 6]); + final replacement = await service.pickAttachmentFile(); + await service.uploadFile(replacement!); + expect(uploads.last, [5, 6]); + await (replacement as RetainedAttachmentFile).dispose(); + expect(await File(replacement.path).parent.exists(), isFalse); + expect(await source.readAsBytes(), [5, 6]); + await (retained as RetainedAttachmentFile).dispose(); + expect(await File(retained.path).parent.exists(), isFalse); + }, + ); + test('rejects empty generic file attachments before upload', () async { var uploadRequested = false; final service = MediaUploadService( @@ -1716,7 +1780,11 @@ extension on MediaUploadService { Future pickAndUploadFile() async { final file = await pickAttachmentFile(); - return file == null ? null : uploadFile(file); + try { + return file == null ? null : await uploadFile(file); + } finally { + if (file is RetainedAttachmentFile) await file.dispose(); + } } Future readAndUploadClipboardImage() async {