Skip to content

fix(android): reply exactly once to preparePlayer (upstream #488) - #1

Open
yagoquesadafloriach wants to merge 11 commits into
upstream-1.2.0from
1.2.0-lh1
Open

fix(android): reply exactly once to preparePlayer (upstream #488)#1
yagoquesadafloriach wants to merge 11 commits into
upstream-1.2.0from
1.2.0-lh1

Conversation

@yagoquesadafloriach

@yagoquesadafloriach yagoquesadafloriach commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

Kills the Android IllegalStateException: Reply already submitted crash from preparePlayer, and adds a PlayerController.onPlayerError stream for playback failures that arrive too late to be thrown.

Why

nº3 Android crash in Summary AI: 618 fatal events / 282 users over 30 days, present since app 2.5.2 (Crashlytics 10820c7c).

preparePlayer hands its one-shot MethodChannel.Result to an ExoPlayer listener that outlives the call. It replies success on STATE_READY, then replies again with error on any later onPlayerError. Second reply, main thread, fatal.

Upstream #488 has been open since April and its fix #495 since June. Upstream main (2.0.2) still contains the bare result.error(...), so upgrading doesn't fix this — hence the fork.

How

  • hasReplied guards both reply sites, and the previous player is torn down before it's replaced so a detached listener can't answer a spent result.
  • The interesting case is a failure arriving after prepare succeeded, where the result is genuinely spent and there's nothing left to reject. We emit a new onPlayerError channel event and move the player to stopped. Two things fix: 🐛 Prevent crashes and ANR on network loss during playback SimformSolutionsPvtLtd/audio_waveforms#495 does that we deliberately skipped: a network retry loop (we play local files, and a truncated recording matches its ERROR_CODE_IO_UNSPECIFIED predicate — 5 × 3s of silence before anything is reported), and reporting finishType: 2, which Dart turns into a completion event so a broken file reads as "finished normally".
  • Also backports #420: setFinishMode never replied on success, so await controller.setFinishMode(...) hung forever. Same one-shot-result defect family.
  • Base is upstream-1.2.0, which is upstream af6bc5a8 verbatim — the ref audio_app pins. Not this fork's main, which sits ~9 months of upstream history behind and would bury the diff.

Risks

  • WaveformExtractor.kt is untouched and byte-identical to upstream 1.2.0. An earlier revision of this branch rewrote it; that was reverted in 3604ce5 once it turned out upstream had already fixed all of it (#409, #414, #431). Take those by upgrading to 2.0.x, not by reimplementing on an old base.
  • Two defects knowingly left in: stopAllPlayers orphans its players, and MediaCodec.setCallback with a null handler dispatches on the main thread. Both also exist in 2.0.2 and neither is reachable from audio_app, so fixing them would mean diverging for no gain.

yagoquesadafloriach and others added 11 commits August 7, 2026 12:21
preparePlayer captured the one-shot MethodChannel.Result inside a
Player.Listener that stays attached for the player's whole lifetime. The
success path was guarded by isPlayerPrepared; the error path was guarded by
nothing, so a playback failure after a successful prepare called result.error
on a Result that had already been answered. Flutter's DartMessenger enforces
one reply per message with an atomic flag and throws IllegalStateException
"Reply already submitted" on the second — on the main thread, inside a
listener callback, so the process dies.

- Add hasReplied, reset at the top of preparePlayer, and guard both
  result.success(true) and result.error(...) with it. Once isPlayerPrepared is
  true, onPlayerError no longer touches result at all.
- Tear the previous player down at the top of preparePlayer via the new no-arg
  stop(). The old listener still captures the previous, already-answered
  Result; leaving it attached let the old player reply to it. This also resets
  isPlayerPrepared, so re-preparing the same key can reply again instead of
  leaving the Dart future pending forever (the "dead play button").
- release() now detaches the listener and nulls the player. A listener left
  bound to a released player can still receive queued events, which is a
  second route to a late reply.
- stopAllPlayers replied once per player via stop(result) and then once more
  after the loop: a second, independent route to the same fatal on the same
  channel. It now uses the no-arg stop() and replies exactly once.
- Post-prepare failures can no longer be returned through the spent Result, so
  surface them on a new onPlayerError channel event, exposed in Dart as
  PlayerController.onPlayerError, and move the player to stopped. Upstream
  SimformSolutionsPvtLtd#495 fires onDidFinishPlayingAudio with finishType 2 here, which Dart turns
  into a completion event — a broken file would read as "finished normally".

Port of upstream PR SimformSolutionsPvtLtd#495 (Closes SimformSolutionsPvtLtd#488) onto af6bc5a.
Refs: SimformSolutionsPvtLtd#495

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WaveformExtractor replied to its Result from four places with no coordination
between them: the plugin's onProgress == 1.0F callback, MediaCodec.onError, the
input-buffer failure path, and the pre-decode catch. Any two of them firing for
one extractWaveformData call throws "Reply already submitted". This is the
variant reported in SimformSolutionsPvtLtd#375, which the preparePlayer guard does not cover.

- Route every reply through submitWaveformData()/submitError(), which claim
  isReplySubmitted under a lock so the first reply wins and later ones are
  no-ops.
- Post replies and onCurrentExtractedWaveformData on the main handler. Codec
  callbacks arrive on a codec-owned thread and Flutter channels are main-thread
  only, so these were already being invoked off the platform thread.
- Add the @volatile released flag and take the lock in the codec callbacks, so
  a callback that lands during teardown bails out instead of touching a freed
  codec or extractor. stop() claims the codec under the lock and releases
  outside it — MediaCodec.stop()/release() drains in-flight callbacks that
  contend for the same lock — and off the callback thread, wrapping each call
  so an already-released codec cannot crash teardown.
- Reply at EOF as well. Previously a file whose sample count never reached the
  expected point count left the Dart future pending forever.
- The plugin called stop() on the *new* extractor right after startDecode().
  That was harmless only because stop() early-returned on a `started` flag that
  was never set to true, so the codec was never released at all. It now tears
  down the previous extractor before replacing it, matching upstream main.

Port of upstream PR SimformSolutionsPvtLtd#495 onto af6bc5a.
Refs: SimformSolutionsPvtLtd#495

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MediaExtractor.setDataSource() and getTrackFormat() block synchronously, and
for a large or unreadable file they can stall for seconds. startDecode() runs
from the platform method-channel handler, so that stall was on the Android main
thread: UI freeze, and an ANR if it ran long enough.

Move the setup onto a decode thread and interrupt it from stop(). The reply and
teardown paths were already made thread-safe in the previous commit, which is
what makes this safe to move off the platform thread.

Port of upstream PR SimformSolutionsPvtLtd#495 onto af6bc5a.
Refs: SimformSolutionsPvtLtd#495

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tLtd#495

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction callback resolved its target with extractors[playerKey] at the
moment it fired. Since decode now runs on its own thread, a late callback from
a superseded extractor resolved to whichever extractor currently occupies the
key and replied to that one's Result — answering the wrong call and leaving the
superseded call pending forever. Close over the extractor instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream already fixed every defect this addressed, so carrying a
hand-rolled version on a 1.2.0 base duplicated merged work and added
maintenance surface for a code path this app never reaches
(`shouldExtractWaveform: false` at both call sites).

- SimformSolutionsPvtLtd#375, the extractor's double-reply, was closed by SimformSolutionsPvtLtd#409 in 1.3.0;
  `main` carries an `isReplySubmitted` guard.
- Cancelling a superseded extraction was fixed by SimformSolutionsPvtLtd#414.
- Codec-teardown crashes were fixed by SimformSolutionsPvtLtd#431.

Reverts dd40b59, a09b3dd and 9bb09db. `WaveformExtractor.kt` is now
byte-identical to upstream 1.2.0. The `stopAllPlayers` reply fix from
32617c6 is untouched — that one is a genuine second route to SimformSolutionsPvtLtd#488.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backport of upstream SimformSolutionsPvtLtd#420, merged after 1.2.0 and so missing from this
base. `setFinishMode` never replied on success, so a caller awaiting
`controller.setFinishMode(...)` waited forever. The plugin's dispatch for
it had the same defect from a different angle: a nested `?.let` fell
through silently when the key was null or no player was registered.

Same one-shot-Result defect family as SimformSolutionsPvtLtd#488, and live for any caller that
awaits it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records why this fork exists at all: SimformSolutionsPvtLtd#488 is open, SimformSolutionsPvtLtd#495 is open, and
upstream main (2.0.2) still carries the bare `result.error(...)` in
`onPlayerError`, so upgrading does not resolve the crash.

Also documents what was deliberately dropped and why, so nobody
reimplements the extractor work that SimformSolutionsPvtLtd#409/SimformSolutionsPvtLtd#414/SimformSolutionsPvtLtd#431 already merged.

Bumps the version to 1.2.0-lh1 so the lockfile and build output identify
the fork rather than reporting plain 1.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…— REVERT BEFORE MERGE

The natural trigger for SimformSolutionsPvtLtd#488 (ExoPlayer failing *after* prepare already
answered its Result) cannot be driven from the UI, so a clean run proves
nothing on its own. This adds a synthetic late-error injection plus a
switch that restores the pre-fix reply path, so the crash can be
reproduced on demand as a negative control.

Drives the two-phase harness in the app repo at
scripts/test-reply-already-submitted.sh. Verified on a OnePlus A6003
(arm64-v8a, API 30): the control run reproduces the exact Crashlytics
10820c7c stack (DartMessenger$Reply.reply ->
MethodChannel$IncomingMethodCallHandler$1.error -> onPlayerError), and
the guarded run survives with zero fatals and delivers a PlayerError.

This commit is scaffolding, not product code. Revert it before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ORE MERGE

Lets the app-side ExoPlayer-code-to-exception mapping be exercised on a
real device instead of only in unit tests.

Second half of the scaffold; revert alongside a522a07 before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts a522a07 and dacad52. The scaffold existed to reproduce SimformSolutionsPvtLtd#488 on
demand and to exercise the app-side error-code mapping on a real device.
Both are done, so the injection, the guard bypass and their logging come
out.

AudioPlayer.kt is restored to exactly the state the v1.2.0-lh1 tag points
at, so the app's pin resolves to an identical tree and does not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yagoquesadafloriach
yagoquesadafloriach marked this pull request as ready for review August 12, 2026 12:55
@yagoquesadafloriach yagoquesadafloriach self-assigned this Aug 12, 2026
@yagoquesadafloriach yagoquesadafloriach added the bug Something isn't working label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant