fix(android): contain panics at the JNI boundary instead of aborting the process

Ten `extern "system"` callbacks are entered by the JVM on arbitrary
threads. A panic unwinding out of one crosses the FFI boundary, which
Rust answers by aborting: the app vanishes with no Java exception, no
attributable stack trace, and no crash report the user can send. For
callbacks that fire four times a second during playback that is the worst
available failure mode.

It was reachable. `nativeOnPositionUpdate` built a fallback Tokio runtime
with `Runtime::new().unwrap()` on threads that have none, and
`Runtime::new()` fails under exactly the fd exhaustion and thread-spawn
refusal Android subjects a media app to. That now logs and drops the
report — losing one progress report is recoverable, losing the app is not.

Every callback body is wrapped in `jni_guard`, which catches the unwind
and logs it. It is a backstop, not a licence to panic: a contained panic
still leaves whatever it interrupted half-done.

The guard lives in `player::jni_guard` rather than `player::android`
because that module is `cfg(target_os = "android")` and so never compiles
on the host — which is why its 1575 lines had no tests at all. A tripwire
test asserts every entry point wraps its body, so an eleventh callback
cannot reintroduce the defect; it reads the source, since exercising the
real boundary needs a JVM.

Verified with `cargo check --target aarch64-linux-android`.
This commit is contained in:
2026-09-07 22:24:45 +02:00
parent d4f80a4afa
commit f9e1a8e69a
3 changed files with 505 additions and 313 deletions
+379 -313
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use super::jni_guard::jni_guard;
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
@@ -677,42 +678,47 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
position: jdouble,
duration: jdouble,
) {
// Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0;
unsafe {
UPDATE_COUNTER += 1;
if UPDATE_COUNTER % 10 == 0 {
log::debug!("[Android] Position update {} / {}", position, duration);
}
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|| {
// Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0;
unsafe {
UPDATE_COUNTER += 1;
if UPDATE_COUNTER % 10 == 0 {
log::debug!("[Android] Position update {} / {}", position, duration);
}
}
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
}
// Use preserved duration from state, or fall back to the received value
state.duration.unwrap_or(duration)
} else {
duration
};
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
}
// Use preserved duration from state, or fall back to the received value
state.duration.unwrap_or(duration)
} else {
duration
};
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position,
duration: duration_to_emit,
});
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position,
duration: duration_to_emit,
});
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
},
);
}
/// Report throttled playback progress to Jellyfin from the Android position
@@ -774,8 +780,17 @@ fn report_android_progress(position: f64) {
handle.spawn(spawn_report());
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(spawn_report());
// Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
// fails under the fd exhaustion and thread-spawn refusal Android
// subjects a media app to. The panic used to unwind out of the
// `extern "system"` caller and abort the process — losing one
// progress report is recoverable, losing the app is not.
match tokio::runtime::Runtime::new() {
Ok(rt) => rt.block_on(spawn_report()),
Err(e) => log::error!(
"[Android] No runtime available to report progress; dropping it: {e}"
),
}
});
}
@@ -790,51 +805,56 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString,
media_id: JString,
) {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged",
|| {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
} else {
env.get_string(&media_id).map(|s| s.into()).ok()
};
let media_id_opt: Option<String> = if media_id.is_null() {
None
} else {
env.get_string(&media_id).map(|s| s.into()).ok()
};
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
match state_str.as_str() {
"playing" => {
shared.state = PlayerState::Playing {
media,
position,
duration,
};
shared.is_loaded = true;
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
match state_str.as_str() {
"playing" => {
shared.state = PlayerState::Playing {
media,
position,
duration,
};
shared.is_loaded = true;
}
"paused" => {
shared.state = PlayerState::Paused {
media,
position,
duration,
};
}
"idle" => {
shared.state = PlayerState::Idle;
shared.is_loaded = false;
}
_ => {}
}
}
"paused" => {
shared.state = PlayerState::Paused {
media,
position,
duration,
};
}
"idle" => {
shared.state = PlayerState::Idle;
shared.is_loaded = false;
}
_ => {}
}
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::StateChanged {
state: state_str,
media_id: media_id_opt,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::StateChanged {
state: state_str,
media_id: media_id_opt,
});
}
},
);
}
/// Called when media has finished loading.
@@ -844,15 +864,20 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass,
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|| {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
},
);
}
/// Called when playback reaches the end.
@@ -861,49 +886,38 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_env: JNIEnv,
_class: JClass,
) {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|| {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
// Get player controller and handle autoplay decision
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
// Get player controller and handle autoplay decision
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
// Spawn async task to handle autoplay decision
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
Ok(AutoplayDecision::AdvanceToNext) => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let ctrl = controller.lock().await;
// Spawn async task to handle autoplay decision
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
Ok(AutoplayDecision::AdvanceToNext) => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let ctrl = controller.lock().await;
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
match ctrl.next() {
Ok(_) => {
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
@@ -912,93 +926,115 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
// Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed();
match ctrl.next() {
Ok(_) => {
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!(
"[Autoplay] Queue state after next(): {}",
queue_info
);
// Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed();
}
Err(e) => {
log::error!(
"[Autoplay] Failed to advance to next track: {}",
e
);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
}) => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
if auto_advance {
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Ok(AutoplayDecision::ResumeStream { position }) => {
// ExoPlayer reported ENDED because the progressive transcode's
// connection dropped, not because the episode finished. This
// is the arm that matters while backgrounded: it needs no
// frontend echo, so the stream re-opens even with the webview
// suspended — and playback never parks in STATE_ENDED, where
// the next lockscreen/Bluetooth play restarts the item at 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
Err(e) => {
log::error!("[Autoplay] Failed to advance to next track: {}", e);
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
}) => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
if auto_advance {
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Ok(AutoplayDecision::ResumeStream { position }) => {
// ExoPlayer reported ENDED because the progressive transcode's
// connection dropped, not because the episode finished. This
// is the arm that matters while backgrounded: it needs no
// frontend echo, so the stream re-opens even with the webview
// suspended — and playback never parks in STATE_ENDED, where
// the next lockscreen/Bluetooth play restarts the item at 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
Err(e) => {
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
});
} else {
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
// Fallback: just emit PlaybackEnded event
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
});
} else {
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
// Fallback: just emit PlaybackEnded event
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
},
);
}
/// Called when buffering state changes.
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass,
percent: jint,
) {
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8,
});
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|| {
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8,
});
}
},
);
}
/// Called when a playback error occurs.
@@ -1023,67 +1064,72 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
message: JString,
recoverable: jboolean,
) {
let message_str: String = env
.get_string(&message)
.map(|s| s.into())
.unwrap_or_else(|_| "Unknown error".to_string());
let recoverable = recoverable != 0;
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|| {
let message_str: String = env
.get_string(&message)
.map(|s| s.into())
.unwrap_or_else(|_| "Unknown error".to_string());
let recoverable = recoverable != 0;
// A background audio-only handoff is an mp3 the device was already decoding,
// so a recoverable failure part-way through is the network. Surfacing it as a
// player error stops playback for good (the frontend's handler calls
// player_stop); re-opening the stream where it died is the "buffer and
// resume" this actually is. Everything else keeps reporting the error.
if recoverable {
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
let message_str = message_str.clone();
tauri::async_runtime::spawn(async move {
let resume = controller.lock().await.recoverable_error_resume();
let Some((position, delay_secs)) = resume else {
// Declined here, so report it as NOT recoverable: the frontend
// would otherwise echo it into player_recover_stream and ask
// the same question a second time.
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
// A background audio-only handoff is an mp3 the device was already decoding,
// so a recoverable failure part-way through is the network. Surfacing it as a
// player error stops playback for good (the frontend's handler calls
// player_stop); re-opening the stream where it died is the "buffer and
// resume" this actually is. Everything else keeps reporting the error.
if recoverable {
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
let message_str = message_str.clone();
tauri::async_runtime::spawn(async move {
let resume = controller.lock().await.recoverable_error_resume();
let Some((position, delay_secs)) = resume else {
// Declined here, so report it as NOT recoverable: the frontend
// would otherwise echo it into player_recover_stream and ask
// the same question a second time.
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
return;
};
log::warn!(
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
message_str,
position,
delay_secs
);
// Give a brief outage time to clear before asking the server for
// the stream again; retrying instantly just burns the budget.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
}
});
return;
};
log::warn!(
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
message_str,
position,
delay_secs
);
// Give a brief outage time to clear before asking the server for
// the stream again; retrying instantly just burns the budget.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
}
});
return;
}
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable,
});
}
},
);
}
/// Called when volume changes.
@@ -1094,16 +1140,21 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
volume: jfloat,
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume;
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|| {
if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::VolumeChanged {
volume,
muted: muted != 0,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::VolumeChanged {
volume,
muted: muted != 0,
});
}
},
);
}
// JNI callback for MediaSession commands from JellyTauPlaybackService
@@ -1127,14 +1178,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
_class: JClass,
command: JString,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|| {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str);
}
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str);
}
},
);
}
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
@@ -1148,14 +1204,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
command: JString,
volume: jint,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|| {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32);
}
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32);
}
},
);
}
/// JNI callback from Kotlin when codec detection completes.
@@ -1170,40 +1231,45 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
audio_codecs: JString,
max_audio_channels: jint,
) {
let video_str: String = env
.get_string(&video_codecs)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|| {
let video_str: String = env
.get_string(&video_codecs)
.map(|s| s.into())
.unwrap_or_default();
let audio_str: String = env
.get_string(&audio_codecs)
.map(|s| s.into())
.unwrap_or_default();
let audio_str: String = env
.get_string(&audio_codecs)
.map(|s| s.into())
.unwrap_or_default();
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
codecs.video_codecs.len(),
codecs.video_codecs_string()
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
codecs.video_codecs.len(),
codecs.video_codecs_string()
);
log::info!(
"[CodecDetection] Detected {} audio codecs: {}",
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized");
}
},
);
log::info!(
"[CodecDetection] Detected {} audio codecs: {}",
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized");
}
}
/// Start the JellyTauPlaybackService if not already running.
+122
View File
@@ -0,0 +1,122 @@
//! Panic containment for the Android JNI boundary.
//!
//! Compiled on every platform, unlike `player::android` itself, so the guard and
//! the tripwire that enforces its use are unit-tested on the host — the same
//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
/// Run the body of a JNI callback with any panic contained.
///
/// Every `extern "system"` function in this file is called by the JVM on an
/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
/// which Rust answers by **aborting the process** — the app vanishes with no
/// Java exception, no stack trace attributable to it, and no crash report the
/// user can send. That is the worst possible failure mode for the callbacks
/// that fire four times a second during playback.
///
/// The panics are real, not theoretical: this file builds a fallback Tokio
/// runtime on threads that have none, and `Runtime::new()` fails under the fd
/// exhaustion and thread-spawn refusal an Android device puts a media app
/// through. Losing one position report is recoverable; losing the process is
/// not.
///
/// A contained panic still leaves whatever it interrupted half-done, so this is
/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
/// mutex from cascading; this keeps the FFI boundary from turning any remaining
/// panic into a process kill.
///
/// TRACES: UR-005 | DR-052
///
/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
/// so it is dead code on every other target — the same reason
/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
/// tested here on purpose.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
// AssertUnwindSafe: the shared state behind these callbacks is already
// reached through poison-tolerant locks, so a panic cannot hand out a
// guard observing a torn value.
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
// The panic hook has already logged the payload and location.
log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
}
}
// TRACES: UR-005 | DR-052 | UT-052
#[cfg(test)]
mod jni_guard_tests {
use super::*;
/// The guard must swallow a panic rather than let it reach the JVM.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_panicking_callback_body_does_not_escape_the_guard() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
std::panic::set_hook(hook);
// Reaching here at all is the assertion: without the guard the panic
// would unwind out of the `extern "system"` fn and abort the process.
}
/// The guard must not disturb a callback that behaves.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_normal_callback_body_still_runs() {
let mut ran = false;
jni_guard("test_callback", || ran = true);
assert!(ran);
}
/// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
///
/// A panic crossing the `extern "system"` boundary aborts the process, so a
/// twelfth callback added without the guard reintroduces the whole defect.
/// Checked against the source because the real boundary needs a JVM to
/// exercise — the same tripwire idiom as `check:boundary`.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn every_jni_entry_point_wraps_its_body_in_the_guard() {
let src = include_str!("android/mod.rs");
let mut unguarded = Vec::new();
let mut lines = src.lines().enumerate().peekable();
while let Some((_, line)) = lines.next() {
if !line.starts_with("pub extern \"system\" fn ") {
continue;
}
let name = line
.trim_start_matches("pub extern \"system\" fn ")
.trim_end_matches('(')
.to_string();
// Walk to the end of the parameter list, then look at the first
// statement of the body.
let mut body_start = None;
for (n, l) in lines.by_ref() {
if l.trim_end().ends_with(") {") || l.trim() == ") {" {
body_start = Some(n);
break;
}
}
assert!(body_start.is_some(), "could not find the body of {name}");
match lines.peek() {
Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
other => unguarded.push(format!(
"{name} (body starts with {:?})",
other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
)),
}
}
assert!(
unguarded.is_empty(),
"JNI entry points whose body is not wrapped in jni_guard — a panic in \
one of these aborts the process:\n {}",
unguarded.join("\n ")
);
}
}
+4
View File
@@ -28,6 +28,10 @@ pub mod track_switch;
#[cfg(test)]
mod mpv_backend_test;
// Panic containment for the JNI boundary. Not gated on the target: the guard
// and its tripwire test are exercised on the host, where `android` never builds.
pub mod jni_guard;
// Platform-specific backends
#[cfg(target_os = "android")]
pub mod android;