diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index 29329d902..00b9162f6 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -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 = if media_id.is_null() { - None - } else { - env.get_string(&media_id).map(|s| s.into()).ok() - }; + let media_id_opt: Option = 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. diff --git a/src-tauri/src/player/jni_guard.rs b/src-tauri/src/player/jni_guard.rs new file mode 100644 index 000000000..08b9fd7e4 --- /dev/null +++ b/src-tauri/src/player/jni_guard.rs @@ -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(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("") + )), + } + } + + 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 ") + ); + } +} diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 923255783..083a515cd 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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;