diff --git a/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt b/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt new file mode 100644 index 0000000..1dde4ef --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/NetworkFollower.kt @@ -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? = 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() + 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 + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index d168388..7b5521f 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -4,8 +4,6 @@ import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.ConnectivityManager -import android.net.Network import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice @@ -69,6 +67,7 @@ class SessionController(private val context: Context) { settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener() ) private val conn get() = session.connection() + private val network = NetworkFollower(context, session, ::refresh, ::system) private val _state = MutableStateFlow(snapshotState()) val state: StateFlow = _state @@ -105,25 +104,6 @@ class SessionController(private val context: Context) { private val structureChanged = AtomicBoolean(true) private var roots: List = 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) val micLevel: StateFlow = levels.level @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. if (hasMicPermission()) session.setMicrophoneActive(true) context.startForegroundService(Intent(context, ConnectionService::class.java)) + network.connecting() session.connect(host, port, nickname, password, identityId, channel, channelPassword) } - fun disconnect() = session.disconnect() + fun disconnect() { + network.leaving() + session.disconnect() + } // ---- bookmarks ---- @@ -252,7 +236,7 @@ class SessionController(private val context: Context) { private fun updateLevels() { 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 ---- @@ -329,9 +313,9 @@ class SessionController(private val context: Context) { val self = conn.model.getClient(conn.selfClientId) return SessionState( title = session.title(), - status = session.status(), + status = network.status ?: session.status(), connected = session.isConnected, - busy = session.isBusy, + busy = session.isBusy || network.isRecovering, micActive = conn.isMicrophoneActive, micMuted = session.isMicMuted, deafened = session.isDeafened, @@ -379,9 +363,13 @@ class SessionController(private val context: Context) { override fun onStatus(status: String) {} - override fun onConnected() = refresh() + override fun onConnected() { + network.onConnected() + refresh() + } override fun onDisconnected(reason: String?) { + network.onDisconnected() _chats.update { it.copy(private = emptyList()) } scheduleTree(structure = true) refresh()