Follow the phone from one network to another

A server knows its clients by address, so moving between Wi-Fi and mobile
data leaves the connection with nothing to do but time out. The process's
sockets are now bound to the network a connection was made over: on a
change, the goodbye still leaves through the old network while it lasts,
and the connection is made again over the new one, back into the channel
we were in. A drop because the network vanished counts as a change too.

A server still holding the old connection refuses the new one as a clone
until it times out, so attempts are retried with a growing pause; a ban
ends it, and so do five minutes without getting back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 18:31:41 +00:00
parent c8e99c8946
commit 5fa7024bcb
2 changed files with 212 additions and 26 deletions

View File

@@ -0,0 +1,198 @@
package com.ts3client.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import com.ts3client.session.ServerSession
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* Keeps the connection on the network Android routes over.
*
* A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile
* data (or back) the old connection is dead weight: it can only time out. This notices the
* move, says goodbye over the old network while that still works, and connects again over
* the new one, back into the channel we were in. A server that still holds our old
* connection may refuse the new one for a while as a clone, so a failed attempt is retried.
*
* The process's sockets are bound to the network a connection was made over. Otherwise the
* old connection would silently follow the new default network, and its goodbye would come
* from an address the server does not know.
*/
class NetworkFollower(
context: Context,
private val session: ServerSession,
private val onChange: () -> Unit,
private val system: (String) -> Unit,
) {
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** The system's default network; null while there is none. */
@Volatile
private var network: Network? = null
/** The network the connection was made over. */
@Volatile
private var bound: Network? = null
/** The user wants to be on the server: from their connect until they, or the server, end it. */
@Volatile
private var wanted = false
private var settle: Job? = null
@Volatile
private var recovery: Job? = null
@Volatile
private var attempt: CompletableDeferred<Boolean>? = null
/** What to show in place of the session's status while getting back on the server. */
@Volatile
var status: String? = null
private set
val isRecovering: Boolean get() = recovery?.isActive == true
init {
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) {
network = available
reconsider()
}
override fun onLost(lost: Network) {
if (network == lost) network = null
reconsider()
}
})
}
/** Before a connect the user asked for, which goes over the network there is now. */
fun connecting() {
recovery?.cancel()
wanted = true
bind(connectivity.activeNetwork)
}
/** The user leaves the server. */
fun leaving() {
wanted = false
recovery?.cancel()
unbind()
}
fun onConnected() {
attempt?.complete(true)
}
fun onDisconnected() {
val pending = attempt
when {
pending != null -> pending.complete(false)
isRecovering -> {}
// A socket on a network that went away fails at once, before we hear of the change.
wanted && networkGone() -> synchronized(this) {
if (!isRecovering) recovery = recover()
}
// The server let us go (a kick, a shutdown, a failed first connect): leave it at that.
else -> {
wanted = false
unbind()
}
}
}
/** Waits for the network to settle: a handover often shows several changes in a row. */
private fun reconsider() {
synchronized(this) {
settle?.cancel()
settle = scope.launch {
delay(SETTLE_MS)
synchronized(this@NetworkFollower) {
if (wanted && bound != network && !isRecovering) recovery = recover()
}
}
}
}
private fun recover() = scope.launch {
try {
val back = withTimeoutOrNull(GIVE_UP_MS) {
system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…")
if (session.isBusy) session.connection().disconnectBlocking("Network changed")
var retry = RETRY_MS
while (true) {
val current = awaitNetwork()
bind(current)
show("Reconnecting…")
val done = CompletableDeferred<Boolean>()
attempt = done
session.reconnect()
val connected = done.await()
attempt = null
if (connected) break
if (session.connection().connectErrorId == ERROR_BANNED) break
show("Reconnecting, retrying soon…")
delay(retry)
retry = (retry * 2).coerceAtMost(MAX_RETRY_MS)
}
}
if (session.connection().connectErrorId == ERROR_BANNED) {
wanted = false
unbind()
} else if (back == null) {
system("Could not get back on the server.")
wanted = false
unbind()
}
} finally {
attempt = null
status = null
onChange()
// The network may have moved again while we were busy.
reconsider()
}
}
private fun networkGone(): Boolean {
val on = bound ?: return false
return connectivity.getNetworkCapabilities(on) == null || connectivity.activeNetwork != on
}
private suspend fun awaitNetwork(): Network {
while (true) {
connectivity.activeNetwork?.let { return it }
show("Waiting for network…")
delay(POLL_MS)
}
}
private fun show(text: String) {
status = text
onChange()
}
private fun bind(to: Network?) {
connectivity.bindProcessToNetwork(to)
bound = to
}
private fun unbind() = bind(null)
private companion object {
const val SETTLE_MS = 1_500L
/** The server's refusal for a banned client; asking again will not help. */
const val ERROR_BANNED = 3329
const val RETRY_MS = 5_000L
const val MAX_RETRY_MS = 30_000L
const val POLL_MS = 500L
const val GIVE_UP_MS = 5 * 60_000L
}
}

View File

@@ -4,8 +4,6 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
@@ -69,6 +67,7 @@ class SessionController(private val context: Context) {
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener() settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
) )
private val conn get() = session.connection() private val conn get() = session.connection()
private val network = NetworkFollower(context, session, ::refresh, ::system)
private val _state = MutableStateFlow(snapshotState()) private val _state = MutableStateFlow(snapshotState())
val state: StateFlow<SessionState> = _state val state: StateFlow<SessionState> = _state
@@ -105,25 +104,6 @@ class SessionController(private val context: Context) {
private val structureChanged = AtomicBoolean(true) private val structureChanged = AtomicBoolean(true)
private var roots: List<ChannelNode> = emptyList() private var roots: List<ChannelNode> = emptyList()
/** The network the connection runs over, to notice when the system moves to another. */
@Volatile
private var network: Network? = null
init {
context.getSystemService(ConnectivityManager::class.java)
.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) {
val previous = network
network = available
// The socket is bound to the old network's address and would only time out.
if (previous != null && previous != available && session.isBusy) {
system("Network changed, reconnecting…")
session.reconnect()
}
}
})
}
private val levels = LevelMonitor(settings, audio) private val levels = LevelMonitor(settings, audio)
val micLevel: StateFlow<MicLevel> = levels.level val micLevel: StateFlow<MicLevel> = levels.level
@Volatile @Volatile
@@ -173,10 +153,14 @@ class SessionController(private val context: Context) {
// Capture starts once connected; without the permission yet, the UI hands it over later. // Capture starts once connected; without the permission yet, the UI hands it over later.
if (hasMicPermission()) session.setMicrophoneActive(true) if (hasMicPermission()) session.setMicrophoneActive(true)
context.startForegroundService(Intent(context, ConnectionService::class.java)) context.startForegroundService(Intent(context, ConnectionService::class.java))
network.connecting()
session.connect(host, port, nickname, password, identityId, channel, channelPassword) session.connect(host, port, nickname, password, identityId, channel, channelPassword)
} }
fun disconnect() = session.disconnect() fun disconnect() {
network.leaving()
session.disconnect()
}
// ---- bookmarks ---- // ---- bookmarks ----
@@ -252,7 +236,7 @@ class SessionController(private val context: Context) {
private fun updateLevels() { private fun updateLevels() {
val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null
levels.update(watchingLevel, source, session.isBusy, hasMicPermission()) levels.update(watchingLevel, source, session.isBusy || network.isRecovering, hasMicPermission())
} }
// ---- channels and clients ---- // ---- channels and clients ----
@@ -329,9 +313,9 @@ class SessionController(private val context: Context) {
val self = conn.model.getClient(conn.selfClientId) val self = conn.model.getClient(conn.selfClientId)
return SessionState( return SessionState(
title = session.title(), title = session.title(),
status = session.status(), status = network.status ?: session.status(),
connected = session.isConnected, connected = session.isConnected,
busy = session.isBusy, busy = session.isBusy || network.isRecovering,
micActive = conn.isMicrophoneActive, micActive = conn.isMicrophoneActive,
micMuted = session.isMicMuted, micMuted = session.isMicMuted,
deafened = session.isDeafened, deafened = session.isDeafened,
@@ -379,9 +363,13 @@ class SessionController(private val context: Context) {
override fun onStatus(status: String) {} override fun onStatus(status: String) {}
override fun onConnected() = refresh() override fun onConnected() {
network.onConnected()
refresh()
}
override fun onDisconnected(reason: String?) { override fun onDisconnected(reason: String?) {
network.onDisconnected()
_chats.update { it.copy(private = emptyList()) } _chats.update { it.copy(private = emptyList()) }
scheduleTree(structure = true) scheduleTree(structure = true)
refresh() refresh()