295 lines
10 KiB
Dart
295 lines
10 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:reelbox/media/cast_media_share.dart';
|
|
import 'package:reelbox/media/segmented_download.dart';
|
|
|
|
void main() {
|
|
final original = <String, dynamic>{
|
|
'url': 'https://authorized.invalid/synthetic.mp4',
|
|
'title': 'Synthetic fixture (not a decoder test)',
|
|
'contentType': 'video/mp4',
|
|
'positionMs': 1250,
|
|
'subtitleUrl': 'https://authorized.invalid/not-webvtt.srt',
|
|
};
|
|
|
|
late Directory root;
|
|
setUp(() async {
|
|
root = await Directory.systemTemp.createTemp('cast-share-test-');
|
|
});
|
|
tearDown(() async {
|
|
await root.delete(recursive: true);
|
|
});
|
|
|
|
test('explicit local-file lease serves only the exact file and revokes without deleting media', () async {
|
|
// Synthetic bytes exercise HTTP ownership, not audiovisual decoding.
|
|
final video = await File('${root.path}/synthetic.mp4')
|
|
.writeAsBytes([0, 1, 2, 3, 4, 5]);
|
|
await File('${root.path}/private.txt').writeAsString('do not share');
|
|
final lease = await CastMediaShare.prepare(
|
|
payload: original,
|
|
localFile: video.path,
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
);
|
|
addTearDown(lease.close);
|
|
final url = Uri.parse(lease.payload['url'] as String);
|
|
expect(url.scheme, 'http');
|
|
expect(url.pathSegments.first.length, greaterThanOrEqualTo(32));
|
|
expect((await fetch(url)).bytes, [0, 1, 2, 3, 4, 5]);
|
|
expect((await fetch(url, range: 'bytes=2-4')).bytes, [2, 3, 4]);
|
|
for (final forbidden in [
|
|
url.resolve('./'),
|
|
url.resolve('private.txt'),
|
|
url.resolve('package.json'),
|
|
url.replace(path: '/media.mp4'),
|
|
]) {
|
|
expect((await fetch(forbidden)).status, 404);
|
|
}
|
|
expect(lease.payload['title'], original['title']);
|
|
expect(lease.payload['positionMs'], 1250);
|
|
expect(original['url'], startsWith('https://'));
|
|
await Future.wait([lease.close(), lease.close()]);
|
|
await expectLater(fetch(url), throwsA(isA<SocketException>()));
|
|
expect(await video.readAsBytes(), [0, 1, 2, 3, 4, 5]);
|
|
expect(
|
|
await File('${root.path}/private.txt').readAsString(),
|
|
'do not share',
|
|
);
|
|
});
|
|
|
|
test('remote video plus selected text serves actual UTF8 WebVTT only and deletes disposable files', () async {
|
|
final before = await sessionTemps();
|
|
final lease = await CastMediaShare.prepare(
|
|
payload: original,
|
|
subtitleText: '1\n00:00:01,000 --> 00:00:02,000\nسلام',
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
);
|
|
addTearDown(lease.close);
|
|
expect(lease.payload['url'], original['url']);
|
|
final url = Uri.parse(lease.payload['subtitleUrl'] as String);
|
|
final response = await fetch(url);
|
|
expect(response.type, startsWith('text/vtt'));
|
|
expect(
|
|
utf8.decode(response.bytes),
|
|
'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nسلام\n\n',
|
|
);
|
|
final owned = (await sessionTemps()).difference(before);
|
|
expect(owned, hasLength(1));
|
|
expect(owned.single.startsWith(root.path), isFalse);
|
|
expect(
|
|
(await Directory(owned.single).list().toList()).single.path,
|
|
endsWith('.vtt'),
|
|
);
|
|
await lease.close();
|
|
expect(await sessionTemps(), before);
|
|
await expectLater(fetch(url), throwsA(isA<SocketException>()));
|
|
});
|
|
|
|
test('local file and selected text share separate actual VTT and media resources', () async {
|
|
final video = await File('${root.path}/synthetic.webm')
|
|
.writeAsString('synthetic media fixture');
|
|
final lease = await CastMediaShare.prepare(
|
|
payload: original,
|
|
localFile: video.path,
|
|
subtitleText: 'WEBVTT\n\n00:00.000 --> 00:01.000\n<b>caption</b>',
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
);
|
|
addTearDown(lease.close);
|
|
final videoUri = Uri.parse(lease.payload['url'] as String);
|
|
final subUri = Uri.parse(lease.payload['subtitleUrl'] as String);
|
|
expect(subUri.pathSegments.first, videoUri.pathSegments.first);
|
|
expect(subUri, isNot(videoUri));
|
|
expect(utf8.decode((await fetch(subUri)).bytes), contains('\ncaption\n'));
|
|
await lease.close();
|
|
expect(await video.exists(), isTrue);
|
|
});
|
|
|
|
test('invalid local selections fail rather than quietly loading the remote fallback', () async {
|
|
final empty = await File('${root.path}/empty.mp4').create();
|
|
final unsupported = await File('${root.path}/notes.txt')
|
|
.writeAsString('not video');
|
|
for (final path in [
|
|
'relative.mp4',
|
|
'${root.path}/missing.mp4',
|
|
root.path,
|
|
empty.path,
|
|
unsupported.path,
|
|
]) {
|
|
await expectLater(
|
|
CastMediaShare.prepare(
|
|
payload: original,
|
|
localFile: path,
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
),
|
|
throwsA(
|
|
anyOf(
|
|
isA<ArgumentError>(),
|
|
isA<FileSystemException>(),
|
|
isA<UnsupportedError>(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('unsupported selected subtitles and failed server preparation leave no temp data', () async {
|
|
final before = await sessionTemps();
|
|
await expectLater(
|
|
CastMediaShare.prepare(
|
|
payload: original,
|
|
subtitleText: '<tt>unsupported</tt>',
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
),
|
|
throwsFormatException,
|
|
);
|
|
expect(await sessionTemps(), before);
|
|
await expectLater(
|
|
CastMediaShare.prepare(
|
|
payload: original,
|
|
subtitleText: '1\n00:00:00,000 --> 00:00:01,000\nSynthetic',
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
advertisedHost: '8.8.8.8',
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
expect(await sessionTemps(), before);
|
|
});
|
|
|
|
test('complete indexed package shares only rewritten manifests and validated resources', () async {
|
|
final package = await syntheticPackage(root);
|
|
await File('${package.directory}/private.txt').writeAsString('private');
|
|
final lease = await CastMediaShare.prepare(
|
|
payload: original,
|
|
localFile: package.path,
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
);
|
|
addTearDown(lease.close);
|
|
final uri = Uri.parse(lease.payload['url'] as String);
|
|
expect(uri.scheme, 'http');
|
|
expect(
|
|
utf8.decode((await fetch(uri)).bytes),
|
|
package.manifests[package.manifest],
|
|
);
|
|
expect(
|
|
utf8.decode((await fetch(uri.resolve('playlist_1.m3u8'))).bytes),
|
|
contains('resource_0.ts'),
|
|
);
|
|
expect(
|
|
utf8.decode((await fetch(uri.resolve('resource_0.ts'))).bytes),
|
|
'synthetic resource',
|
|
);
|
|
expect((await fetch(uri.resolve('package.json'))).status, 404);
|
|
expect((await fetch(uri.resolve('private.txt'))).status, 404);
|
|
await lease.close();
|
|
expect(await File(package.path).exists(), true);
|
|
expect(await File('${package.directory}/resource_0.ts').exists(), true);
|
|
});
|
|
test('damaged package is not advertised as Cast-ready', () async {
|
|
final package = await syntheticPackage(root);
|
|
await File('${package.directory}/resource_0.ts').writeAsString('corrupt');
|
|
await expectLater(
|
|
CastMediaShare.prepare(
|
|
payload: original,
|
|
localFile: package.path,
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
test(
|
|
'remote-only lease copies payload and never passes through an SRT URL',
|
|
() async {
|
|
final lease = await CastMediaShare.prepare(payload: original);
|
|
expect(lease.payload, {...original}..remove('subtitleUrl'));
|
|
expect(original['subtitleUrl'], endsWith('.srt'));
|
|
await lease.close();
|
|
await lease.close();
|
|
},
|
|
);
|
|
test(
|
|
'public injectable lease constructor closes its resource only once',
|
|
() async {
|
|
var closed = 0;
|
|
final lease = CastMediaLease(
|
|
payload: original,
|
|
onClose: () async {
|
|
closed++;
|
|
},
|
|
);
|
|
await Future.wait([lease.close(), lease.close()]);
|
|
expect(closed, 1);
|
|
expect(lease.payload, original);
|
|
expect(identical(lease.payload, original), isFalse);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<OfflinePackage> syntheticPackage(Directory root) async {
|
|
final dir = await Directory('${root.path}/pkg').create();
|
|
final bytes = utf8.encode('synthetic resource');
|
|
final package = OfflinePackage(
|
|
id: 'pkg',
|
|
url: 'https://authorized.invalid/root.m3u8',
|
|
directory: dir.path,
|
|
manifest: 'playlist_0.m3u8',
|
|
resources: [
|
|
OfflineResource(
|
|
url: 'https://authorized.invalid/segment.ts',
|
|
filename: 'resource_0.ts',
|
|
bytes: bytes.length,
|
|
digest: sha256.convert(bytes).toString(),
|
|
),
|
|
],
|
|
manifests: {
|
|
'playlist_0.m3u8':
|
|
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nplaylist_1.m3u8\n',
|
|
'playlist_1.m3u8': '#EXTM3U\n#EXTINF:1,\nresource_0.ts\n#EXT-X-ENDLIST\n',
|
|
},
|
|
status: 'complete',
|
|
);
|
|
await File('${dir.path}/resource_0.ts').writeAsBytes(bytes);
|
|
for (final entry in package.manifests.entries) {
|
|
await File('${dir.path}/${entry.key}').writeAsString(entry.value);
|
|
}
|
|
await File('${dir.path}/package.json')
|
|
.writeAsString(jsonEncode(package.toJson()));
|
|
return package;
|
|
}
|
|
|
|
Future<({int status, List<int> bytes, String type})> fetch(
|
|
Uri uri, {
|
|
String? range,
|
|
}) async {
|
|
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
|
|
try {
|
|
final request = await client.getUrl(uri);
|
|
if (range != null) request.headers.set('range', range);
|
|
final response = await request.close();
|
|
final bytes = await response.fold<List<int>>([], (a, b) => a..addAll(b));
|
|
return (
|
|
status: response.statusCode,
|
|
bytes: bytes,
|
|
type: response.headers.value('content-type') ?? '',
|
|
);
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
}
|
|
|
|
Future<Set<String>> sessionTemps() async =>
|
|
(await Directory.systemTemp
|
|
.list(followLinks: false)
|
|
.where(
|
|
(f) =>
|
|
f is Directory &&
|
|
f.uri.pathSegments
|
|
.where((s) => s.isNotEmpty)
|
|
.last
|
|
.startsWith('reelbox-cast-'),
|
|
)
|
|
.toList())
|
|
.map((f) => f.path)
|
|
.toSet();
|