Skip to content

Commit 2526d8a

Browse files
authored
Fix android camera crashing while spamming toggle (#48)
* Fix camera crash by reusing surface texture helper * Fix copilot concerns + formatting * Fix copilot complaints
1 parent deb7cb2 commit 2526d8a

3 files changed

Lines changed: 133 additions & 41 deletions

File tree

android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ class GetUserMediaImpl {
6565
private boolean createConfigForDefaultDisplay = false;
6666
private float resolutionScale = 1.0f;
6767

68+
// Reusable SurfaceTextureHelper for camera captures.
69+
// By reusing a single STH for all camera sessions we hold exactly one EGL context
70+
// regardless of how many times the camera is toggled. On dispose we only call
71+
// stopListening() so
72+
// the context stays alive for the next getUserMedia call.
73+
private SurfaceTextureHelper reusableCameraSTH = null;
74+
6875
GetUserMediaImpl(WebRTCModule webRTCModule, ReactApplicationContext reactContext) {
6976
this.webRTCModule = webRTCModule;
7077
this.reactContext = reactContext;
@@ -439,7 +446,18 @@ VideoTrack createVideoTrack(AbstractVideoCaptureController videoCaptureControlle
439446

440447
PeerConnectionFactory pcFactory = webRTCModule.mFactory;
441448
EglBase.Context eglContext = EglUtils.getRootEglBaseContext();
442-
SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglContext);
449+
450+
boolean isCameraCapture = videoCaptureController instanceof CameraCaptureController;
451+
SurfaceTextureHelper surfaceTextureHelper;
452+
453+
if (isCameraCapture) {
454+
if (reusableCameraSTH == null) {
455+
reusableCameraSTH = SurfaceTextureHelper.create("CaptureThread", eglContext);
456+
}
457+
surfaceTextureHelper = reusableCameraSTH;
458+
} else {
459+
surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglContext);
460+
}
443461

444462
if (surfaceTextureHelper == null) {
445463
Log.d(TAG, "Error creating SurfaceTextureHelper");
@@ -457,7 +475,8 @@ VideoTrack createVideoTrack(AbstractVideoCaptureController videoCaptureControlle
457475
VideoTrack track = pcFactory.createVideoTrack(id, videoSource);
458476

459477
track.setEnabled(true);
460-
tracks.put(id, new TrackPrivate(track, videoSource, videoCaptureController, surfaceTextureHelper));
478+
tracks.put(id,
479+
new TrackPrivate(track, videoSource, videoCaptureController, surfaceTextureHelper, isCameraCapture));
461480

462481
videoCaptureController.startCapture();
463482

@@ -523,26 +542,32 @@ private static class TrackPrivate {
523542
private final SurfaceTextureHelper surfaceTextureHelper;
524543

525544
/**
526-
* Whether this object has been disposed or not.
545+
* When true, {@link #surfaceTextureHelper} is the shared reusable camera STH owned by
546+
* {@link GetUserMediaImpl#reusableCameraSTH}. Its EGL context must NOT be destroyed on
547+
* dispose — only {@code stopListening()} is called so the context can be reclaimed by the
548+
* next camera session. Non-camera STHs (screen share, etc.) are not reused and are fully
549+
* disposed.
527550
*/
528-
private boolean disposed;
551+
private final boolean reusableSTH;
529552

530553
/**
531-
* Initializes a new {@code TrackPrivate} instance.
532-
*
533-
* @param track
534-
* @param mediaSource the {@code MediaSource} from which the specified
535-
* {@code code} was created
536-
* @param videoCaptureController the {@code AbstractVideoCaptureController} from which the
537-
* specified {@code mediaSource} was created if the specified
538-
* {@code track} is a {@link VideoTrack}
554+
* Whether this object has been disposed or not.
539555
*/
556+
private boolean disposed;
557+
540558
public TrackPrivate(MediaStreamTrack track, MediaSource mediaSource,
541559
AbstractVideoCaptureController videoCaptureController, SurfaceTextureHelper surfaceTextureHelper) {
560+
this(track, mediaSource, videoCaptureController, surfaceTextureHelper, false);
561+
}
562+
563+
public TrackPrivate(MediaStreamTrack track, MediaSource mediaSource,
564+
AbstractVideoCaptureController videoCaptureController, SurfaceTextureHelper surfaceTextureHelper,
565+
boolean reusableSTH) {
542566
this.track = track;
543567
this.mediaSource = mediaSource;
544568
this.videoCaptureController = videoCaptureController;
545569
this.surfaceTextureHelper = surfaceTextureHelper;
570+
this.reusableSTH = reusableSTH;
546571
this.disposed = false;
547572
}
548573

@@ -558,11 +583,17 @@ public void dispose() {
558583
* As per webrtc library documentation - The caller still has ownership of {@code
559584
* surfaceTextureHelper} and is responsible for making sure surfaceTextureHelper.dispose() is
560585
* called. This also means that the caller can reuse the SurfaceTextureHelper to initialize a new
561-
* VideoCapturer once the previous VideoCapturer has been disposed. */
562-
586+
* VideoCapturer once the previous VideoCapturer has been disposed.
587+
*
588+
* For camera captures we use a single reusable STH (GetUserMediaImpl.reusableCameraSTH)
589+
* so we only call stopListening() here — the EGL context is kept alive for the next
590+
* getUserMedia call. Full dispose() is only called for non-reusable STHs (screen share).
591+
*/
563592
if (surfaceTextureHelper != null) {
564593
surfaceTextureHelper.stopListening();
565-
surfaceTextureHelper.dispose();
594+
if (!reusableSTH) {
595+
surfaceTextureHelper.dispose();
596+
}
566597
}
567598

568599
mediaSource.dispose();
@@ -572,6 +603,28 @@ public void dispose() {
572603
}
573604
}
574605

606+
/**
607+
* Releases resources held by this instance. Must be called when the owning
608+
* {@link WebRTCModule} is invalidated (e.g. React Native JS reload) so that
609+
* the reusable camera EGL context and its GL thread are not kept alive for
610+
* the rest of the process lifetime.
611+
*
612+
* <p>All active {@link TrackPrivate} entries are disposed first so no capturer
613+
* is left running against an already-disposed STH.
614+
*/
615+
void dispose() {
616+
for (TrackPrivate track : tracks.values()) {
617+
track.dispose();
618+
}
619+
tracks.clear();
620+
621+
if (reusableCameraSTH != null) {
622+
reusableCameraSTH.stopListening();
623+
reusableCameraSTH.dispose();
624+
reusableCameraSTH = null;
625+
}
626+
}
627+
575628
public interface BiConsumer<T, U> {
576629
void accept(T t, U u);
577630
}

android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ public void initialize() {
136136
public void invalidate() {
137137
super.invalidate();
138138
audioOutputManager.stopObserving();
139+
getUserMediaImpl.dispose();
139140
}
140141

141142
@NonNull

android/src/main/java/com/oney/WebRTCModule/WebRTCView.java

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -619,50 +619,88 @@ void setStreamURL(String streamURL) {
619619
/**
620620
* Sets the {@code VideoTrack} to be rendered by this {@code WebRTCView}.
621621
*
622+
* <p>The SurfaceViewRenderer (and its underlying EGL context) is kept alive as long as this
623+
* view is attached to a window, even when the track becomes {@code null} (camera off). This
624+
* avoids the pattern of calling {@code SurfaceViewRenderer.release()} on camera-off and
625+
* {@code SurfaceViewRenderer.init()} on camera-on, which each time allocates a new EGL context
626+
* from Android's finite per-process pool and can produce:
627+
* {@code android.opengl.GLException: Failed to create EGL context: 0x3000}
628+
* {@code release()} is only called from {@link #onDetachedFromWindow()}.
629+
*
622630
* @param videoTrack The {@code VideoTrack} to be rendered by this
623631
* {@code WebRTCView} or {@code null}.
624632
*/
625633
private void setVideoTrack(VideoTrack videoTrack) {
626634
VideoTrack oldVideoTrack = this.videoTrack;
627635

628-
if (oldVideoTrack != videoTrack) {
629-
if (oldVideoTrack != null && videoTrack != null && rendererAttached) {
630-
final VideoTrack capturedOld = oldVideoTrack;
636+
if (oldVideoTrack == videoTrack) {
637+
return;
638+
}
639+
640+
// Case 1: Swapping one live track for another while the renderer is active.
641+
// Just move the sink — no EGL context churn.
642+
if (oldVideoTrack != null && videoTrack != null && rendererAttached) {
643+
final VideoTrack capturedOld = oldVideoTrack;
644+
final VideoTrack capturedNew = videoTrack;
645+
this.videoTrack = videoTrack;
646+
ThreadUtils.runOnExecutor(() -> {
647+
try {
648+
capturedOld.removeSink(surfaceViewRenderer);
649+
} catch (Throwable tr) {
650+
Log.w(TAG, "Failed to remove sink from old track", tr);
651+
}
652+
if (!rendererAttached) {
653+
return;
654+
}
655+
try {
656+
capturedNew.addSink(surfaceViewRenderer);
657+
} catch (Throwable tr) {
658+
Log.e(TAG, "Failed to add renderer", tr);
659+
}
660+
});
661+
return;
662+
}
663+
664+
// Detach the old track's sink from the renderer (without releasing the EGL context).
665+
if (oldVideoTrack != null && rendererAttached) {
666+
final VideoTrack trackToRemove = oldVideoTrack;
667+
ThreadUtils.runOnExecutor(() -> {
668+
try {
669+
trackToRemove.removeSink(surfaceViewRenderer);
670+
} catch (Throwable tr) {
671+
Log.w(TAG, "Failed to remove sink from track", tr);
672+
}
673+
});
674+
}
675+
676+
this.videoTrack = videoTrack;
677+
678+
if (videoTrack != null) {
679+
if (rendererAttached) {
680+
// The EGL context was kept alive from a previous session — just add the sink.
681+
if (oldVideoTrack == null) {
682+
cleanSurfaceViewRenderer();
683+
}
631684
final VideoTrack capturedNew = videoTrack;
632-
this.videoTrack = videoTrack;
633685
ThreadUtils.runOnExecutor(() -> {
634-
try {
635-
capturedOld.removeSink(surfaceViewRenderer);
636-
} catch (Throwable tr) {
637-
Log.w(TAG, "Failed to remove sink from old track", tr);
638-
}
639-
if (!rendererAttached) {
640-
return;
641-
}
642686
try {
643687
capturedNew.addSink(surfaceViewRenderer);
644688
} catch (Throwable tr) {
645689
Log.e(TAG, "Failed to add renderer", tr);
646690
}
647691
});
648-
return;
649-
}
650-
651-
if (oldVideoTrack != null) {
652-
if (videoTrack == null) {
653-
cleanSurfaceViewRenderer();
654-
}
655-
removeRendererFromVideoTrack();
656-
}
657-
658-
this.videoTrack = videoTrack;
659-
660-
if (videoTrack != null) {
661-
tryAddRendererToVideoTrack();
692+
} else {
693+
// Renderer not yet initialised (first render after attach, or after detach).
662694
if (oldVideoTrack == null) {
663695
cleanSurfaceViewRenderer();
664696
}
697+
tryAddRendererToVideoTrack();
665698
}
699+
} else {
700+
// Track became null (camera off). Keep the EGL context alive so the next
701+
// camera-on can reuse it without calling surfaceViewRenderer.init() again.
702+
// The renderer is only released in onDetachedFromWindow().
703+
cleanSurfaceViewRenderer();
666704
}
667705
}
668706

0 commit comments

Comments
 (0)