Skip to content
Merged
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
58 changes: 48 additions & 10 deletions mobile/lib/blocs/video_editor/audio_timing/audio_timing_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,28 @@ class AudioTimingCubit extends Cubit<AudioTimingState> {
);
final clipEnd = Duration(milliseconds: (clipEndSecs * 1000).toInt());

// Determine URI and whether it's an asset
final String uri;
final bool isAsset;
// Determine the URI and which AudioSourceConfig variant fits.
//
// Audio extracted from local clips (see
// ClipEditorBloc._onAudioExtractionRequested) is stored in
// AudioEvent.url as a bare absolute path like
// `/var/mobile/.../extracted_audio_<ts>.wav`. Without explicit
// classification, that path is treated as a network URL and the
// remote loader calls HttpClient.getUrl on it, throwing
// "No host specified in URI" (issue #4395).
final AudioSourceConfig config;
if (_sound.isBundled && _sound.assetPath != null) {
uri = _sound.assetPath!;
isAsset = true;
config = AudioSourceConfig.asset(
_sound.assetPath!,
start: clipStart,
end: clipEnd,
);
} else if (_sound.url != null) {
uri = _sound.url!;
isAsset = false;
config = _configForUrl(
_sound.url!,
start: clipStart,
end: clipEnd,
);
} else {
Log.warning(
'No audio source available for sound: ${_sound.id}',
Expand All @@ -224,12 +237,37 @@ class AudioTimingCubit extends Cubit<AudioTimingState> {
return;
}

final config = isAsset
? AudioSourceConfig.asset(uri, start: clipStart, end: clipEnd)
: AudioSourceConfig.network(uri, start: clipStart, end: clipEnd);
await _clipPlayer.setClip(config);
}

/// Classifies a raw URL string from [AudioEvent.url] into the appropriate
/// [AudioSourceConfig] variant.
///
/// Supports http(s) network URLs and local file paths (bare absolute paths
/// like `/var/mobile/...` or `file://` URIs). Platform-specific schemes
/// such as Android `content://` and web `blob:` are not handled and will
/// fall through to the network variant, where the defense-in-depth check
/// in [AudioClipPlayer] will surface a clear [ArgumentError].
static AudioSourceConfig _configForUrl(
String url, {
required Duration start,
required Duration end,
}) {
final parsed = Uri.tryParse(url);
final isLocalFile =
parsed == null ||
parsed.scheme.isEmpty ||
parsed.scheme == 'file' ||
url.startsWith('/');
if (isLocalFile) {
final filePath = parsed != null && parsed.scheme == 'file'
? parsed.toFilePath()
: url;
return AudioSourceConfig.file(filePath, start: start, end: end);
}
return AudioSourceConfig.network(url, start: start, end: end);
}

@override
Future<void> close() async {
await _completionSubscription?.cancel();
Expand Down
15 changes: 15 additions & 0 deletions mobile/packages/sound_service/lib/src/audio_clip_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ class AudioClipPlayer {
File? cachedFile,
Uri? cachedUri,
) async {
// Defense-in-depth for issue #4395: callers that miscategorize a
// local file path as a network source would otherwise reach
// HttpClient.getUrl with a schemeless / file:// URI and crash with
// "No host specified in URI". Reject anything that is not http(s)
// up front with an actionable error. Note: platform-specific schemes
// such as Android content:// and web blob: are also rejected here.
if (uri.scheme != 'http' && uri.scheme != 'https') {
throw ArgumentError.value(
uri,
'uri',
'Remote audio loader requires an http(s) URI; got scheme '
'"${uri.scheme}". Use AudioSourceConfig.file for local paths.',
);
}

if (cachedFile != null && cachedUri == uri && cachedFile.existsSync()) {
return cachedFile;
}
Expand Down
48 changes: 48 additions & 0 deletions mobile/packages/sound_service/test/src/audio_clip_player_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,54 @@ void main() {

verify(() => mockAudioPlayer.setAudioSource(any())).called(1);
});

// Regression coverage for #4395: a caller that mis-classifies a
// local file path as network audio must hit an actionable
// ArgumentError before the default loader reaches HttpClient.
test(
'default network loader rejects non-http(s) URIs with ArgumentError',
() async {
final mockHttpClient = _MockHttpClient();

await expectLater(
HttpOverrides.runZoned(
() => player.setClip(
const AudioSourceConfig.network(
'/var/mobile/extracted_audio.wav',
start: Duration.zero,
end: Duration(seconds: 2),
),
),
createHttpClient: _httpClientFactory(mockHttpClient),
),
throwsA(isA<ArgumentError>()),
);

verifyNever(() => mockHttpClient.getUrl(any()));
},
);

test('AudioSourceConfig.file never invokes the remote loader', () async {
var loaderCallCount = 0;
player = AudioClipPlayer(
audioPlayer: mockAudioPlayer,
remoteAudioFileLoader: (_, _, _) async {
loaderCallCount++;
throw StateError('remote loader must not be called');
},
);

await player.setClip(
const AudioSourceConfig.file(
'/var/mobile/extracted_audio.wav',
start: Duration.zero,
end: Duration(seconds: 2),
),
);

expect(loaderCallCount, 0);
verify(() => mockAudioPlayer.setAudioSource(any())).called(1);
});
});

group('play', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,75 @@ void main() {
verify(() => mockClipPlayer.play()).called(1);
},
);

// Regression coverage for #4395: extracted audio is stored in
// AudioEvent.url as a bare absolute path; it must be routed
// through AudioSourceConfig.file, not .network.
blocTest<AudioTimingCubit, AudioTimingState>(
'uses AudioSourceConfig.file for a bare absolute path',
build: () => buildCubit(
sound: _createTestSound(
url:
'/var/mobile/Containers/Data/Application/'
'tmp/extracted_audio_123.wav',
),
),
seed: () => const AudioTimingState(audioDuration: 20),
act: (cubit) => cubit.resumePlayback(),
verify: (_) {
final captured =
verify(() => mockClipPlayer.setClip(captureAny())).captured.single
as AudioSourceConfig;
expect(captured.isAsset, isFalse);
expect(captured.isFile, isTrue);
expect(captured.uri, startsWith('/var/mobile/'));
},
);

blocTest<AudioTimingCubit, AudioTimingState>(
'uses AudioSourceConfig.file for a file:// URI',
build: () => buildCubit(
sound: _createTestSound(url: 'file:///tmp/extracted_audio.wav'),
),
seed: () => const AudioTimingState(audioDuration: 20),
act: (cubit) => cubit.resumePlayback(),
verify: (_) {
final captured =
verify(() => mockClipPlayer.setClip(captureAny())).captured.single
as AudioSourceConfig;
expect(captured.isFile, isTrue);
expect(captured.uri, '/tmp/extracted_audio.wav');
},
);

blocTest<AudioTimingCubit, AudioTimingState>(
'uses AudioSourceConfig.network for an https URL',
build: () => buildCubit(sound: _createTestSound()),
seed: () => const AudioTimingState(audioDuration: 20),
act: (cubit) => cubit.resumePlayback(),
verify: (_) {
final captured =
verify(() => mockClipPlayer.setClip(captureAny())).captured.single
as AudioSourceConfig;
expect(captured.isAsset, isFalse);
expect(captured.isFile, isFalse);
expect(captured.uri, 'https://example.com/audio.mp3');
},
);

blocTest<AudioTimingCubit, AudioTimingState>(
'uses AudioSourceConfig.asset for a bundled sound',
build: () => buildCubit(sound: _createBundledSound()),
seed: () => const AudioTimingState(audioDuration: 20),
act: (cubit) => cubit.resumePlayback(),
verify: (_) {
final captured =
verify(() => mockClipPlayer.setClip(captureAny())).captured.single
as AudioSourceConfig;
expect(captured.isAsset, isTrue);
expect(captured.isFile, isFalse);
},
);
});

group('stopPlayback', () {
Expand Down
Loading