mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
## Summary Deletes mobile code that no longer has a user-reachable path and has no counterpart in buzz-app, so it is unlikely to come back for parity. Net: 104 files, about 5,500 lines removed. No intended behavior change. - **iOS native message menu and action surface:** `NativeMessageMenu.swift` and `NativeMessageActionSurface.swift`, the `message`/`supportsMessage` coordinator branches, and the Dart selection hook. Dart stopped creating this view in [#8280](https://github.com/block/buzz/pull/8280). The native reactions sheet in the same coordinator is kept. - **Old home quick-actions launcher and `ComposeMorphIcon`:** only instantiated in tests since the nav-bar creation actions replaced them. - **Retired local theme stack:** `ThemeNotifier`, `AccentNotifier`, `SchemeNotifier`, `adaptive_theme.dart`, `AccentPickerPage`, the appearance-mode sheet and their helpers. These predate per-community theme sync. The legacy prefs migration in `community_theme_preference.dart` is kept. - **Unused widgets:** `ProfileAvatar` and `MaskedAvatarBadge`. - **Push permission-status plumbing:** the iOS `notificationAuthorizationStatus`/`openNotificationSettings` handlers, plus the uncalled strict age-gate snapshot registration and its Swift `syncAgeGatePushSnapshot` path. - **Orphaned files** with no importer: `profile_text_edit_sheet`, `editor_option_button`, `retained_page_transition`, `tappable_flapping_bee`, `agent_model_info`, `community_bot_profiles`, `sheet_divider`, `success_haptic`. - **Uncalled helpers:** the `pickAndUpload*`/camera helpers in `media_upload.dart`, unused `NostrFilters` builders, `mediaGetHeadersFor*`, and about 20 other uncalled functions, providers and styles. - **Removed-UI leftovers:** the never-wired invite directory providers, `AuthNotifier.signOut` and `removeActiveCommunityForSignOut` (the Sign out row is gone), `renameCommunity`, `HomePage.profilePageBuilder`/`SettingsPage.embedded`, and the create-forum branches of the create-channel sheet. Forum viewing is unchanged. Tests that only covered deleted code are removed. Tests of surviving code that used deleted helpers as drivers were repointed to the surviving API. An audit of `mobile/android` found no dead Kotlin, resources, manifest entries or Gradle dependencies, so Android code is unchanged. ## Validation - `flutter analyze`: no issues. - After rebasing onto main ([93b8b8f](https://github.com/block/buzz/commit/93b8b8febc)), `just mobile-check` is clean (format and analyze), and `just mobile-test` passes (4,070 passed, 35 skipped, 0 failed, plus the push-unconfigured and secp256k1 suites). - `dart format`: clean. - Android: `just mobile-build-android` builds the debug APK, and `./gradlew :app:testDebugUnitTest` passes. - iOS: `build-for-testing` succeeds and BuzzPushKit `swift test` passes (65 tests). In RunnerTests, 97 of 98 pass. `testNativeNavigationTitleExpandsAgainAtTop` fails, but it fails the same way on unmodified `main` (acf56fff6f) on the same machine (Xcode 27, iPhone 17 Pro simulator), so it is not caused by this change. - Agent review of the diff ran. Its one blocker (stale `project.pbxproj` entries for the deleted Swift files) is fixed. - In-app smoke run at [66141c1](66141c10b9): a debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to the internal community. The following flows all worked with no errors: - Home: the New menu (channel, DM, workspace, chat) and the create-channel sheet, including its channel-type options. - A chat, with the message action sheet (reactions row, Reply, Copy link, Remind me, Mark unread, Copy text, Edit and Delete). - Appearance: the previews, and cycling the System and Light modes. - Settings: the profile header, the display name editor, and Edit photo (Image and Emoji tabs). - The Agents directory. - Search, including the Channels filter, plus opening a team channel from the results. - Tapping a reaction summary, which opens the native iOS Reactions sheet. - The recording isn't attached here because it shows internal channels and messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1728 lines
53 KiB
Dart
1728 lines
53 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.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' as http_testing;
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:buzz/shared/relay/media_auth.dart';
|
|
import 'package:buzz/shared/relay/media_upload.dart';
|
|
|
|
final _pngBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0d,
|
|
0x49,
|
|
0x48,
|
|
0x44,
|
|
0x52,
|
|
]);
|
|
|
|
final _jpegBytes = Uint8List.fromList([
|
|
0xff,
|
|
0xd8,
|
|
0xff,
|
|
0xdb,
|
|
0x00,
|
|
0x43,
|
|
0x00,
|
|
0x01,
|
|
]);
|
|
|
|
final _heicBytes = Uint8List.fromList([
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x18,
|
|
0x66,
|
|
0x74,
|
|
0x79,
|
|
0x70,
|
|
0x68,
|
|
0x65,
|
|
0x69,
|
|
0x63,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x6d,
|
|
0x69,
|
|
0x66,
|
|
0x31,
|
|
0x68,
|
|
0x65,
|
|
0x69,
|
|
0x63,
|
|
]);
|
|
|
|
class _NamedXFile extends XFile {
|
|
final String _name;
|
|
|
|
_NamedXFile(super.bytes, this._name) : super.fromData();
|
|
|
|
@override
|
|
String get name => _name;
|
|
}
|
|
|
|
final _gifBytes = Uint8List.fromList([
|
|
...ascii.encode('GIF89a'),
|
|
0x02,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x80,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0xff,
|
|
0xff,
|
|
0xff,
|
|
0x21,
|
|
0xfe,
|
|
0x05,
|
|
...ascii.encode('hello'),
|
|
0x00,
|
|
0x21,
|
|
0xff,
|
|
0x0b,
|
|
...ascii.encode('NETSCAPE2.0'),
|
|
0x03,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x21,
|
|
0xf9,
|
|
0x04,
|
|
0x00,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x2c,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x02,
|
|
0x44,
|
|
0x01,
|
|
0x00,
|
|
0x3b,
|
|
]);
|
|
|
|
final _apngBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
..._testPngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]),
|
|
..._testPngChunk('IEND', const []),
|
|
]);
|
|
|
|
List<int> _testPngChunk(String type, List<int> payload) {
|
|
return [
|
|
payload.length >> 24 & 0xff,
|
|
payload.length >> 16 & 0xff,
|
|
payload.length >> 8 & 0xff,
|
|
payload.length & 0xff,
|
|
...ascii.encode(type),
|
|
...payload,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
];
|
|
}
|
|
|
|
final _staticPngWithActlPayloadBytes = Uint8List.fromList([
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x04,
|
|
0x49,
|
|
0x44,
|
|
0x41,
|
|
0x54,
|
|
0x61,
|
|
0x63,
|
|
0x54,
|
|
0x4c,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x49,
|
|
0x45,
|
|
0x4e,
|
|
0x44,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
]);
|
|
|
|
final _animatedWebpBytes = Uint8List.fromList([
|
|
0x52,
|
|
0x49,
|
|
0x46,
|
|
0x46,
|
|
0x16,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x57,
|
|
0x45,
|
|
0x42,
|
|
0x50,
|
|
0x56,
|
|
0x50,
|
|
0x38,
|
|
0x58,
|
|
0x0a,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x02,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
]);
|
|
|
|
const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload');
|
|
|
|
void _setMockMediaUploadPlatformHandler(
|
|
Future<Object?> Function(MethodCall call)? handler,
|
|
) {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
|
|
tearDownAll(() {
|
|
_setMockMediaUploadPlatformHandler(null);
|
|
});
|
|
|
|
group('MediaGetAuthService', () {
|
|
test('signs relay media get requests with a server-scoped token', () {
|
|
final keychain = nostr.Keys.generate();
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://Relay.Example:443',
|
|
nsec: keychain.nsec,
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
|
);
|
|
|
|
final headers = service.headersFor(
|
|
'https://relay.example:443/media/${'a' * 64}.jpg',
|
|
);
|
|
|
|
final authHeader = headers['Authorization'];
|
|
expect(authHeader, startsWith('Nostr '));
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
expect(authEvent['kind'], 24242);
|
|
expect(authEvent['pubkey'], keychain.public);
|
|
expect(authEvent['content'], 'Get buzz-media');
|
|
expect(authEvent['tags'], contains(equals(['t', 'get'])));
|
|
expect(authEvent['tags'], contains(equals(['server', 'relay.example'])));
|
|
expect(authEvent['tags'], contains(equals(['expiration', '1700000060'])));
|
|
});
|
|
|
|
test('does not sign non-relay or non-media URLs', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://evil.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
expect(service.headersFor('https://relay.example/avatar.png'), isEmpty);
|
|
});
|
|
|
|
test('normalizes default ports and rejects path-prefix lookalikes', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://Relay.Example:443',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isNotEmpty,
|
|
);
|
|
expect(
|
|
service.headersFor('https://relay.example/media-evil/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
service.headersFor('ftp://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('does not sign without a key', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('does not throw or sign when the stored key is invalid', () {
|
|
final service = MediaGetAuthService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: 'not-an-nsec',
|
|
);
|
|
|
|
expect(
|
|
service.headersFor('https://relay.example/media/${'a' * 64}.jpg'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('MediaUploadService', () {
|
|
test('signs Blossom auth and uploads gallery image bytes', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
'thumb': 'https://relay.example/media/test.thumb.jpg',
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example:8443',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(
|
|
capturedRequest!.url.toString(),
|
|
'https://relay.example:8443/upload',
|
|
);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
|
|
expect(capturedRequest!.bodyBytes, _pngBytes);
|
|
|
|
final authHeader = capturedRequest!.headers['Authorization'];
|
|
expect(authHeader, isNotNull);
|
|
expect(authHeader, startsWith('Nostr '));
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
final tags = (authEvent['tags'] as List<dynamic>)
|
|
.map((tag) => (tag as List<dynamic>).cast<String>())
|
|
.toList();
|
|
|
|
expect(authEvent['kind'], 24242);
|
|
expect(authEvent['pubkey'], keychain.public);
|
|
expect(tags, anyElement(equals(<String>['t', 'upload'])));
|
|
expect(
|
|
tags,
|
|
anyElement(
|
|
equals(<String>['x', capturedRequest!.headers['X-SHA-256']!]),
|
|
),
|
|
);
|
|
expect(tags, anyElement(equals(<String>['expiration', '1700000060'])));
|
|
expect(
|
|
tags,
|
|
anyElement(equals(<String>['server', 'relay.example:8443'])),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'retries the legacy upload route when the standard route is absent',
|
|
() async {
|
|
final requests = <http.Request>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path == '/upload') {
|
|
return http.Response('not found', HttpStatus.notFound);
|
|
}
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256': request.headers['X-SHA-256'],
|
|
'size': _pngBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await service.uploadBytes(_pngBytes, mimeType: 'image/png');
|
|
|
|
expect(requests.map((request) => request.url.path), [
|
|
'/upload',
|
|
'/media/upload',
|
|
]);
|
|
expect(requests[1].bodyBytes, requests[0].bodyBytes);
|
|
expect(requests[0].headers['Authorization'], startsWith('Nostr '));
|
|
expect(requests[1].headers['Authorization'], startsWith('Nostr '));
|
|
expect(
|
|
requests[1].headers['X-SHA-256'],
|
|
requests[0].headers['X-SHA-256'],
|
|
);
|
|
},
|
|
);
|
|
|
|
for (final statusCode in [
|
|
HttpStatus.unsupportedMediaType,
|
|
HttpStatus.unprocessableEntity,
|
|
]) {
|
|
test(
|
|
'maps $statusCode media policy responses to friendly copy',
|
|
() async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
'{"error":"media contains metadata"}',
|
|
statusCode,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadBytes(_pngBytes, mimeType: 'image/png'),
|
|
throwsA(
|
|
isA<MediaPolicyUploadException>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
"We couldn't prepare this image for upload.",
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
test('preserves video policy response details', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
'{"error":"unsupported video codec"}',
|
|
HttpStatus.unprocessableEntity,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadBytes(Uint8List(0), mimeType: 'video/mp4'),
|
|
throwsA(
|
|
isA<Exception>()
|
|
.having(
|
|
(error) => error,
|
|
'type',
|
|
isNot(isA<MediaPolicyUploadException>()),
|
|
)
|
|
.having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('unsupported video codec'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'checks clipboard image availability through the platform channel',
|
|
() async {
|
|
final invokedMethods = <String>[];
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
invokedMethods.add(call.method);
|
|
if (call.method == 'clipboardHasImage') return true;
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
expect(await service.clipboardHasImage(), isTrue);
|
|
expect(invokedMethods, ['clipboardHasImage']);
|
|
},
|
|
);
|
|
|
|
test('reads clipboard image through the platform channel', () async {
|
|
final invokedMethods = <String>[];
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
invokedMethods.add(call.method);
|
|
if (call.method == 'readClipboardImage') return _pngBytes;
|
|
if (call.method == 'sanitizeImageForUpload') {
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient(
|
|
(request) async => http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/clipboard.png',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
),
|
|
),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final descriptor = await service.readAndUploadClipboardImage();
|
|
|
|
expect(invokedMethods.first, 'readClipboardImage');
|
|
expect(descriptor.type, 'image/png');
|
|
});
|
|
|
|
test('sanitizes and uploads GIF clipboard bytes', () async {
|
|
http.Request? capturedRequest;
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/clipboard.gif',
|
|
'sha256':
|
|
'3333333333333333333333333333333333333333333333333333333333333333',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/gif',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
readClipboardImage: () async => _gifBytes,
|
|
);
|
|
|
|
final descriptor = await service.readAndUploadClipboardImage();
|
|
|
|
expect(descriptor.type, 'image/gif');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/gif');
|
|
expect(
|
|
ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true),
|
|
isNot(contains('hello')),
|
|
);
|
|
});
|
|
|
|
test('rejects empty clipboard image bytes', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
readClipboardImage: () async => Uint8List(0),
|
|
);
|
|
|
|
expect(
|
|
service.readAndUploadClipboardImage,
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('Unable to read pasted image'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('returns null when the gallery picker is cancelled', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final result = await service.pickAndUploadImage();
|
|
expect(result, isNull);
|
|
});
|
|
|
|
test('uses a bracketed IPv6 server tag in Blossom auth', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'http://[::1]:3000/media/test.png',
|
|
'sha256':
|
|
'2222222222222222222222222222222222222222222222222222222222222222',
|
|
'size': 16,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'http://[::1]:3000',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
|
);
|
|
|
|
await service.pickAndUploadImage();
|
|
|
|
expect(capturedRequest, isNotNull);
|
|
final authHeader = capturedRequest!.headers['Authorization'];
|
|
expect(authHeader, isNotNull);
|
|
final encoded = authHeader!.substring('Nostr '.length);
|
|
final decoded = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(encoded)),
|
|
);
|
|
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
|
final tags = (authEvent['tags'] as List<dynamic>)
|
|
.map((tag) => (tag as List<dynamic>).cast<String>())
|
|
.toList();
|
|
|
|
expect(tags, anyElement(equals(<String>['server', '[::1]:3000'])));
|
|
});
|
|
|
|
test('transcodes HEIC gallery files on iOS before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? transcodedInput;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
|
transcodeImageToJpeg: (bytes) async {
|
|
transcodedInput = bytes;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(transcodedInput, _heicBytes);
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes iOS JPEG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(sanitizedInput, _jpegBytes);
|
|
expect(sanitizedMimeType, 'image/jpeg');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('transcodes HEIC gallery files on Android before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? transcodedInput;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'1234512345123451234512345123451234512345123451234512345123451234',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
|
transcodeImageToJpeg: (bytes) async {
|
|
transcodedInput = bytes;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(transcodedInput, _heicBytes);
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes Android JPEG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.jpg',
|
|
'sha256':
|
|
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
'size': _jpegBytes.length,
|
|
'type': 'image/jpeg',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _jpegBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/jpeg');
|
|
expect(sanitizedInput, _jpegBytes);
|
|
expect(sanitizedMimeType, 'image/jpeg');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
|
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
|
});
|
|
|
|
test('sanitizes Android PNG gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
addTearDown(() {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
});
|
|
|
|
Uint8List? sanitizedInput;
|
|
String? sanitizedMimeType;
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.png',
|
|
'sha256':
|
|
'9999999999999999999999999999999999999999999999999999999999999999',
|
|
'size': _pngBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_pngBytes, name: 'photo.png'),
|
|
sanitizeImageBytes: (bytes, mimeType) async {
|
|
sanitizedInput = bytes;
|
|
sanitizedMimeType = mimeType;
|
|
return _pngBytes;
|
|
},
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(sanitizedInput, _pngBytes);
|
|
expect(sanitizedMimeType, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _pngBytes);
|
|
});
|
|
|
|
test('sanitizes and uploads GIF gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.gif',
|
|
'sha256':
|
|
'4444444444444444444444444444444444444444444444444444444444444444',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/gif',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_gifBytes, name: 'animated.gif'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/gif');
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/gif');
|
|
expect(
|
|
ascii.decode(capturedRequest!.bodyBytes, allowInvalid: true),
|
|
isNot(contains('hello')),
|
|
);
|
|
});
|
|
|
|
test('sanitizes and uploads animated PNG gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.png',
|
|
'sha256':
|
|
'5555555555555555555555555555555555555555555555555555555555555555',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_apngBytes, name: 'animated.png'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _apngBytes);
|
|
});
|
|
|
|
test('uploads static PNG when acTL appears only in chunk payload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
http.Request? capturedRequest;
|
|
final client = http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/static.png',
|
|
'sha256':
|
|
'1111111111111111111111111111111111111111111111111111111111111111',
|
|
'size': _staticPngWithActlPayloadBytes.length,
|
|
'type': 'image/png',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/png');
|
|
expect(capturedRequest, isNotNull);
|
|
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
|
expect(capturedRequest!.bodyBytes, _staticPngWithActlPayloadBytes);
|
|
});
|
|
|
|
test('sanitizes and uploads animated WebP gallery files', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
http.Request? capturedRequest;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
capturedRequest = request;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/animated.webp',
|
|
'sha256':
|
|
'6666666666666666666666666666666666666666666666666666666666666666',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'image/webp',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async =>
|
|
XFile.fromData(_animatedWebpBytes, name: 'animated.webp'),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadImage();
|
|
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'image/webp');
|
|
expect(capturedRequest!.bodyBytes, _animatedWebpBytes);
|
|
});
|
|
|
|
test('rejects unsupported gallery files before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => XFile.fromData(
|
|
Uint8List.fromList(utf8.encode('not an image')),
|
|
name: 'note.txt',
|
|
),
|
|
);
|
|
|
|
expect(
|
|
service.pickAndUploadImage(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('unsupported file type'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('rejects empty generic file attachments before upload', () async {
|
|
var uploadRequested = false;
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
uploadRequested = true;
|
|
return http.Response('', HttpStatus.ok);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
pickAttachmentFile: () async =>
|
|
XFile.fromData(Uint8List(0), name: 'empty.txt'),
|
|
);
|
|
|
|
await expectLater(
|
|
service.pickAndUploadFile(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(error) => error.toString(),
|
|
'message',
|
|
contains('File is empty'),
|
|
),
|
|
),
|
|
);
|
|
expect(uploadRequested, isFalse);
|
|
});
|
|
|
|
test('sanitizes generic filenames to relay imeta constraints', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.bin',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'application/octet-stream',
|
|
'uploaded': 1,
|
|
}),
|
|
HttpStatus.ok,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
pickAttachmentFile: () async => _NamedXFile(
|
|
Uint8List.fromList([1]),
|
|
'folder\\draft\u0000${List.filled(200, 'é').join()}.txt',
|
|
),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadFile();
|
|
final filename = descriptor!.filename!;
|
|
|
|
expect(filename, startsWith('draft'));
|
|
expect(filename, isNot(contains('\u0000')));
|
|
expect(filename, isNot(contains('/')));
|
|
expect(filename, isNot(contains('\\')));
|
|
expect(utf8.encode(filename).length, lessThanOrEqualTo(255));
|
|
});
|
|
});
|
|
|
|
group('uploadVoiceNote', () {
|
|
test('keeps ordinary audio attachments on inline audio markdown', () {
|
|
const descriptor = BlobDescriptor(
|
|
url: 'https://relay.example/media/meeting.m4a',
|
|
sha256:
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
size: 3,
|
|
type: 'audio/mp4',
|
|
uploaded: 1,
|
|
filename: 'meeting.m4a',
|
|
);
|
|
|
|
expect(
|
|
descriptor.toMarkdownImage(),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'removes generated Android package when fast-start rewrite fails',
|
|
() async {
|
|
final previousPlatform = debugDefaultTargetPlatformOverride;
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
final sourceDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_rewrite_source_',
|
|
);
|
|
final generatedDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_rewrite_generated_',
|
|
);
|
|
final source = File('${sourceDirectory.path}/recording.m4a');
|
|
final generated = File('${generatedDirectory.path}/packaged.mp4');
|
|
await source.writeAsBytes(const [1, 2, 3]);
|
|
await generated.writeAsBytes(const [4, 5, 6]);
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
if (call.method == 'packageVoiceNoteForUpload') {
|
|
return generated.path;
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() async {
|
|
debugDefaultTargetPlatformOverride = previousPlatform;
|
|
_setMockMediaUploadPlatformHandler((call) async {
|
|
switch (call.method) {
|
|
case 'sanitizeImageForUpload':
|
|
final arguments = call.arguments as Map<Object?, Object?>;
|
|
return arguments['bytes'] as Uint8List;
|
|
case 'transcodeImageToJpeg':
|
|
return _jpegBytes;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
if (await sourceDirectory.exists()) {
|
|
await sourceDirectory.delete(recursive: true);
|
|
}
|
|
if (await generatedDirectory.exists()) {
|
|
await generatedDirectory.delete(recursive: true);
|
|
}
|
|
});
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
service.uploadVoiceNote(
|
|
XFile(source.path, mimeType: 'audio/mp4'),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
throwsFormatException,
|
|
);
|
|
|
|
expect(await source.exists(), isTrue);
|
|
expect(await generated.exists(), isFalse);
|
|
expect(generatedDirectory.listSync().whereType<File>(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('uploads the packaged voice note as a video MP4 audio card', () async {
|
|
final sourceDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_source_',
|
|
);
|
|
final packagedDirectory = await Directory.systemTemp.createTemp(
|
|
'voice_note_packaged_',
|
|
);
|
|
final source = File('${sourceDirectory.path}/voice-note-test.m4a');
|
|
final packaged = File('${packagedDirectory.path}/voice-note-test.mp4');
|
|
await source.writeAsBytes(const [1, 2, 3]);
|
|
await packaged.writeAsBytes(const [4, 5, 6]);
|
|
var packagedSourcePath = '';
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((request) async {
|
|
expect(request.headers['Content-Type'], 'video/mp4');
|
|
expect(request.bodyBytes, const [4, 5, 6]);
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/voice-note.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
'duration': 3.0,
|
|
}),
|
|
HttpStatus.ok,
|
|
);
|
|
}),
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
packageVoiceNoteForUpload: (path) async {
|
|
packagedSourcePath = path;
|
|
return packaged.path;
|
|
},
|
|
);
|
|
|
|
try {
|
|
final descriptor = await service.uploadVoiceNote(
|
|
XFile(source.path, mimeType: 'audio/mp4'),
|
|
duration: const Duration(milliseconds: 3250),
|
|
);
|
|
|
|
expect(packagedSourcePath, source.path);
|
|
expect(descriptor.type, 'video/mp4');
|
|
expect(descriptor.filename, 'voice-note-test.mp4');
|
|
expect(descriptor.duration, 3.0);
|
|
expect(descriptor.toImetaTag(), contains('duration 3.0'));
|
|
expect(
|
|
descriptor.toMarkdownImage(),
|
|
'[voice-note-test.mp4](${descriptor.url})',
|
|
);
|
|
expect(await packaged.exists(), isFalse);
|
|
} finally {
|
|
await sourceDirectory.delete(recursive: true);
|
|
await packagedDirectory.delete(recursive: true);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('pickAndUploadVideo', () {
|
|
// Helper: build ftyp header bytes for a given brand.
|
|
Uint8List buildFtypHeader(String brand) {
|
|
final bytes = Uint8List(32);
|
|
bytes[3] = 32;
|
|
bytes[4] = 0x66;
|
|
bytes[5] = 0x74;
|
|
bytes[6] = 0x79;
|
|
bytes[7] = 0x70;
|
|
final brandBytes = ascii.encode(brand);
|
|
for (var i = 0; i < 4 && i < brandBytes.length; i++) {
|
|
bytes[8 + i] = brandBytes[i];
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
// Helper: write bytes to a temp file, return its XFile.
|
|
Future<(XFile, File)> writeTempVideo(Uint8List bytes, String name) async {
|
|
final dir = await Directory.systemTemp.createTemp('video_test_');
|
|
final file = File('${dir.path}/$name');
|
|
await file.writeAsBytes(bytes);
|
|
return (XFile(file.path), file);
|
|
}
|
|
|
|
test('rebuilds an existing MP4 container before upload', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
var transcodeCalled = false;
|
|
final client = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final mp4Bytes = buildFtypHeader('isom');
|
|
final (xfile, tempFile) = await writeTempVideo(mp4Bytes, 'clip.mp4');
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
transcodeCalled = true;
|
|
final outDir = await tempFile.parent.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'video/mp4');
|
|
expect(transcodeCalled, isTrue);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test('transcodes non-MP4 container before uploading', () async {
|
|
final keychain = nostr.Keys.generate();
|
|
final nsec = keychain.nsec;
|
|
var transcodeCalled = false;
|
|
final client = http_testing.MockClient((request) async {
|
|
expect(request.headers['Content-Type'], 'video/mp4');
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': 'video/mp4',
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
// QuickTime container ('qt ' brand) — needs transcoding.
|
|
final movBytes = buildFtypHeader('qt ');
|
|
final (xfile, tempFile) = await writeTempVideo(movBytes, 'clip.mov');
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
transcodeCalled = true;
|
|
// Mock transcoding: write an "MP4" file
|
|
final outDir = await tempFile.parent.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
expect(descriptor, isNotNull);
|
|
expect(descriptor!.type, 'video/mp4');
|
|
expect(transcodeCalled, isTrue);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'stops before reading a transcode completed after cancellation',
|
|
() async {
|
|
final transcodeStarted = Completer<void>();
|
|
final transcodeFinished = Completer<String>();
|
|
final cancellationToken = UploadCancellationToken();
|
|
var uploadRequested = false;
|
|
final (xfile, sourceFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
final outputDirectory = await Directory.systemTemp.createTemp(
|
|
'cancelled_transcode_',
|
|
);
|
|
final outputFile = File('${outputDirectory.path}/out.mp4');
|
|
final outputHandle = await outputFile.open(mode: FileMode.write);
|
|
await outputHandle.truncate(101 * 1024 * 1024);
|
|
await outputHandle.close();
|
|
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: http_testing.MockClient((_) async {
|
|
uploadRequested = true;
|
|
return http.Response('', HttpStatus.internalServerError);
|
|
}),
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (_) {
|
|
transcodeStarted.complete();
|
|
return transcodeFinished.future;
|
|
},
|
|
);
|
|
|
|
final upload = service.uploadVideo(
|
|
xfile,
|
|
cancellationToken: cancellationToken,
|
|
);
|
|
final expectation = expectLater(
|
|
upload,
|
|
throwsA(isA<UploadCancelledException>()),
|
|
);
|
|
await transcodeStarted.future;
|
|
cancellationToken.cancel();
|
|
transcodeFinished.complete(outputFile.path);
|
|
await expectation;
|
|
|
|
expect(uploadRequested, isFalse);
|
|
expect(await outputFile.exists(), isFalse);
|
|
} finally {
|
|
await sourceFile.parent.delete(recursive: true);
|
|
await outputDirectory.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'uploads a generated poster and links it from the video imeta',
|
|
() async {
|
|
final keychain = nostr.Keys.generate();
|
|
final requestTypes = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
final type = request.headers['Content-Type']!;
|
|
requestTypes.add(type);
|
|
final isPoster = type == 'image/jpeg';
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': isPoster
|
|
? 'https://relay.example/media/poster.jpg'
|
|
: 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': 32,
|
|
'type': type,
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final (xfile, tempFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: keychain.nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
final outDir = await tempFile.parent.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
generateVideoPoster: (_) async => _jpegBytes,
|
|
sanitizeImageBytes: (bytes, _) async => bytes,
|
|
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
|
|
expect(descriptor?.image, 'https://relay.example/media/poster.jpg');
|
|
expect(
|
|
descriptor?.toImetaTag(),
|
|
contains('image https://relay.example/media/poster.jpg'),
|
|
);
|
|
expect(requestTypes, ['video/mp4', 'image/jpeg']);
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'falls back to the picked video when the exported poster fails',
|
|
() async {
|
|
final sourcePaths = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
final type = request.headers['Content-Type']!;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'url': type == 'image/jpeg'
|
|
? 'https://relay.example/media/poster.jpg'
|
|
: 'https://relay.example/media/test.mp4',
|
|
'sha256':
|
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'size': request.bodyBytes.length,
|
|
'type': type,
|
|
'uploaded': 1,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final (xfile, tempFile) = await writeTempVideo(
|
|
buildFtypHeader('qt '),
|
|
'clip.mov',
|
|
);
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: nostr.Keys.generate().nsec,
|
|
httpClient: client,
|
|
pickGalleryVideo: () async => xfile,
|
|
pickGalleryImage: () async => null,
|
|
transcodeVideoToMp4: (path) async {
|
|
final outDir = await tempFile.parent.createTemp('transcode_');
|
|
final outFile = File('${outDir.path}/out.mp4');
|
|
await outFile.writeAsBytes(buildFtypHeader('isom'));
|
|
return outFile.path;
|
|
},
|
|
generateVideoPoster: (path) async {
|
|
sourcePaths.add(path);
|
|
if (sourcePaths.length == 1) throw Exception('frame unavailable');
|
|
return _jpegBytes;
|
|
},
|
|
sanitizeImageBytes: (bytes, _) async => bytes,
|
|
);
|
|
|
|
final descriptor = await service.pickAndUploadVideo();
|
|
|
|
expect(sourcePaths, hasLength(2));
|
|
expect(sourcePaths.last, xfile.path);
|
|
expect(descriptor?.image, 'https://relay.example/media/poster.jpg');
|
|
} finally {
|
|
await tempFile.parent.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('returns null when video picker is cancelled', () async {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => null,
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
final result = await service.pickAndUploadVideo();
|
|
expect(result, isNull);
|
|
});
|
|
|
|
test('rejects videos over 100MB', () async {
|
|
// Create a temp file with 101MB of zeros.
|
|
final dir = await Directory.systemTemp.createTemp('video_size_test_');
|
|
final file = File('${dir.path}/huge.mp4');
|
|
final raf = await file.open(mode: FileMode.write);
|
|
await raf.truncate(101 * 1024 * 1024);
|
|
await raf.close();
|
|
|
|
try {
|
|
final service = MediaUploadService(
|
|
baseUrl: 'https://relay.example',
|
|
nsec: null,
|
|
pickGalleryVideo: () async => XFile(file.path),
|
|
pickGalleryImage: () async => null,
|
|
);
|
|
|
|
await expectLater(
|
|
() => service.pickAndUploadVideo(),
|
|
throwsA(
|
|
isA<Exception>().having(
|
|
(e) => e.toString(),
|
|
'message',
|
|
contains('too large'),
|
|
),
|
|
),
|
|
);
|
|
} finally {
|
|
await dir.delete(recursive: true);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Pick-then-upload compositions over the service's picker and upload steps.
|
|
extension on MediaUploadService {
|
|
Future<BlobDescriptor?> pickAndUploadImage() async {
|
|
final image = await pickGalleryImage();
|
|
return image == null ? null : uploadImage(image);
|
|
}
|
|
|
|
Future<BlobDescriptor?> pickAndUploadVideo() async {
|
|
final video = await pickGalleryVideo();
|
|
return video == null ? null : uploadVideo(video);
|
|
}
|
|
|
|
Future<BlobDescriptor?> pickAndUploadFile() async {
|
|
final file = await pickAttachmentFile();
|
|
return file == null ? null : uploadFile(file);
|
|
}
|
|
|
|
Future<BlobDescriptor> readAndUploadClipboardImage() async {
|
|
final image = await readClipboardImage();
|
|
if (image == null) throw Exception('Unable to read pasted image');
|
|
return uploadImage(image);
|
|
}
|
|
}
|