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