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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user