feat: NetworkChanged command for instant mobile reconnect, lower stall threshold 25s->8s

This commit is contained in:
ospab 2026-05-21 00:29:49 +03:00
parent baff58c7fb
commit 0cc5cf47ef
4 changed files with 100 additions and 18 deletions

View File

@ -40,6 +40,9 @@ pub enum BridgeCommand {
ToggleTunnel,
NextProfile,
ReloadConfig,
/// Triggered by Android NetworkCallback when the active network changes (WiFi→LTE, etc.).
/// Causes an immediate background reconnect without waiting for stall detection.
NetworkChanged,
Shutdown,
}

View File

@ -338,6 +338,73 @@ impl Bridge {
tx.send(UiEvent::ProfileChanged(self.profile)).await.ok();
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
}
Some(BridgeCommand::NetworkChanged) => {
if self.running {
// Network changed (e.g. WiFi→LTE): IP address changed, existing UDP
// socket is dead. Trigger immediate reconnect without waiting for stall.
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
self.last_valid_recv = Instant::now() - Duration::from_secs(100); // force stall path
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut new_sessions = Vec::with_capacity(session_count);
let mut successful_sessions = 0;
let mut rtt_sum = 0.0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = new_sessions.len();
let socket = Arc::new(sock);
let socket_clone = socket.clone();
let udp_tx_clone = udp_tx.clone();
let is_turn = self.turn_enabled;
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = if is_turn && n >= 4 && buf[0] == 0x40 && buf[1] == 0x00 {
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
if 4 + len <= n { Bytes::copy_from_slice(&buf[4..4+len]) } else { Bytes::copy_from_slice(&buf[..n]) }
} else {
Bytes::copy_from_slice(&buf[..n])
};
if udp_tx_clone.send((session_index, inbound)).await.is_err() { break; }
}
Err(e) => {
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
new_sessions.push(SessionState { socket, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("NetworkChanged reconnect session {}/{} failed: {}", idx + 1, session_count, err))).await;
}
}
}
if !new_sessions.is_empty() {
sessions_opt = Some(new_sessions);
udp_rx_opt = Some(udp_rx);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.last_valid_recv = Instant::now();
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "network changed");
self.metrics.connection_state.store(2, Ordering::Relaxed);
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect successful!".to_string())).await;
} else {
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect failed — will retry on keepalive tick".to_string())).await;
}
}
}
Some(BridgeCommand::ReloadConfig) => {
match ClientConfig::reload_from_json_near_binary() {
Ok(cfg) => {
@ -374,7 +441,7 @@ impl Bridge {
_ = keepalive_tick.tick() => {
if self.running {
// 1. Connection Liveness Check & Silent Background Reconnect
if self.last_valid_recv.elapsed().as_secs() > 25 {
if self.last_valid_recv.elapsed().as_secs() > 8 {
let elapsed = self.last_valid_recv.elapsed().as_secs();
if elapsed > 180 {
// Hard timeout after 3 minutes of total silence

View File

@ -36,6 +36,7 @@ class OstpClientSdk private constructor(private val context: Context) {
private external fun nativeStopClient(): Boolean
private external fun nativeGetMetrics(): String
private external fun nativeGetLogs(): String
private external fun notifyNetworkChanged()
// ── Public data models ────────────────────────────────────────────────────
@ -263,28 +264,19 @@ class OstpClientSdk private constructor(private val context: Context) {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
// Network came back (e.g. switched from WiFi to LTE)
// If we're not connected, trigger a reconnect by bouncing the native client
if (_state.value !is TunnelState.Connected && started.get()) {
emitLog("Network available — triggering reconnect")
scope.launch {
nativeStopClient()
delay(500L)
val json = config.toNativeJson()
val ok = nativeStartClient(json)
if (!ok) {
_state.value = TunnelState.Failed("Reconnect failed after network change")
} else {
if (!started.get()) return
// Network became available (WiFi→LTE, tower switch, etc.)
// Send a lightweight BridgeCommand::NetworkChanged to Rust so the bridge
// immediately reconnects on the new interface without a full stop/start.
emitLog("Network available — signalling Rust bridge for immediate reconnect")
_state.value = TunnelState.Connecting
}
}
}
notifyNetworkChanged()
}
override fun onLost(network: Network) {
if (_state.value is TunnelState.Connected) {
_state.value = TunnelState.Reconnecting("Network lost", 0)
emitLog("Network lost — waiting for reconnect")
emitLog("Network lost — waiting for new network")
}
}
}

View File

@ -353,3 +353,23 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_addLog(
add_log(text);
}
}
/// Called by Android NetworkCallback when the active network changes (WiFi→LTE, etc.).
/// Sends BridgeCommand::NetworkChanged to trigger an immediate reconnect in the Rust bridge.
#[no_mangle]
pub extern "system" fn Java_net_ostp_client_OstpClientSdk_notifyNetworkChanged(
_env: JNIEnv,
_class: JClass,
) {
let state = match STATE.lock() {
Ok(s) => s,
Err(_) => return,
};
if let Some(ref cmd_tx) = state.cmd_tx {
// Use try_send since we're likely on a background thread from Android's ConnectivityManager
let _ = cmd_tx.try_send(ostp_client::app::BridgeCommand::NetworkChanged);
add_log("notifyNetworkChanged: BridgeCommand::NetworkChanged sent".to_string());
}
}