fix(android): close outgoing sessions when the task is swiped away (#15753)

* fix(android): close outgoing sessions when the task is swiped away

Swiping RustDesk away from recents destroys the UI but does not
necessarily end the process: when MainService is running (screen share
enabled, or started at boot) the process survives, and with it the
native io_loop of any active outgoing session.

That orphaned io_loop keeps echoing TestDelay (client.rs handle_test_delay
runs entirely on the network thread, no UI involved), which keeps
refreshing last_recv_time on the controlled side. Its 30s inactivity
timeout in server/connection.rs therefore never fires, so the remote
session stays established with no UI left to close it, and the peer
cannot be reconnected to.

Close client sessions from Service.onTaskRemoved, which fires only on
explicit task removal -- not on Home or backgrounding, so ordinary
backgrounding is unaffected. The service itself keeps running, so
incoming connections and the device staying reachable are unchanged.

This complements 152c5c71b, which covered the route-pop path via
dispose(); dispose() does not run when the task is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): also close sessions on activity destroy

Review follow-up. onTaskRemoved only reaches MainService, but the
accessibility InputService keeps the process alive on its own: a user
with input control enabled and screen sharing off has a surviving
process after a swipe while MainService is not running, so the callback
never fires and the session still outlives its UI. onTaskRemoved cannot
cover that -- InputService is bound by the system, not started, so the
callback is not delivered there.

Close from MainActivity.onDestroy() as well, which runs while the
process is still alive regardless of which service keeps it up. Guarded
on isFinishing so a destroy for recreation (configuration change, "don't
keep activities") does not tear down a live session. Both paths are
idempotent.

Also drop the now-wrong "on task removed" wording from the Rust log,
which has two distinct callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): release held keys before draining the session map

close_all_sessions drained SESSIONS first, then called
release_remote_keys. The release path sends through get_cur_session(),
which resolves against SESSIONS, so every generated key-up was dropped
after take_remote_keys() had already cleared TO_RELEASE: a key held as
the task is removed stays down on the controlled side until its own
timeout, with the state lost locally.

Release first, while a session is still registered. It is a no-op when
no key is held, so the previous is_empty() guard is not needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RustDesk
2026-08-04 11:18:14 +08:00
committed by GitHub
parent d752823b8c
commit e6dd925ab0
5 changed files with 70 additions and 0 deletions

View File

@@ -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<FlutterSession> = SESSIONS
.write()
.unwrap()
.drain()
.map(|(_, session)| session)
.collect();
for session in sessions.iter() {
let session_ids: Vec<SessionID> = 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:

View File

@@ -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,