diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java index cbf185ce..382a5b6f 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/LocalCameraImpl.java @@ -61,6 +61,25 @@ public CompletableFuture switchCamera(VideoDevice device) { requireNonNull(device, "device"); CompletableFuture switched = new CompletableFuture<>(); + + if (isEnded()) { + switched.completeExceptionally(new MediaException("The camera track is closed")); + return switched; + } + + // Registered like the creation of a track, as the switch opens a + // camera and uses the factory: a runtime that begins to close either + // waits for it before it frees the factory, or is seen closing here. + LocalMediaImpl.Creation operation; + + try { + operation = media.begin(); + } + catch (PeerClosedException e) { + switched.completeExceptionally(e); + return switched; + } + media.rtc.track(switched, null); // Opening a camera can take a moment, so it happens off the caller's thread. @@ -73,6 +92,12 @@ public CompletableFuture switchCamera(VideoDevice device) { switched.completeExceptionally(e instanceof WebRtcException ? e : new MediaException("Switching to the camera " + device.getName() + " failed", e)); } + finally { + operation.close(); + } + }, () -> { + operation.close(); + switched.completeExceptionally(new PeerClosedException("The runtime is closed")); }); // Completed on the event executor, so that the application's @@ -84,6 +109,11 @@ private void switchTo(VideoDevice device) { LocalMediaImpl.SharedCamera current; synchronized (this) { + // Closed while the switch was queued, so no camera is opened. + if (!isLive()) { + throw new MediaException("The camera track is closed"); + } + current = camera; } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java index df8bda8a..0514f7a5 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/RuntimeCloseTests.java @@ -34,6 +34,7 @@ import dev.onvoid.webrtc.RTCSessionDescription; import dev.onvoid.webrtc.media.audio.AudioDevice; import dev.onvoid.webrtc.media.video.CustomVideoSource; +import dev.onvoid.webrtc.media.video.VideoDevice; import org.junit.jupiter.api.Test; @@ -441,6 +442,73 @@ void switchDeviceAfterClose() throws Exception { await(closed); } + @Test + void closeWhileSwitchingCamera() throws Exception { + // A camera switch is queued when the runtime begins to close. The + // runtime waits for it before it frees the factory, and the switch, + // which finds its track closed, opens no camera. + ManualExecutor control = new ManualExecutor(); + WebRtc runtime = runtime(control); + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + LocalCameraImpl camera = camera(runtime); + + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + + // Named among what the runtime waits for, beside the track. + assertEquals(2, media.closeAll().size()); + + CompletableFuture closed = runtime.closeAsync(); + control.runUntil(closed::isDone, TIMEOUT_SECONDS); + await(closed); + + // The switch ran while the runtime still had its threads, rather than + // being failed by the runtime's close. + assertTrackClosed(switched); + } + + @Test + void switchCameraAfterClose() throws Exception { + LocalCameraImpl camera = camera(rtc); + await(camera.closeAsync()); + + // Failed at once, without opening a camera over the factory. + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + + assertTrue(switched.isDone()); + assertTrackClosed(switched); + } + + @Test + void switchCameraRejected() throws Exception { + // The executor refuses the switch, which fails and lets go of its + // registration, so that the runtime does not wait for it. + ManualExecutor control = new ManualExecutor(); + WebRtc runtime = runtime(control); + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + LocalCameraImpl camera = camera(runtime); + + try { + control.rejecting(true); + CompletableFuture switched = camera.switchCamera(camera("Other camera")); + + ExecutionException e = assertThrows(ExecutionException.class, () -> await(switched)); + assertInstanceOf(PeerClosedException.class, e.getCause()); + assertEquals(1, errors.size(), () -> "Errors: " + errors); + assertInstanceOf(RejectedExecutionException.class, errors.get(0).getCause()); + } + finally { + control.rejecting(false); + errors.clear(); + } + + // Only the track is left. + assertEquals(1, media.closeAll().size()); + + CompletableFuture closed = runtime.closeAsync(); + control.runUntil(closed::isDone, TIMEOUT_SECONDS); + await(closed); + } + @Test void closedPeerNeedsNoTimer() throws Exception { // An operation reaches a peer the closing runtime has closed, after it @@ -511,6 +579,38 @@ private WebRtc runtime(ManualExecutor control) { .build(); } + /** + * A camera track over a custom source, so that no camera is needed. Its + * camera has another user, so that the track's release lets go of its + * share only, and touches no capture device. + */ + private static LocalCameraImpl camera(WebRtc runtime) { + LocalMediaImpl media = (LocalMediaImpl) runtime.media(); + VideoDevice device = camera("Test camera"); + LocalMediaImpl.SharedCamera shared = new LocalMediaImpl.SharedCamera(device.getDescriptor(), + CameraOptions.builder().device(device).build(), null); + shared.users = 2; + + try (LocalMediaImpl.Creation creation = media.begin()) { + CustomVideoSource source = new CustomVideoSource(); + LocalCameraImpl camera = media.register(new LocalCameraImpl(media, + runtime.factory.createVideoTrack("camera", source), shared)); + camera.onEnded(source::dispose); + + return camera; + } + } + + private static VideoDevice camera(String name) { + return new VideoDevice(name, name.toLowerCase().replace(' ', '-')) { }; + } + + private static void assertTrackClosed(CompletableFuture switched) { + ExecutionException e = assertThrows(ExecutionException.class, () -> await(switched)); + assertInstanceOf(MediaException.class, e.getCause()); + assertEquals("The camera track is closed", e.getCause().getMessage()); + } + /** * @return what waiting for the future threw, or null. */