Skip to content
Draft
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
4 changes: 4 additions & 0 deletions mobile/lib/features/channels/compose_bar/attachments.dart
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ class _PendingAttachment {
}

Future<void> _deleteXFile(XFile file) async {
if (file is RetainedAttachmentFile) {
await file.dispose();
return;
}
final path = file.path;
if (path.isEmpty) return;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 45 additions & 15 deletions mobile/lib/shared/relay/media_upload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<BlobDescriptor> _uploadPackagedAudio(
XFile voiceNote, {
Duration? duration,
ValueChanged<double>? 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 {
Expand All @@ -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.
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions mobile/lib/shared/relay/media_upload/helpers.dart
Original file line number Diff line number Diff line change
@@ -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<RetainedAttachmentFile> 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<void> dispose() async {
try {
await _directory.delete(recursive: true);
} on PathNotFoundException {
// Removal can race composer disposal after a completed send.
}
}
}

Future<Uint8List> _readAttachmentBytes(XFile file) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in _attachmentChunks(file)) {
builder.add(chunk);
}
return builder.takeBytes();
}

Stream<Uint8List> _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<int> prefix) {
if (bytes.length < prefix.length) return false;
for (var i = 0; i < prefix.length; i++) {
Expand Down
171 changes: 171 additions & 0 deletions mobile/test/shared/relay/imported_audio_upload_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <List<int>>[];
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<PlatformException>()),
);
expect(uploads, isEmpty);
expect(await File(retained.path).exists(), isTrue);

failPreparation = false;
token = UploadCancellationToken();
await expectLater(
service.uploadFile(retained, cancellationToken: token),
throwsA(isA<UploadCancelledException>()),
);
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]);
},
);
}
Loading
Loading