From b3ad3ceda072c395315b8891fc188a74394e629e Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 25 Sep 2026 08:28:36 +0000 Subject: [PATCH] Keep the Android connection alive in the background The session moves from the connection screen's ViewModel into an app-wide SessionController, and a foreground service keeps the process running while connected: - Its notification shows the server and status and offers Mute, Deafen and Disconnect. - It declares microphone use only once the microphone is handed to the connection, as Android requires that permission to be granted then. - It holds a low-latency Wi-Fi lock and a CPU wake lock, since voice goes out every 20 ms. - When the system moves to another network, the session reconnects to where it was, as its socket stays bound to the old one. Co-Authored-By: Claude Opus 5.5 --- android/app/build.gradle.kts | 3 +- android/app/src/main/AndroidManifest.xml | 11 ++ .../ts3client/android/ConnectionService.kt | 123 ++++++++++++++++++ .../com/ts3client/android/MainActivity.kt | 15 ++- ...ssionViewModel.kt => SessionController.kt} | 45 +++++-- .../com/ts3client/android/Ts3Application.kt | 5 + android/app/src/main/res/values/strings.xml | 1 + android/gradle/libs.versions.toml | 2 +- 8 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ConnectionService.kt rename android/app/src/main/java/com/ts3client/android/{SessionViewModel.kt => SessionController.kt} (72%) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index be76544..dc21fb4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,6 +22,7 @@ android { buildTypes { release { isMinifyEnabled = true + isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } @@ -51,5 +52,5 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.service) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c7f32ee..a6c5aae 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,12 @@ + + + + + + + + diff --git a/android/app/src/main/java/com/ts3client/android/ConnectionService.kt b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt new file mode 100644 index 0000000..c68e27d --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ConnectionService.kt @@ -0,0 +1,123 @@ +package com.ts3client.android + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.PowerManager +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Keeps the process in the foreground while connected, so the connection and its audio + * survive the app leaving the screen, and offers the essentials in its notification. + */ +class ConnectionService : LifecycleService() { + + private lateinit var sessions: SessionController + private lateinit var wifiLock: WifiManager.WifiLock + private lateinit var wakeLock: PowerManager.WakeLock + private var foregroundTypes = 0 + + override fun onCreate() { + super.onCreate() + sessions = (application as Ts3Application).sessions + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, getString(R.string.connection_channel), NotificationManager.IMPORTANCE_LOW) + ) + // Voice goes out every 20 ms; Wi-Fi power saving and a sleeping CPU would stretch that. + wifiLock = getSystemService(WifiManager::class.java) + .createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "ts3j:voice") + .apply { setReferenceCounted(false); acquire() } + wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection") + .apply { setReferenceCounted(false); acquire() } + + show(sessions.state.value) + lifecycleScope.launch { + sessions.state.collectLatest { state -> + if (state.busy) { + show(state) + } else { + // A reconnect leaves the server briefly; only stop once it stays gone. + delay(STOP_DELAY_MS) + stopSelf() + } + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val state = sessions.state.value + when (intent?.action) { + ACTION_TOGGLE_MUTE -> sessions.setMicMuted(!state.micMuted) + ACTION_TOGGLE_DEAFEN -> sessions.setDeafened(!state.deafened) + ACTION_DISCONNECT -> sessions.disconnect() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + wifiLock.release() + wakeLock.release() + super.onDestroy() + } + + /** Posts the notification, re-declaring what the service uses when that changed. */ + private fun show(state: SessionState) { + val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + if (state.micActive && hasMicPermission()) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 + val notification = notification(state) + if (types != foregroundTypes) { + startForeground(NOTIFICATION_ID, notification, types) + foregroundTypes = types + } else { + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification) + } + } + + private fun hasMicPermission() = + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + + private fun notification(state: SessionState): Notification { + val open = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE + ) + return Notification.Builder(this, CHANNEL) + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setContentTitle(state.title) + .setContentText(state.status) + .setContentIntent(open) + .setOngoing(true) + .addAction(action(ACTION_TOGGLE_MUTE, if (state.micMuted) "Unmute" else "Mute")) + .addAction(action(ACTION_TOGGLE_DEAFEN, if (state.deafened) "Undeafen" else "Deafen")) + .addAction(action(ACTION_DISCONNECT, "Disconnect")) + .build() + } + + private fun action(action: String, label: String): Notification.Action { + val intent = PendingIntent.getService( + this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE + ) + return Notification.Action.Builder(null, label, intent).build() + } + + private companion object { + const val CHANNEL = "connection" + const val NOTIFICATION_ID = 1 + const val STOP_DELAY_MS = 3_000L + const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE" + const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN" + const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT" + } +} diff --git a/android/app/src/main/java/com/ts3client/android/MainActivity.kt b/android/app/src/main/java/com/ts3client/android/MainActivity.kt index d6b5200..bec6785 100644 --- a/android/app/src/main/java/com/ts3client/android/MainActivity.kt +++ b/android/app/src/main/java/com/ts3client/android/MainActivity.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -45,7 +44,7 @@ class MainActivity : ComponentActivity() { setContent { MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { Surface(Modifier.fillMaxSize()) { - ConnectionScreen(viewModel()) + ConnectionScreen((application as Ts3Application).sessions) } } } @@ -54,7 +53,7 @@ class MainActivity : ComponentActivity() { /** A bare connection screen for trying the port out; the real UI replaces it. */ @Composable -private fun ConnectionScreen(vm: SessionViewModel) { +private fun ConnectionScreen(vm: SessionController) { val state by vm.state.collectAsStateWithLifecycle() var address by remember { mutableStateOf(vm.lastAddress) } var nickname by remember { mutableStateOf(vm.nickname) } @@ -64,6 +63,8 @@ private fun ConnectionScreen(vm: SessionViewModel) { val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) vm.setMicrophoneActive(true) } + // Without it the connection still runs, just with no notification to control it from. + val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} Column( Modifier.safeDrawingPadding().padding(12.dp), @@ -81,7 +82,13 @@ private fun ConnectionScreen(vm: SessionViewModel) { if (state.busy) { Button(onClick = vm::disconnect) { Text("Disconnect") } } else { - Button(onClick = { vm.connect(address, nickname) }, enabled = address.isNotBlank()) { Text("Connect") } + Button(onClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + vm.connect(address, nickname) + }, enabled = address.isNotBlank()) { Text("Connect") } } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/android/app/src/main/java/com/ts3client/android/SessionViewModel.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt similarity index 72% rename from android/app/src/main/java/com/ts3client/android/SessionViewModel.kt rename to android/app/src/main/java/com/ts3client/android/SessionController.kt index 3e8c108..a40c14a 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionViewModel.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -1,12 +1,15 @@ package com.ts3client.android -import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network import android.text.Html -import androidx.lifecycle.AndroidViewModel import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.config.IdentityStore import com.ts3client.config.Settings import com.ts3client.contacts.ContactStore +import com.ts3client.net.ChannelNode import com.ts3client.net.ConnectionListener.ChatScope import com.ts3client.session.ServerSession import com.ts3client.sound.SoundNotifier @@ -14,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -/** What the connection screen shows. */ +/** What the app shows of the connection. */ data class SessionState( val title: String = "", val status: String = "Not connected", @@ -29,13 +32,14 @@ data class SessionState( ) /** - * Holds one [ServerSession] and mirrors what it reports into [state]. Session callbacks - * arrive on connection threads; [MutableStateFlow] takes updates from any of them. + * The app's one [ServerSession], outliving any activity: [ConnectionService] keeps the + * process in the foreground while it is connected. Session callbacks arrive on connection + * threads; [MutableStateFlow] takes updates from any of them. */ -class SessionViewModel(app: Application) : AndroidViewModel(app) { +class SessionController(private val context: Context) { private val settings = Settings.load() - private val audio = AndroidAudioBackend(app) + private val audio = AndroidAudioBackend(context) private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) } private val session = ServerSession( settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener() @@ -47,12 +51,32 @@ class SessionViewModel(app: Application) : AndroidViewModel(app) { val nickname: String get() = settings.nickname val lastAddress: String get() = settings.lastAddress + /** 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()) { + log("Network changed, reconnecting…") + session.reconnect() + } + } + }) + } + fun connect(address: String, nickname: String) { val host = address.substringBefore(':').trim() val port = address.substringAfter(':', "9987").trim().toIntOrNull() ?: 9987 settings.lastAddress = address settings.nickname = nickname settings.save() + context.startForegroundService(Intent(context, ConnectionService::class.java)) session.connect(host, port, nickname, "", "", "", "") } @@ -70,10 +94,6 @@ class SessionViewModel(app: Application) : AndroidViewModel(app) { fun sendChannelMessage(text: String) = session.sendChat(ChatScope.CHANNEL, 0, text) - override fun onCleared() { - Thread { session.shutdown() }.start() - } - private fun refresh() { val model = session.connection().model _state.update { @@ -91,8 +111,7 @@ class SessionViewModel(app: Application) : AndroidViewModel(app) { } } - private fun countChannels(node: com.ts3client.net.ChannelNode): Int = - 1 + node.children.sumOf { countChannels(it) } + private fun countChannels(node: ChannelNode): Int = 1 + node.children.sumOf { countChannels(it) } private fun log(line: String) { _state.update { it.copy(log = (it.log + line).takeLast(500)) } diff --git a/android/app/src/main/java/com/ts3client/android/Ts3Application.kt b/android/app/src/main/java/com/ts3client/android/Ts3Application.kt index e42045d..8f8081a 100644 --- a/android/app/src/main/java/com/ts3client/android/Ts3Application.kt +++ b/android/app/src/main/java/com/ts3client/android/Ts3Application.kt @@ -4,9 +4,14 @@ import android.app.Application import com.ts3client.config.AppDirs class Ts3Application : Application() { + + lateinit var sessions: SessionController + private set + override fun onCreate() { super.onCreate() // Before any store loads: there is no home directory to find them in. AppDirs.init(filesDir, filesDir.toPath().resolve("chats")) + sessions = SessionController(this) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ea80664..90dec7c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ TS3J + Connection diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 18a2cdc..c35f5da 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,7 +14,7 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" } oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }