diff --git a/docs/guide/media/media-recording.md b/docs/guide/media/media-recording.md index aa69c0cc..0b206b48 100644 --- a/docs/guide/media/media-recording.md +++ b/docs/guide/media/media-recording.md @@ -30,7 +30,24 @@ try (MediaRecorder recorder = new MediaRecorder(Paths.get("call.mkv"))) { A sender's frames are recorded as they leave the encoder, a receiver's as they go to the decoder. Senders and receivers of any number of peer connections can go into one file, e.g. both sides of a call. -`stop()` waits until everything received so far is written and the file is finished, and returns whether the file holds a recording. A recorder that never got any media to write deletes its file rather than leave an unplayable one behind. `close()` stops a recording that still runs. +`stop()` waits until everything received so far is written and the file is finished and closed, and returns whether the file holds a recording. A recorder that never got any media to write deletes its file rather than leave an unplayable one behind. `close()` stops a recording that still runs. + +How the recording ended, `getResult()` tells once the recorder is stopped, and keeps telling after `close()`: + +| Result | File | Meaning | +| --- | --- | --- | +| `COMPLETE` | kept | Everything was written, and the file was finished and closed. | +| `PARTIAL` | kept | Media went into the file, but writing, finishing or closing it failed later. What made it into the file is kept, since it may still play. | +| `EMPTY` | deleted | No media arrived before the stop. | +| `FAILED` | deleted | Writing failed before any media went into the file. | + +```java +recorder.stop(); + +if (recorder.getResult() == MediaRecorderResult.PARTIAL) { + System.out.println("Incomplete recording in " + recorder.getFile()); +} +``` ::: warning Keep the senders and receivers undisposed while recording. The recorder asks them for key frames, and a disposed one cannot be asked. @@ -85,6 +102,8 @@ recorder.setListener(new MediaRecorderListener() { Calls arrive in order on a thread the recorder keeps for them, so a listener may take its time and may even stop the recorder. +`onError` also hears of a file that cannot be finished or closed when the recorder stops, e.g. because the last data does not fit on the disk. The recording then ends as `PARTIAL`. + ## How Tracks Are Recorded - **Video starts at a key frame.** A decoder can do nothing with the frames before one, so they are skipped. The recorder asks the sender or receiver for a key frame, so recording starts within moments instead of waiting until WebRTC sends one of its own accord, which it rarely does. diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/EncryptedRecordingExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/EncryptedRecordingExample.java index ec894559..1a7f7066 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/EncryptedRecordingExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/EncryptedRecordingExample.java @@ -57,6 +57,7 @@ import dev.onvoid.webrtc.media.audio.CustomAudioSource; import dev.onvoid.webrtc.media.recorder.MediaRecorder; import dev.onvoid.webrtc.media.recorder.MediaRecorderListener; +import dev.onvoid.webrtc.media.recorder.MediaRecorderResult; import dev.onvoid.webrtc.media.video.CustomVideoSource; import dev.onvoid.webrtc.media.video.NativeI420Buffer; import dev.onvoid.webrtc.media.video.VideoFrame; @@ -170,7 +171,8 @@ public void onError(String message) { Thread.sleep(TimeUnit.SECONDS.toMillis(RECORD_SECONDS)); if (recorder.stop()) { - LOG.info("Recorded " + RECORD_SECONDS + " seconds into " + recorder.getFile()); + LOG.info("Recorded " + RECORD_SECONDS + " seconds into " + recorder.getFile() + + (recorder.getResult() == MediaRecorderResult.PARTIAL ? ", incomplete" : "")); } } finally { diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java index bc375a1b..e0072205 100644 --- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java @@ -689,10 +689,18 @@ String stopRecording() { return "Not recording"; } - boolean recorded = running.stop(); running.close(); - return recorded ? "Saved " + recording : "Nothing was recorded"; + switch (running.getResult()) { + case COMPLETE: + return "Saved " + recording; + case PARTIAL: + return "Saved " + recording + ", but writing it failed"; + case FAILED: + return "Recording failed"; + default: + return "Nothing was recorded"; + } } /** diff --git a/webrtc-media/src/main/cpp/include/JNI_MediaRecorder.h b/webrtc-media/src/main/cpp/include/JNI_MediaRecorder.h index 6b6b56be..5fb9a84f 100644 --- a/webrtc-media/src/main/cpp/include/JNI_MediaRecorder.h +++ b/webrtc-media/src/main/cpp/include/JNI_MediaRecorder.h @@ -49,9 +49,9 @@ extern "C" { /* * Class: dev_onvoid_webrtc_media_recorder_MediaRecorder * Method: stop - * Signature: (J)Z + * Signature: (J)I */ - JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop + JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop (JNIEnv *, jclass, jlong); /* diff --git a/webrtc-media/src/main/cpp/include/JNI_WriteFault.h b/webrtc-media/src/main/cpp/include/JNI_WriteFault.h new file mode 100644 index 00000000..913f5c51 --- /dev/null +++ b/webrtc-media/src/main/cpp/include/JNI_WriteFault.h @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +/* Header for class dev_onvoid_webrtc_media_recorder_WriteFault */ + +#ifndef _Included_dev_onvoid_webrtc_media_recorder_WriteFault +#define _Included_dev_onvoid_webrtc_media_recorder_WriteFault +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_recorder_WriteFault + * Method: failHeader + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failHeader + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_recorder_WriteFault + * Method: failPacket + * Signature: (I)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failPacket + (JNIEnv *, jclass, jint); + + /* + * Class: dev_onvoid_webrtc_media_recorder_WriteFault + * Method: failTrailer + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failTrailer + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_recorder_WriteFault + * Method: failClose + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failClose + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_recorder_WriteFault + * Method: disarm + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_disarm + (JNIEnv *, jclass); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-media/src/main/cpp/include/media/MediaRecorder.h b/webrtc-media/src/main/cpp/include/media/MediaRecorder.h index a198d338..1c4563ee 100644 --- a/webrtc-media/src/main/cpp/include/media/MediaRecorder.h +++ b/webrtc-media/src/main/cpp/include/media/MediaRecorder.h @@ -54,6 +54,22 @@ namespace ffmpeg class MediaRecorder { public: + // How a recording ended, in the order of the Java + // MediaRecorderResult. + enum class Result + { + // Every frame taken was written, and the file was finished + // and closed. + kComplete, + // Media went into the file, but writing, finishing or closing + // it failed later. + kPartial, + // Nothing went wrong, and no media went into the file. + kEmpty, + // Writing failed before any media went into the file. + kFailed + }; + MediaRecorder(const webrtc_java_api * api, std::string path); // Stops the recording if it still runs. @@ -80,9 +96,10 @@ namespace ffmpeg void Start(); // Detaches from every track, writes out what is queued, finishes - // the file and waits for the writer thread. Returns whether any - // media was written. Stopping twice does nothing more. - bool Stop(); + // the file, waits for the writer thread and closes the file. + // Returns how the recording ended. Stopping twice does nothing + // more, and returns the same. + Result Stop(); private: // What the observer callback knows about a track. Guarded by @@ -135,6 +152,10 @@ namespace ffmpeg bool MaybeWriteHeader(bool finishing); void Finish(); + + // Closes the file if it is open, and fails the recording if that + // does. + void Close(); void Fail(const std::string & message, int error); void Warn(const std::string & message); @@ -155,6 +176,8 @@ namespace ffmpeg int64_t first_ready_us_ = 0; // Also read by Stop(), on the thread that stops. std::atomic header_written_{ false }; + // Read by Stop() once the writer has ended; the thread that stops + // sets it when it closes a file that had no writer. bool failed_ = false; // When recording started, in the clock of the frames. @@ -175,6 +198,8 @@ namespace ffmpeg bool stopping_ = false; bool started_ = false; bool stopped_ = false; + // Set by the first Stop(), once the file is closed. + Result result_ = Result::kEmpty; }; } diff --git a/webrtc-media/src/main/cpp/include/media/WriteFault.h b/webrtc-media/src/main/cpp/include/media/WriteFault.h new file mode 100644 index 00000000..cc7337fd --- /dev/null +++ b/webrtc-media/src/main/cpp/include/media/WriteFault.h @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef WEBRTC_JAVA_MEDIA_WRITE_FAULT_H_ +#define WEBRTC_JAVA_MEDIA_WRITE_FAULT_H_ + +namespace ffmpeg +{ + // Makes the writing of a recording fail on purpose, so that the tests can + // see what a recorder does when a disk fills up or a file cannot be + // closed, which no disk does on demand. + // + // Only the tests arm it, through JNI_WriteFault, and a call that is not + // armed pays a single atomic load. The call itself still runs, so the + // file is as it would be; only the result it reports is replaced. A + // fault fires once, for the recorder that runs into it first. + class WriteFault + { + public: + enum class Call + { + kHeader, + kPacket, + kTrailer, + kClose + }; + + // Fails the call that follows the given number of calls of the + // same kind, once, with the given AVERROR. + static void Fail(Call call, int error, int after_calls); + + // Forgets a fault that has not fired. + static void Disarm(); + + // Returns the result of the given call, or the error it is to + // fail with if a fault is armed for it, which disarms the fault. + static int Check(Call call, int result); + }; +} + +#endif diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp index 47a5c215..a455ba73 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp @@ -99,15 +99,17 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_start }); } -JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop +JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop (JNIEnv * env, jclass caller, jlong handle) { + const jint failed = static_cast(ffmpeg::MediaRecorder::Result::kFailed); + if (handle == 0) { - return JNI_FALSE; + return failed; } - return ffmpeg::CallFromJava(env, JNI_FALSE, [&]() -> jboolean { - return RecorderOf(handle)->Stop() ? JNI_TRUE : JNI_FALSE; + return ffmpeg::CallFromJava(env, failed, [&]() -> jint { + return static_cast(RecorderOf(handle)->Stop()); }); } diff --git a/webrtc-media/src/main/cpp/src/JNI_WriteFault.cpp b/webrtc-media/src/main/cpp/src/JNI_WriteFault.cpp new file mode 100644 index 00000000..b233a5de --- /dev/null +++ b/webrtc-media/src/main/cpp/src/JNI_WriteFault.cpp @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JNI_WriteFault.h" +#include "media/WriteFault.h" + +#include + +extern "C" { +#include +} + +// The Java class this belongs to exists in the tests only, so that nothing but +// them can make a recording fail. Writing fails as on a full disk, closing as +// on a file system that loses what it was to keep. + +using ffmpeg::WriteFault; + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failHeader +(JNIEnv * env, jclass caller) +{ + WriteFault::Fail(WriteFault::Call::kHeader, AVERROR(ENOSPC), 0); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failPacket +(JNIEnv * env, jclass caller, jint afterPackets) +{ + WriteFault::Fail(WriteFault::Call::kPacket, AVERROR(ENOSPC), afterPackets); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failTrailer +(JNIEnv * env, jclass caller) +{ + WriteFault::Fail(WriteFault::Call::kTrailer, AVERROR(ENOSPC), 0); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_failClose +(JNIEnv * env, jclass caller) +{ + WriteFault::Fail(WriteFault::Call::kClose, AVERROR(EIO), 0); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_WriteFault_disarm +(JNIEnv * env, jclass caller) +{ + WriteFault::Disarm(); +} diff --git a/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp b/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp index 1c728f52..7fae0075 100644 --- a/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp +++ b/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp @@ -17,6 +17,7 @@ #include "media/MediaRecorder.h" #include "media/AllocationFault.h" #include "media/ErrorText.h" +#include "media/WriteFault.h" #include #include @@ -191,13 +192,13 @@ namespace ffmpeg } } - bool MediaRecorder::Stop() + MediaRecorder::Result MediaRecorder::Stop() { { std::lock_guard lock(mutex_); if (stopped_) { - return header_written_; + return result_; } stopped_ = true; @@ -232,11 +233,24 @@ namespace ffmpeg // The writer closes the file when it finishes. A recorder that never // started has no writer, and its file must not stay open either: the // caller deletes an empty file, which Windows refuses while it is. - if (context_ != nullptr && context_->pb != nullptr && !(context_->oformat->flags & AVFMT_NOFILE)) { - avio_closep(&context_->pb); + Close(); + + // The writer has ended, so what it left is final. Media in the file + // is kept even after a failure: what made it there may still play. + Result result; + + if (header_written_) { + result = failed_ ? Result::kPartial : Result::kComplete; + } + else { + result = failed_ ? Result::kFailed : Result::kEmpty; } - return header_written_; + std::lock_guard lock(mutex_); + + result_ = result; + + return result_; } void MediaRecorder::OnEncodedFrame(void * opaque, const wj_encoded_frame * frame) @@ -519,7 +533,7 @@ namespace ffmpeg AVPacket * packet = tracks_[frame.track]->Stamp(frame, start_us_); // Takes the packet's reference and leaves the packet blank. - int result = av_interleaved_write_frame(context_, packet); + int result = WriteFault::Check(WriteFault::Call::kPacket, av_interleaved_write_frame(context_, packet)); if (result < 0) { Fail("Writing to " + path_ + " failed", result); @@ -576,7 +590,7 @@ namespace ffmpeg av_dict_set(&options, "frag_duration", "1000000", 0); } - int result = avformat_write_header(context_, &options); + int result = WriteFault::Check(WriteFault::Call::kHeader, avformat_write_header(context_, &options)); av_dict_free(&options); @@ -612,7 +626,7 @@ namespace ffmpeg if (header_written_) { // Even after a failed write: whatever made it into the file // is only playable with a trailer. - int result = av_write_trailer(context_); + int result = WriteFault::Check(WriteFault::Call::kTrailer, av_write_trailer(context_)); if (result < 0 && !failed_) { Fail("Finishing " + path_ + " failed", result); @@ -621,8 +635,22 @@ namespace ffmpeg pending_.clear(); - if (context_->pb != nullptr && !(context_->oformat->flags & AVFMT_NOFILE)) { - avio_closep(&context_->pb); + Close(); + } + + void MediaRecorder::Close() + { + if (context_ == nullptr || context_->pb == nullptr || (context_->oformat->flags & AVFMT_NOFILE)) { + return; + } + + // Flushes what is still buffered, so a full disk may only show here, + // and the file system may refuse to close a file it could not keep. + int result = WriteFault::Check(WriteFault::Call::kClose, avio_closep(&context_->pb)); + + // After an earlier failure, the same cause is not reported twice. + if (result < 0 && !failed_) { + Fail("Closing " + path_ + " failed", result); } } diff --git a/webrtc-media/src/main/cpp/src/media/WriteFault.cpp b/webrtc-media/src/main/cpp/src/media/WriteFault.cpp new file mode 100644 index 00000000..fcc646c5 --- /dev/null +++ b/webrtc-media/src/main/cpp/src/media/WriteFault.cpp @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "media/WriteFault.h" + +#include + +namespace +{ + constexpr int kNone = -1; + + std::atomic armed_call{ kNone }; + std::atomic error_code{ 0 }; + std::atomic calls_left{ 0 }; +} + +namespace ffmpeg +{ + void WriteFault::Fail(Call call, int error, int after_calls) + { + error_code.store(error); + calls_left.store(after_calls); + armed_call.store(static_cast(call)); + } + + void WriteFault::Disarm() + { + armed_call.store(kNone); + } + + int WriteFault::Check(Call call, int result) + { + int expected = static_cast(call); + + if (result < 0 || armed_call.load() != expected) { + return result; + } + if (calls_left.fetch_sub(1) > 0) { + return result; + } + + // Once only, for whichever call gets here first. + if (!armed_call.compare_exchange_strong(expected, kNone)) { + return result; + } + + return error_code.load(); + } +} diff --git a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorder.java b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorder.java index f164b12b..24fbd6c6 100644 --- a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorder.java +++ b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorder.java @@ -110,6 +110,9 @@ public class MediaRecorder implements AutoCloseable { private MediaRecorderState state = MediaRecorderState.IDLE; + /** How the recording ended, or null until it was stopped. */ + private MediaRecorderResult result; + /** * Creates a recorder writing to the given file, which is created, or @@ -253,24 +256,31 @@ public void start() { * so far to be written. A recorder that never got any media to write * deletes its file rather than leave an unplayable one behind. *

- * Stopping a recorder that is stopped does nothing. Stopping one that was - * never started deletes its file. + * A file that holds media is kept even when writing, finishing or closing + * it failed, since what made it into the file may still play. Whether it + * is complete, {@link #getResult()} tells. + *

+ * Stopping a recorder that is stopped does nothing, and returns the same. + * Stopping one that was never started deletes its file. * - * @return True if the file holds a recording, false if it was deleted. + * @return True if the file holds a recording, complete or not, false if it + * was deleted. */ public boolean stop() { boolean recorded; synchronized (lock) { + if (result != null) { + return result.keepsFile(); + } if (handle == 0) { return false; } - if (state == MediaRecorderState.STOPPED) { - return Files.exists(file); - } - recorded = stop(handle); + // In the order of the native MediaRecorder::Result. + result = MediaRecorderResult.values()[stop(handle)]; state = MediaRecorderState.STOPPED; + recorded = result.keepsFile(); if (events != null) { // Lets the reports already queued run, then ends the thread. @@ -301,6 +311,20 @@ public MediaRecorderState getState() { } } + /** + * Returns how the recording ended: completely, partly, without media, or + * failed. It is known once the recorder is stopped, and stays the same + * from then on, after {@link #close()} as well. + * + * @return The result, or {@code null} if the recorder was not stopped + * yet. + */ + public MediaRecorderResult getResult() { + synchronized (lock) { + return result; + } + } + /** * @return The file this recorder writes to. */ @@ -466,7 +490,7 @@ private void onNativeKeyFrameNeeded(int track) { private static native void start(long handle); - private static native boolean stop(long handle); + private static native int stop(long handle); private static native void dispose(long handle); diff --git a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderListener.java b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderListener.java index 56ebaba6..17f4a502 100644 --- a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderListener.java +++ b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderListener.java @@ -48,7 +48,9 @@ default void onWarning(String message) { /** * Writing the file failed, e.g. because the disk is full. Nothing more * goes into the file; what is in it stays, and {@link MediaRecorder#stop()} - * still finishes it as far as it can. + * still finishes it as far as it can. Finishing and closing the file when + * stopping may fail as well, and are reported here too. Once stopped, + * {@link MediaRecorder#getResult()} tells what is left. * * @param message What went wrong. */ diff --git a/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderResult.java b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderResult.java new file mode 100644 index 00000000..e7eaf6ff --- /dev/null +++ b/webrtc-media/src/main/java/dev/onvoid/webrtc/media/recorder/MediaRecorderResult.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.recorder; + +/** + * How a {@link MediaRecorder} recording ended, as {@link + * MediaRecorder#getResult()} tells once the recorder is stopped. + * + * @author Alex Andres + */ +public enum MediaRecorderResult { + + // In the order of the native MediaRecorder::Result. + + /** + * Everything the recorder took went into the file, and the file was + * finished and closed without an error. + */ + COMPLETE, + + /** + * Media went into the file, but writing, finishing or closing it failed + * later, as {@link MediaRecorderListener#onError(String)} was told. The + * file is kept: it may end before the recording was stopped, lack its + * index, or miss what the system had not yet written when closing it + * failed. + */ + PARTIAL, + + /** + * Nothing went wrong, but no media arrived to be written, e.g. because + * the recorder was stopped right after it was started, or never started. + * The file is deleted. + */ + EMPTY, + + /** + * Writing failed before any media went into the file, e.g. writing its + * header. The file is deleted. + */ + FAILED; + + + /** + * Returns whether the recorder kept its file, with media in it. + * + * @return True for {@link #COMPLETE} and {@link #PARTIAL}. + */ + public boolean keepsFile() { + return this == COMPLETE || this == PARTIAL; + } + +} diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java index 03d2576f..3b2e6561 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java @@ -97,11 +97,13 @@ void recordsReceivedCallIntoMatroska() throws Exception { assertEquals(MediaRecorderState.RECORDING, recorder.getState()); assertTrue(listener.started.await(10, TimeUnit.SECONDS), "not started"); + assertNull(recorder.getResult()); Thread.sleep(RECORD_MS); assertTrue(recorder.stop()); assertEquals(MediaRecorderState.STOPPED, recorder.getState()); + assertEquals(MediaRecorderResult.COMPLETE, recorder.getResult()); } finally { video.dispose(); @@ -204,6 +206,7 @@ void deletesFileWithoutMedia() throws Exception { // Too soon for any frame to have made it into the file. assertFalse(recorder.stop()); + assertEquals(MediaRecorderResult.EMPTY, recorder.getResult()); } assertFalse(Files.exists(file)); @@ -212,13 +215,21 @@ void deletesFileWithoutMedia() throws Exception { @Test void unstartedRecorderDeletesFile() throws Exception { Path file = tempDir.resolve("unstarted.mkv"); + MediaRecorder recorder = new MediaRecorder(file); - try (MediaRecorder recorder = new MediaRecorder(file)) { + try { assertEquals(MediaRecorderState.IDLE, recorder.getState()); assertThrows(IllegalStateException.class, recorder::start); } + finally { + recorder.close(); + } assertFalse(Files.exists(file)); + + // Kept beyond the close, with the answer of the stop it made. + assertEquals(MediaRecorderResult.EMPTY, recorder.getResult()); + assertFalse(recorder.stop()); } @Test @@ -382,6 +393,113 @@ void dropsFrameThatCannotBeQueued() throws Exception { assertTrue(readInfo(file).hasVideo()); } + @Test + void failedHeaderFailsRecording() throws Exception { + Path file = tempDir.resolve("failed-header.mkv"); + Listener listener = new Listener(); + + WriteFault.failHeader(); + + try (TestCall call = new TestCall(factory); + MediaRecorder recorder = new MediaRecorder(file)) { + recorder.setListener(listener); + recorder.addTrack(call.getVideoSender()); + recorder.start(); + + assertTrue(listener.failed.await(10, TimeUnit.SECONDS), "no error"); + + // Nothing playable went into the file, so it is not kept. + assertFalse(recorder.stop()); + assertEquals(MediaRecorderResult.FAILED, recorder.getResult()); + } + finally { + WriteFault.disarm(); + } + + assertErrorStartsWith(listener, "Writing the header of "); + assertEquals(1, listener.started.getCount(), "reported as started"); + assertFalse(Files.exists(file)); + } + + @Test + void failedWriteKeepsPartialRecording() throws Exception { + Path file = tempDir.resolve("failed-write.mkv"); + Listener listener = new Listener(); + + // Well into the recording, once there is media in the file. + WriteFault.failPacket(30); + + try (TestCall call = new TestCall(factory); + MediaRecorder recorder = new MediaRecorder(file)) { + recorder.setListener(listener); + recorder.addTrack(call.getVideoSender()); + recorder.start(); + + assertTrue(listener.failed.await(10, TimeUnit.SECONDS), "no error"); + + assertTrue(recorder.stop()); + assertEquals(MediaRecorderResult.PARTIAL, recorder.getResult()); + } + finally { + WriteFault.disarm(); + } + + assertErrorStartsWith(listener, "Writing to "); + assertTrue(readInfo(file).hasVideo()); + } + + @Test + void failedTrailerKeepsPartialRecording() throws Exception { + assertFailedFinishKeepsFile("failed-trailer.mkv", WriteFault::failTrailer, "Finishing "); + } + + @Test + void failedCloseKeepsPartialRecording() throws Exception { + assertFailedFinishKeepsFile("failed-close.mkv", WriteFault::failClose, "Closing "); + } + + /** + * Records a call whose file fails to be finished or closed when the + * recorder stops: the failure is reported, and the file is kept as + * partial. + */ + private void assertFailedFinishKeepsFile(String name, Runnable fault, String error) throws Exception { + Path file = tempDir.resolve(name); + Listener listener = new Listener(); + + fault.run(); + + try (TestCall call = new TestCall(factory); + MediaRecorder recorder = new MediaRecorder(file)) { + recorder.setListener(listener); + recorder.addTrack(call.getVideoSender()); + recorder.start(); + + assertTrue(listener.started.await(10, TimeUnit.SECONDS), "not started"); + + Thread.sleep(1000); + + assertNull(listener.error.get()); + assertTrue(recorder.stop()); + assertEquals(MediaRecorderResult.PARTIAL, recorder.getResult()); + + // Reported before the event thread ends, which the stop let do. + assertTrue(listener.failed.await(10, TimeUnit.SECONDS), "no error"); + } + finally { + WriteFault.disarm(); + } + + assertErrorStartsWith(listener, error + file.toAbsolutePath()); + assertTrue(Files.exists(file)); + } + + private static void assertErrorStartsWith(Listener listener, String prefix) { + String error = listener.error.get(); + + assertTrue(error != null && error.startsWith(prefix), "error: " + error); + } + /** * Records a call that sends G.711 into Matroska, which has no codec ID of * its own for it, and reads the audio back as the codec it was sent in. @@ -440,6 +558,8 @@ private static class Listener implements MediaRecorderListener { final AtomicReference error = new AtomicReference<>(); + final CountDownLatch failed = new CountDownLatch(1); + @Override public void onStarted() { @@ -454,6 +574,7 @@ public void onWarning(String message) { @Override public void onError(String message) { error.set(message); + failed.countDown(); } } diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/WriteFault.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/WriteFault.java new file mode 100644 index 00000000..f112da1b --- /dev/null +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/WriteFault.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.recorder; + +import dev.onvoid.webrtc.media.player.FFmpeg; + +/** + * Makes the writing of a recording fail on purpose, so that the tests can see + * what a recorder does when the disk fills up or the file cannot be closed, + * which no disk does on demand. It lives in the tests so that nothing else + * can reach it. + *

+ * The call that fails still runs, and only its result is replaced: writing + * fails as on a full disk, closing with an I/O error. A fault is armed for + * the next recorder that runs into it, fires once, and has to be {@link + * #disarm() disarmed} when a test is done. + * + * @author Alex Andres + */ +final class WriteFault { + + static { + FFmpeg.load(); + } + + + private WriteFault() { + // Static access only. + } + + /** + * Fails the writing of the file header, before any media is in the file. + */ + static native void failHeader(); + + /** + * Fails the write of the packet that follows the given number of them. + * + * @param afterPackets How many packets are written before the one that + * fails. + */ + static native void failPacket(int afterPackets); + + /** + * Fails the writing of the trailer, which finishes the file. + */ + static native void failTrailer(); + + /** + * Fails the closing of the file, the last thing a recorder does with it. + */ + static native void failClose(); + + /** + * Forgets a fault that has not fired. + */ + static native void disarm(); + +}