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