Ensure MediaPlayer has dedicated thread owner that is not the main thread#3148
Conversation
• Updated TextureVideoView.release() in ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/video/VideoView.kt to:
• set released = true immediately,
• hide + clear controller,
• detach surface with player.setSurface(null),
• directly player.release() inside safely.
• Removed player.stop() and player.reset() from teardown (the blocking path shown in the stack trace).
This keeps teardown one-shot and avoids the reset()->MediaHTTPConnection.disconnect() main-thread stall during detach.
Validation run:
Introduce MediaPlayerFacade and AndroidMediaPlayerFacade to abstract Android MediaPlayer usage and improve testability. Move MediaPlayerThreadOwner out of VideoView into its own file and update it to use the facade with an injectable playerFactory, worker thread handling, and robust state caching. Make safely internal for broader use. Add unit tests (MediaPlayerThreadOwnerTests) and a FakeMediaPlayerFacade to verify non-blocking release and that all player operations are serialized on a single worker thread.
Remove MediaPlayerFacade and AndroidMediaPlayerFacade and refactor MediaPlayerThreadOwner to depend on android.media.MediaPlayer directly. Introduce PlaybackSnapshot to centralize and atomically update playback state (prepared, duration, position, isPlaying, audioSessionId) instead of multiple volatile cached fields. Update position ticker, player lifecycle, listeners, and ensurePlayer to work with MediaPlayer. Add safe snapshot update helpers and adjust threading/handler logic. VideoView now properly releases the attached Surface via a releaseAttachedSurface() helper to avoid leaks. Tests replaced the FakeMediaPlayerFacade with a createMockMediaPlayer helper using mockk, plus supporting utilities to capture listeners and record thread usage.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: MediaPlayer accessed on main thread in onPrepared callback
- Captured video width and height on the worker-thread onPrepared callback and passed those immutable values to the main-thread callback.
- ✅ Fixed: Non-atomic snapshot update from calling thread races with worker
- Moved the release snapshot mutation into the workerHandler cleanup block so all snapshot writes are serialized on the worker thread.
Or push these changes by commenting:
@cursor push 970ee3e688
Preview (970ee3e688)
diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/video/MediaPlayerThreadOwner.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/video/MediaPlayerThreadOwner.kt
--- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/video/MediaPlayerThreadOwner.kt
+++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/video/MediaPlayerThreadOwner.kt
@@ -126,6 +126,8 @@
mediaPlayer.setOnPreparedListener { preparedPlayer ->
if (released) return@setOnPreparedListener
val activePlayer = preparedPlayer ?: return@setOnPreparedListener
+ val videoWidth = getPlayerValue(activePlayer, 0) { currentPlayer -> currentPlayer.videoWidth }
+ val videoHeight = getPlayerValue(activePlayer, 0) { currentPlayer -> currentPlayer.videoHeight }
updatePlaybackSnapshot {
it.copy(
prepared = true,
@@ -140,7 +142,7 @@
}
mainHandler.post {
if (!released) {
- onPrepared(activePlayer.videoWidth, activePlayer.videoHeight)
+ onPrepared(videoWidth, videoHeight)
}
}
}
@@ -236,14 +238,14 @@
fun release() {
if (released) return
released = true
- updatePlaybackSnapshot {
- it.copy(
- prepared = false,
- isPlaying = false,
- )
- }
workerHandler.removeCallbacksAndMessages(null)
workerHandler.post {
+ updatePlaybackSnapshot {
+ it.copy(
+ prepared = false,
+ isPlaying = false,
+ )
+ }
val mediaPlayer = player
player = null
currentSurface = nullAdd defensive checks and logging to MediaPlayerThreadOwner to avoid crashes when operations occur before the player or surface are ready: cache video dimensions, warn when looping is set before initialization, handle null surface during prepare, and move playback snapshot clearing into the worker thread on release. Introduce a safely(...) helper to centralize try/catch logging. Adjust TextureVideoView.onSurfaceTextureDestroyed to avoid releasing resources if already released and ensure the player surface is cleared only when appropriate. Also add .cursor/.gitignore to ignore plans/.
Add clearSurfaceBlocking to MediaPlayerThreadOwner to synchronously detach a Surface from the player on the worker thread and wait for that detach to finish (using CountDownLatch). This preserves the ordering invariant that MediaPlayer.setSurface(null) completes before the caller can release the Surface, avoiding native rendering races. The method executes inline if called from the worker thread to avoid deadlocks. Factor out setSurfaceInternal and update TextureVideoView to use clearSurfaceBlocking when detaching surfaces. Add a unit test that verifies the blocking behavior and ordering.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3148 +/- ##
=======================================
Coverage 79.40% 79.40%
=======================================
Files 356 356
Lines 14345 14345
Branches 1959 1959
=======================================
Hits 11390 11390
Misses 2151 2151
Partials 804 804 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… into pw-996/thread-owner
|
I've tested it a bunch on my Pixel 7, doing more now on my Samsung, if I can't get an ANR, I think it's probably good to go |
|
The code looks good (from what I can understand), but I've noticed there is some inconsistency with the playback controls. Is there something we still need to update on the main thread? |
I tested on my Pixel 7 and my Galaxy fold and I'm not experiencing anything odd. The video doesn't exactly show what the inconsistency is that you're referencing. Would you be a bit more specific? |
Yes, sorry for my vague explanation! When there is a video component with the playback controls being shown, the initial UI of the controls is correct (presenting the ⏸️ pause icon when the video is playing). One thing to mention is that I've tested this on an emulator, as I don't have a physical device that runs Android. |
|
Ah, I tested it again and paid close attention to the symbols on the buttons, and I can replicate that. I will check to see if that is new from this PR or not and if I can dispatch to main to correct it. Great feedback. Thank you. |
Introduce a PendingPlaybackState and playbackCommandId to record intended playing state immediately when start()/pause() are called, so isPlaying() reflects the requested state even while the worker thread command is blocked. start() and pause() now mark a pending state (and early-return if released/unprepared), clear it on success or failure, and ensure pending state is cleared on prepare/release/stop paths. Added helper methods markPendingPlaybackState/clearPendingPlaybackState and adjusted isPlaying() to prefer the pending state. Tests added to verify isPlaying() returns the expected value immediately after start/pause when the worker command is blocked, plus a waitForCondition helper used by tests.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Remove the private 'looping' property from TextureVideoView and the redundant assignment in setLooping. The looping state is delegated to playerOwner.setLooping, so the field was unused; this is a small cleanup with no behavior change.
Replace eager releaseAttachedSurface() with takeAttachedSurface() to hand off the existing Surface to MediaPlayerThreadOwner instead of releasing it directly. TextureVideoView now defensively clears any stale surface on texture available, captures the attached surface before detaching, and forwards that Surface to playerOwner.clearSurfaceBlocking(...) and playerOwner.release(...). This avoids double-releasing the Surface and lets the player owner perform asynchronous or timed releases. Tests updated/added to mock a handed-off Surface, assert it is released exactly once, and verify clearSurfaceBlocking's timeout behavior and asynchronous release semantics. Also added an AtomicInteger import and adjusted expected event ordering in tests.
**This is an automatic release.** ## RevenueCat SDK ### 🐞 Bugfixes * [EXTERNAL] fix: ensure activity is attached before showing in-app messages (#3274) contributed by @matteinn (#3275) via Toni Rico (@tonidero) * Ensure MediaPlayer has dedicated thread owner that is not the main thread (#3148) via Jacob Rakidzich (@JZDesign) * Fix heartbeat monitor and Slack notifications for nightly integration tests (#3259) via Rick (@rickvdl) ## RevenueCatUI SDK ### Paywallv2 #### ✨ New Features * Feat: Restore gating in paywalls UI (#3171) via Jacob Rakidzich (@JZDesign) ### 🔄 Other Changes * security: pin GitHub Actions to SHA hashes (#3272) via Alfonso Embid-Desmet (@alfondotnet) * Bump activesupport from 8.0.2 to 8.0.4.1 (#3270) via dependabot[bot] (@dependabot[bot]) * Merge release PR after deploy (#3269) via Antonio Pallares (@ajpallares) * Require PR approval before release tagging (#3268) via Antonio Pallares (@ajpallares) * Bump json from 2.18.1 to 2.19.2 (#3261) via dependabot[bot] (@dependabot[bot]) * feat(ads): update admob sample app (#3264) via Peter Porfy (@peterporfy) * feat(ads): add vanilla-ad-tracker-sample (#3263) via Peter Porfy (@peterporfy) * [Purchase Tester]: Persist appUserId on login screen across app launches (#3266) via Will Taylor (@fire-at-will) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Mostly release automation and versioning changes, but it modifies CI/orb references and deploy/merge automation, which could affect the release pipeline if misconfigured. > > **Overview** > Cuts the `9.28.0` release by removing `-SNAPSHOT` across version sources (`.version`, `gradle.properties`, `Config.frameworkVersion`) and updating sample/test-app dependency pins to `9.28.0`. > > Updates release documentation: publishes Dokka docs to the `9.28.0` S3 path, updates `docs/index.html` redirect to `9.28.0`, and rolls `CHANGELOG.latest.md` into a new `9.28.0` section in `CHANGELOG.md`. > > Tweaks release tooling/CI: pins `fastlane-plugin-revenuecat_internal` to a specific git ref (and bumps a few Ruby deps), switches the CircleCI `revenuecat/sdks-common-config` orb to a dev commit, and adds a temporary `test_merge_queue` workflow to exercise `revenuecat/merge-release-pr` with `use_merge_queue: true`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5050888. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Important
I need to physically test this well… slow going
The media player is essentially bound to a single thread. If we don't ensure that it performs tasks on a background thread, the interactions from the UI layer make it run on the main thread. When we want to deallocate one, we should stop and release the resources to free up RAM on the device. Doing this on the main thread (like on paywall dismissal) can cause certain devices to hang from a main thread block. This is happening and we have reports of ANRs, and it's apparently one of the problems solved by ExoPlayer—which we decided not to use for transient dependency management reasons IIRC.
This PR wraps the media player into a thread management object to ensure we don't block the main thread.
Resolves #3142
Note
Medium Risk
Touches video playback lifecycle and threading (surface attach/detach, prepare/start/pause/release), which can introduce race conditions or device-specific playback issues despite added safeguards and tests.
Overview
Refactors
TextureVideoViewto route allMediaPlayeroperations through a newMediaPlayerThreadOwner, ensuring prepare/start/pause/seek/surface attach/detach/release run on a dedicatedHandlerThreadinstead of the main thread (reducing ANR risk during teardown).Adds bounded blocking surface-detach (
clearSurfaceBlocking) to preserve ordering whenSurfaceTextureis destroyed, introduces request-id guarding to ignore stale prepare callbacks, and includes Robolectric tests validating thread serialization, non-blockingrelease, surface-release ordering/timeout behavior, and immediateisPlayingstate reporting.Written by Cursor Bugbot for commit 637add7. This will update automatically on new commits. Configure here.