Skip to content

fix(audio): route extracted audio paths to AudioSourceConfig.file (#4395)#4549

Merged
hm21 merged 2 commits into
mainfrom
fix/4395-extracted-audio-local-source
May 20, 2026
Merged

fix(audio): route extracted audio paths to AudioSourceConfig.file (#4395)#4549
hm21 merged 2 commits into
mainfrom
fix/4395-extracted-audio-local-source

Conversation

@hm21
Copy link
Copy Markdown
Contributor

@hm21 hm21 commented May 19, 2026

Problem

Crashlytics issue e81e2bc1d6713fa5d91e7b15a0950e18 (16 events, 2 users, v1.0.13):

StateError: Bad state: No host specified in URI ...extracted_audio_xxx.wav
  _HttpClient.getUrl
  AudioClipPlayer._defaultRemoteAudioFileLoader
  AudioClipPlayer.setClip
  AudioTimingCubit._setClippedAudioSource

AudioEvent.url is overloaded: it contains either network URLs or bare absolute file paths (e.g. from clip_editor_bloc.dart via extractAudio(videoPath)). AudioTimingCubit classified everything non-bundled as .network; the default remote loader passed the path without scheme validation to HttpClient.getUrl.

Fix

  • AudioTimingCubit (primary): three-way classification in _setClippedAudioSource

    • bundled → AudioSourceConfig.asset
    • file:// URI or bare absolute path (starts with /) → AudioSourceConfig.file (decoded via Uri.toFilePath())
    • otherwise → AudioSourceConfig.network
  • AudioClipPlayer (defense-in-depth): _defaultRemoteAudioFileLoader now throws ArgumentError for non-http(s) schemes instead of silently passing a broken URI to HttpClient.getUrl.

Closes #4395

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

)

Crashlytics issue e81e2bc1d6713fa5d91e7b15a0950e18: extracted audio paths (bare absolute paths produced by clip_editor_bloc via extractAudio(videoPath)) were classified as .network in AudioTimingCubit and passed to HttpClient.getUrl, crashing with 'No host specified in URI'.

Fix:
- AudioTimingCubit: three-way classification in _setClippedAudioSource:
  bundled -> AudioSourceConfig.asset
  file:// URI or bare absolute path (starts with '/') -> AudioSourceConfig.file (with Uri.toFilePath decoding)
  otherwise -> AudioSourceConfig.network
- AudioClipPlayer: defense-in-depth guard in _defaultRemoteAudioFileLoader throws ArgumentError for non-http(s) schemes.

Tests:
- 4 new blocTests in audio_timing_cubit_test.dart (bare path, file://, https, bundled).
- 2 new tests in audio_clip_player_test.dart (guard throws, file source skips remote loader).
@github-actions

This comment has been minimized.

…ob: in guard comment

- Extract inline URL classification logic in AudioTimingCubit into a
  private static _configForUrl helper (cleaner _setClippedAudioSource).
- Add a note to the ArgumentError guard in AudioClipPlayer that
  platform-specific schemes (Android content://, web blob:) are also
  rejected, not just bare paths and file:// URIs.
@hm21 hm21 self-assigned this May 19, 2026
@github-actions

This comment has been minimized.

@hm21 hm21 marked this pull request as ready for review May 19, 2026 08:05
@github-actions
Copy link
Copy Markdown

Mobile PR Preview

Preview refreshed for c8dffad

Last refresh: c8dffad at 2026-05-19 08:08:59 UTC (preview run)

Property Value
Preview URL https://8688824f.openvine-app.pages.dev
Pages project openvine-app
Preview branch pr-4549
PR branch fix/4395-extracted-audio-local-source
Commit c8dffad

Copy link
Copy Markdown
Member

@rabble rabble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #4549 — fix(audio): route extracted audio paths to AudioSourceConfig.file

Verdict: Approve

Targeted, well-scoped fix for Crashlytics issue #4395 (StateError: No host specified in URI). The diagnosis is correct and traceable end-to-end.

Correctness

  • Root cause confirmed. ClipEditorBloc._onAudioExtractionRequested (line 499–512) constructs AudioEvent with url: result.audioFilePath, where audioFilePath is a bare absolute path like /var/mobile/.../tmp/extracted_audio_<ts>.wav from AudioExtractionService (line 158). AudioTimingCubit._setClippedAudioSource previously force-routed any non-bundled source through AudioSourceConfig.network, which _defaultRemoteAudioFileLoader then handed to HttpClient.getUrl — boom.
  • Three-way classification is the right shape. AudioSourceConfig.file already exists in sound_service and routes correctly to AudioSource.file(config.uri) in audio_clip_player.dart:70. file:// URIs are decoded via Uri.toFilePath() (handles URI-encoded path segments correctly). Bare absolute path detection via startsWith('/') matches the actual extraction output.
  • Layering is correct. The classification lives in the cubit that knows the AudioEvent contract — not in the UI, not leaked into the player. The repository/source-of-truth layer here is the AudioEvent model, and the cubit is the appropriate translator.

Defense-in-depth (good)

  • AudioClipPlayer._defaultRemoteAudioFileLoader now throws ArgumentError for non-http(s) schemes with an actionable message naming AudioSourceConfig.file. This is not a silent fallback — it surfaces future regressions loudly at the boundary, which is exactly what error_handling.md asks for. Android content:// and web blob: are explicitly called out in the doc comment as falling through to this rejection path; that is a reasonable scoping decision for the current fix.

Test coverage

Strong regression coverage at both layers:

  • audio_timing_cubit_test.dart: bare absolute path, file:// URI, http(s) URL — three new blocTests asserting captured.isFile / captured.isAsset and uri shape.
  • audio_clip_player_test.dart: asserts ArgumentError thrown for non-http(s) network config and that HttpClient.getUrl is never called (verifyNever); separate test asserts AudioSourceConfig.file never invokes the remote loader at all.

Both layers have an explicit "this must not silently break again" regression test.

Minor observations (non-blocking)

  • The bare-absolute-path heuristic (url.startsWith('/')) is Unix-specific. Web/Windows paths are not affected here because the extracted audio path comes from path_provider's tempDir on iOS/Android (always starts with /), and the existing in-app AudioEvent sources are http(s). If a future caller stuffs a Windows path or content:// URI into AudioEvent.url, the defense-in-depth ArgumentError will catch it — which is the right failure mode.
  • The PR description's noted out-of-scope schemes (content://, blob:) are documented in both the helper's dartdoc and the loader's reject message, so the omission is intentional and traceable.

Project conventions

  • No hardcoded strings outside what's already in AudioEvent.
  • BLoC-layer logic, no UI leakage.
  • Reportable-error policy: ArgumentError from a defense-in-depth boundary is appropriate; it signals a programming-invariant violation by the caller (per error_handling.md matrix → reportable). No state pollution with error strings.
  • Tests follow blocTest + captureAny mocktail patterns used elsewhere in this file.

Risk

Low. The change is additive in semantics (a previously-broken code path now works) and the network path is unchanged for valid http(s) URLs. CI is green (13/13).

Copy link
Copy Markdown
Member

@rabble rabble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of c8dffad: refactor(audio): extract _configForUrl helper

Verdict: Approve (re-approve)

Pure code-organization refactor on top of the already-approved fix. No behavior change, all prior correctness guarantees preserved.

What c8dffad actually does

Two changes, both non-functional:

  1. audio_timing_cubit.dart — extracts the inline URL-classification block from _setClippedAudioSource into a static AudioSourceConfig _configForUrl(String url, {required Duration start, required Duration end}) helper. Byte-for-byte identical predicate (parsed == null || parsed.scheme.isEmpty || parsed.scheme == 'file' || url.startsWith('/')), identical Uri.toFilePath() decoding path, identical fallback to AudioSourceConfig.network.
  2. audio_clip_player.dart — appends one sentence to the guard's leading comment noting that Android content:// and web blob: are rejected by the same scheme check.

That's it. git diff between 3c771d2 and c8dffad touches exactly two files; no test files changed.

Correctness preservation

  • Logic equivalence: byte-for-byte same predicate and decoding. Only naming change is uri -> url parameter; the predicate uses both parsed Uri? and raw string identically. The parsed.scheme == 'file' ? parsed.toFilePath() : url branch matches the prior inline form exactly.
  • Three-way classification still lives at the right layer (the cubit that knows the AudioEvent contract). Bundled-asset branch is untouched and still constructed inline above the helper call — appropriate, since asset routing needs _sound.assetPath and a different config constructor.
  • Defense-in-depth boundary in AudioClipPlayer._defaultRemoteAudioFileLoader is unchanged; the doc comment addition is documentation-only and accurate (content:// and blob: both have non-http(s) schemes and will hit the ArgumentError branch).

Test coverage still binds the behavior

The four blocTests in audio_timing_cubit_test.dart (bare path -> .file, file:// -> .file, https -> .network, bundled -> .asset) exercise the same observable contract via cubit.resumePlayback() and the captured setClip argument. They route through _setClippedAudioSource which now calls _configForUrl, so the helper is covered transitively. No separate helper-level unit test was added; given the integration-level coverage exercises the same four branches, that's reasonable.

The two audio_clip_player_test.dart regressions (guard throws on non-http(s); .file skips the remote loader) still anchor the player-layer contract — the doc comment change cannot affect them.

Conventions

  • Private static helper with focused dartdoc explaining the supported / unsupported scheme matrix — matches code_style.md (comment the why: the unsupported-scheme fallthrough is intentional and traceable to the defense-in-depth ArgumentError).
  • Helper is at the right granularity: takes only url, start, end; returns the typed config. Not over-extracted.
  • No // TODO, no skipped tests, no behavior shift, no scope creep.

Risk

Zero net new risk. Refactor reduces the cyclomatic complexity of _setClippedAudioSource and makes the doc comment trail (cubit dartdoc -> player guard comment) align across both layers about which schemes are in vs out of scope. Re-approving.

@hm21 hm21 merged commit fd3354a into main May 20, 2026
13 checks passed
@hm21 hm21 deleted the fix/4395-extracted-audio-local-source branch May 20, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(crashlytics): treat extracted editor audio files as local sources

2 participants