Show the local client's state in the Android connection notification, like the tray icon

The notification follows the server holding the microphone, as the desktop tray
does, and draws its state: the Material glyph in the status bar, the chosen
pack's art as the large icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:21:42 +00:00
parent 2d83eafffd
commit 5fd2a612b9
4 changed files with 88 additions and 18 deletions

View File

@@ -8,6 +8,8 @@ import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.Color
import android.graphics.drawable.Icon
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.PowerManager import android.os.PowerManager
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@@ -17,12 +19,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import com.ts3client.config.Settings import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus import com.ts3client.net.ClientStatus
import com.ts3client.session.SelfState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** /**
* Keeps the process in the foreground while connected, so the connection and its audio * Keeps the process in the foreground while connected, so the connection and its audio
@@ -36,6 +43,7 @@ class ConnectionService : LifecycleService() {
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var overlay: PttOverlay private lateinit var overlay: PttOverlay
private var foregroundTypes = 0 private var foregroundTypes = 0
private var shown: ServerController? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -53,19 +61,30 @@ class ConnectionService : LifecycleService() {
show(sessions.current.value?.state?.value ?: SessionState(), capturing = false) show(sessions.current.value?.state?.value ?: SessionState(), capturing = false)
lifecycleScope.launch { lifecycleScope.launch {
val currentState = sessions.current.flatMapLatest { it?.state ?: flowOf(null) }
// The server count shows in the notification too. // The server count shows in the notification too.
combine(currentState, sessions.micOwner, sessions.servers) { state, owner, _ -> state to (owner != null) } combine(shownServer(), sessions.micOwner, sessions.servers) { shown, owner, _ -> shown to (owner != null) }
.collectLatest { (state, capturing) -> .conflate()
if (state != null) { .collect { (shown, capturing) ->
show(state, capturing) if (shown != null) {
} else { this@ConnectionService.shown = shown.first
show(shown.second, capturing)
// Talking flips the icon often, and Android drops updates that come too fast.
delay(MIN_UPDATE_MS)
}
}
}
lifecycleScope.launch {
sessions.current.flatMapLatest { it?.state ?: flowOf(null) }.collectLatest { state ->
if (state == null) {
// A reconnect leaves the server briefly; only stop once it stays gone. // A reconnect leaves the server briefly; only stop once it stays gone.
delay(STOP_DELAY_MS) delay(STOP_DELAY_MS)
stopSelf() stopSelf()
} }
} }
} }
lifecycleScope.launch {
sessions.icons.active.drop(1).collect { shown?.let { show(it.state.value, sessions.micOwner.value != null) } }
}
overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk) overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk)
lifecycleScope.launch { lifecycleScope.launch {
@@ -89,9 +108,25 @@ class ConnectionService : LifecycleService() {
} }
} }
/**
* The server the notification speaks for, as the desktop tray icon does: the one
* holding the microphone, or the visible one when nobody is capturing.
*/
private fun shownServer(): Flow<Pair<ServerController, SessionState>?> =
combine(sessions.current, sessions.micOwner) { current, owner -> current to owner }
.flatMapLatest { (current, owner) ->
combine(current?.state ?: flowOf(null), owner?.state ?: flowOf(null)) { currentState, ownerState ->
when {
owner != null && ownerState?.connected == true -> owner to ownerState
current != null && currentState != null -> current to currentState
else -> null
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val server = sessions.current.value ?: return START_NOT_STICKY val server = shown ?: sessions.current.value ?: return START_NOT_STICKY
val state = server.state.value val state = server.state.value
when (intent?.action) { when (intent?.action) {
ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted) ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted)
@@ -142,7 +177,8 @@ class ConnectionService : LifecycleService() {
) )
val others = sessions.servers.value.size - 1 val others = sessions.servers.value.size - 1
return Notification.Builder(this, CHANNEL) return Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_btn_speak_now) .setSmallIcon(statusIcon(state.self))
.setLargeIcon(largeIcon(state.self))
.setContentTitle(if (others > 0) "${state.title} +$others" else state.title) .setContentTitle(if (others > 0) "${state.title} +$others" else state.title)
.setContentText(state.status) .setContentText(state.status)
.setContentIntent(open) .setContentIntent(open)
@@ -153,6 +189,26 @@ class ConnectionService : LifecycleService() {
.build() .build()
} }
/**
* The status bar shows only the shape of an icon, which pack art loses, so it is
* drawn from the Material pack whatever pack is chosen.
*/
private fun statusIcon(self: SelfState): Icon {
val key = self.iconKey() ?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
val size = (STATUS_ICON_DP * resources.displayMetrics.density).roundToInt()
val bitmap = sessions.icons.renderMaterial(key, size, Color.WHITE)
?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
return Icon.createWithBitmap(bitmap)
}
/** The expanded notification has room for the chosen pack's own art, as the tree draws it. */
private fun largeIcon(self: SelfState): Icon? {
val key = self.iconKey() ?: return null
val size = (LARGE_ICON_DP * resources.displayMetrics.density).roundToInt()
return sessions.icons.render(listOf(key), size, getColor(android.R.color.system_neutral1_500))
?.let(Icon::createWithBitmap)
}
private fun action(action: String, label: String): Notification.Action { private fun action(action: String, label: String): Notification.Action {
val intent = PendingIntent.getService( val intent = PendingIntent.getService(
this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action), this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action),
@@ -165,6 +221,9 @@ class ConnectionService : LifecycleService() {
const val CHANNEL = "connection" const val CHANNEL = "connection"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val STOP_DELAY_MS = 3_000L const val STOP_DELAY_MS = 3_000L
const val MIN_UPDATE_MS = 250L
const val STATUS_ICON_DP = 24
const val LARGE_ICON_DP = 48
const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE" const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE"
const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN" const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN"
const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT" const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT"

View File

@@ -355,6 +355,7 @@ class ServerController internal constructor(
channelId = if (session.isConnected) self?.channelId ?: 0 else 0, channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
inputMode = app.settings.inputMode, inputMode = app.settings.inputMode,
hostButton = if (session.isConnected) conn.model.hostButton() else null, hostButton = if (session.isConnected) conn.model.hostButton() else null,
self = session.selfState(),
) )
} }

View File

@@ -20,6 +20,7 @@ import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeak
import com.ts3client.net.HostButton import com.ts3client.net.HostButton
import com.ts3client.myts.MyTeamSpeakLogin import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.session.SelfState
import com.ts3client.session.Sessions import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack import com.ts3client.sound.SoundPack
@@ -53,6 +54,8 @@ data class SessionState(
val channelId: Int = 0, val channelId: Int = 0,
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION, val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
val hostButton: HostButton? = null, val hostButton: HostButton? = null,
/** What the tree shows for the local client, and the connection notification too. */
val self: SelfState = SelfState.DISCONNECTED,
) )
/** Something that happened once, for the UI to tell the user about. */ /** Something that happened once, for the UI to tell the user about. */

View File

@@ -7,23 +7,30 @@ package com.ts3client.session;
*/ */
public enum SelfState { public enum SelfState {
DISCONNECTED("Not connected"), DISCONNECTED("Not connected", null),
DEAFENED("Speakers muted"), DEAFENED("Speakers muted", "OUTPUT_MUTED"),
MIC_MUTED("Microphone muted"), MIC_MUTED("Microphone muted", "INPUT_MUTED"),
MIC_LOCAL_MUTED("Microphone locally muted"), MIC_LOCAL_MUTED("Microphone locally muted", "INPUT_MUTED_LOCAL"),
AWAY("Away"), AWAY("Away", "AWAY"),
COMMANDER_TALKING("Talking (channel commander)"), COMMANDER_TALKING("Talking (channel commander)", "PLAYER_COMMANDER_ON"),
COMMANDER("Channel commander"), COMMANDER("Channel commander", "PLAYER_COMMANDER_OFF"),
TALKING("Talking"), TALKING("Talking", "PLAYER_ON"),
IDLE("Connected"); IDLE("Connected", "PLAYER_OFF");
private final String label; private final String label;
private final String iconKey;
SelfState(String label) { SelfState(String label, String iconKey) {
this.label = label; this.label = label;
this.iconKey = iconKey;
} }
public String label() { public String label() {
return label; return label;
} }
/** The icon pack key the state is drawn with, or {@code null} for the app's own icon. */
public String iconKey() {
return iconKey;
}
} }