diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 5561b8814..7274085fd 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -106,6 +106,16 @@ class MainActivity : FlutterActivity() { override fun onDestroy() { Log.e(logTag, "onDestroy") + // The process can outlive the UI whenever something keeps it alive: + // MainService, or the accessibility InputService on its own. Only the + // former gets onTaskRemoved, so close outgoing sessions here too, + // otherwise a session survives with no UI left to close it. + // `isFinishing` distinguishes the user really leaving from a destroy + // for recreation (configuration change, "don't keep activities"), + // which must not tear down a live session. + if (isFinishing) { + FFI.closeAllSessions() + } mainService?.let { unbindService(serviceConnection) } diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index 7bb16a00a..b03b63844 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -254,6 +254,16 @@ class MainService : Service() { super.onDestroy() } + // Swiping the app away from recents destroys the UI but this service keeps + // the process alive, so outgoing sessions would stay connected with no way + // to close them. Incoming connections are unaffected: the service keeps + // running so the device stays reachable. + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(logTag, "onTaskRemoved, closing outgoing sessions") + FFI.closeAllSessions() + super.onTaskRemoved(rootIntent) + } + private var isHalfScale: Boolean? = null; private fun updateScreenInfo(orientation: Int) { var w: Int diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index e3c9d9830..89e3dc046 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -21,6 +21,7 @@ object FFI { external fun onAudioFrameUpdate(buf: ByteBuffer) external fun translateLocale(localeName: String, input: String): String external fun refreshScreen() + external fun closeAllSessions() external fun setFrameRawEnable(name: String, value: Boolean) external fun setCodecInfo(info: String) external fun getLocalOption(key: String): String diff --git a/src/flutter.rs b/src/flutter.rs index a07d7c598..f6e3d3edd 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -2131,6 +2131,45 @@ pub mod sessions { s } + /// Close every client session, returning how many peer sessions were closed. + /// + /// Used when the UI is gone but the process keeps running, e.g. the Android + /// task is swiped away from recents while a foreground service keeps the + /// process alive. The orphaned `io_loop` would otherwise keep answering + /// `TestDelay`, so the peer never hits its inactivity timeout and the + /// session stays established with no way to close it. + #[cfg(any(target_os = "android", target_os = "ios"))] + pub fn close_all_sessions() -> usize { + // Release held keys before draining: the release path sends through + // `get_cur_session()`, which resolves against SESSIONS, so draining + // first would take TO_RELEASE and then silently drop every key-up, + // leaving the key stuck on the controlled side. A no-op when nothing + // is held. + crate::keyboard::release_remote_keys("map"); + // Drain so the map lock is released before closing each session. + let sessions: Vec = SESSIONS + .write() + .unwrap() + .drain() + .map(|(_, session)| session) + .collect(); + for session in sessions.iter() { + let session_ids: Vec = session + .ui_handler + .session_handlers + .read() + .unwrap() + .keys() + .cloned() + .collect(); + for session_id in session_ids { + session.close_event_stream(session_id); + } + session.close(); + } + sessions.len() + } + /// Check if removing a session by session_id would result in removing the entire peer. /// /// Returns: diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 0777443cf..9b73c4cd4 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -3128,6 +3128,16 @@ pub mod server_side { crate::server::video_service::refresh() } + /// Close outgoing sessions when the UI goes away but the process may not, + /// so a session cannot outlive the UI that is able to close it. + #[no_mangle] + pub unsafe extern "system" fn Java_ffi_FFI_closeAllSessions(_env: JNIEnv, _class: JClass) { + let closed = crate::flutter::sessions::close_all_sessions(); + if closed > 0 { + log::info!("closed {} outgoing session(s)", closed); + } + } + #[no_mangle] pub unsafe extern "system" fn Java_ffi_FFI_getLocalOption( env: JNIEnv,