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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:28:36 +00:00
parent 11a2a14c70
commit b3ad3ceda0
8 changed files with 186 additions and 19 deletions

View File

@@ -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)
}

View File

@@ -3,6 +3,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application
android:name=".Ts3Application"
@@ -18,5 +24,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ConnectionService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
</application>
</manifest>

View File

@@ -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"
}
}

View File

@@ -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)) {

View File

@@ -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)) }

View File

@@ -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)
}
}

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TS3J</string>
<string name="connection_channel">Connection</string>
</resources>

View File

@@ -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" }