Notify of pokes and private messages on Android, with TeamSpeak's sounds

While the app is out of sight, a poke or a private message posts a
notification; a message's one gathers the conversation, takes a reply
inline and opens its chat. The notifications are silent, as the sound
pack already plays TeamSpeak's own sound for either.

The build bundles the sound packs of the TeamSpeak 3 client found next
to the checkout (or at ts3.client.dir / TS3_CLIENT_DIR), unpacked into
the profile once per app version. Sound Settings picks the pack and its
volume, and speech packs talk through the phone's text-to-speech.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 15:12:16 +00:00
parent 002b1ac7ce
commit a6efc0b8da
14 changed files with 413 additions and 3 deletions

View File

@@ -3,6 +3,47 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
/**
* Copies the sound packs of an installed TeamSpeak 3 client into the app's assets, as the
* desktop client borrows them from the installation. They are TeamSpeak's and not in the
* repository; a build without the client ships no packs.
*/
abstract class BundleSoundPacks : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val packs: DirectoryProperty
@get:OutputDirectory
abstract val assets: DirectoryProperty
@TaskAction
fun copy() {
val out = assets.get().asFile
out.deleteRecursively()
val source = packs.get().asFile
if (!source.isDirectory) {
logger.warn("No TeamSpeak 3 sound packs at $source; the app ships without sounds")
return
}
source.copyRecursively(File(out, "sound"))
}
}
val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
val bundleSoundPacks = tasks.register<BundleSoundPacks>("bundleSoundPacks") {
packs.set(File(ts3ClientDir, "sound"))
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(bundleSoundPacks, BundleSoundPacks::assets)
}
}
android {
namespace = "com.ts3client.android"
compileSdk = 37

View File

@@ -11,6 +11,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Speech sound packs talk through the phone's text-to-speech engine. -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application
android:name=".Ts3Application"
android:label="@string/app_name"
@@ -19,6 +26,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -30,5 +38,9 @@
android:name=".ConnectionService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
<receiver
android:name=".ReplyReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,5 +1,6 @@
package com.ts3client.android
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -8,12 +9,36 @@ import com.ts3client.android.ui.App
import com.ts3client.android.ui.Ts3Theme
class MainActivity : ComponentActivity() {
private val sessions get() = (application as Ts3Application).sessions
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val sessions = (application as Ts3Application).sessions
if (savedInstanceState == null) openRequestedChat(intent)
setContent {
Ts3Theme { App(sessions) }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
openRequestedChat(intent)
}
override fun onStart() {
super.onStart()
sessions.foreground = true
}
override fun onStop() {
sessions.foreground = false
super.onStop()
}
/** A private message notification opens its conversation. */
private fun openRequestedChat(intent: Intent) {
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
if (clientId >= 0) sessions.requestChat(clientId)
}
}

View File

@@ -0,0 +1,132 @@
package com.ts3client.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.RemoteInput
import android.content.Context
import android.content.Intent
/**
* Tells the user about pokes and private messages while the app is out of sight. The
* notifications themselves are silent: the sound pack already plays TeamSpeak's own sound
* for either, as it does in the app.
*/
class MessageNotifier(private val context: Context) {
private val manager = context.getSystemService(NotificationManager::class.java)
private val me = Person.Builder().setName("You").build()
/** What each private conversation's notification shows, by the peer's client id. */
private val conversations = HashMap<Int, Conversation>()
private class Conversation(val peer: Person, val messages: MutableList<Notification.MessagingStyle.Message>)
init {
manager.createNotificationChannels(listOf(
channel(MESSAGES, R.string.messages_channel),
channel(POKES, R.string.pokes_channel),
))
}
private fun channel(id: String, name: Int) =
NotificationChannel(id, context.getString(name), NotificationManager.IMPORTANCE_HIGH).apply {
setSound(null, null)
enableVibration(true)
}
fun poke(from: String, message: String) {
val notification = Notification.Builder(context, POKES)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("$from poked you")
.setContentText(message.ifBlank { null })
.setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(null))
.setAutoCancel(true)
.build()
manager.notify(POKE_TAG, from.hashCode(), notification)
}
fun clearPokes() {
for (active in manager.activeNotifications) {
if (active.tag == POKE_TAG) manager.cancel(POKE_TAG, active.id)
}
}
/** A message the peer sent, which starts the conversation's notification if it had none. */
@Synchronized
fun received(clientId: Int, from: String, text: String) {
val conversation = conversations[clientId]?.takeIf { it.peer.name == from }
?: Conversation(Person.Builder().setName(from).setKey("client:$clientId").build(), ArrayList())
conversations[clientId] = conversation
add(clientId, conversation, text, conversation.peer, alert = true)
}
/** Our own reply, shown in a conversation already notified of; answers the reply action too. */
@Synchronized
fun sent(clientId: Int, text: String) {
val conversation = conversations[clientId] ?: return
add(clientId, conversation, text, me, alert = false)
}
private fun add(clientId: Int, conversation: Conversation, text: String, sender: Person, alert: Boolean) {
conversation.messages += Notification.MessagingStyle.Message(text, System.currentTimeMillis(), sender)
if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0)
val style = Notification.MessagingStyle(me)
conversation.messages.forEach(style::addMessage)
val notification = Notification.Builder(context, MESSAGES)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setStyle(style)
.setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(clientId))
.setOnlyAlertOnce(!alert)
.setAutoCancel(true)
.addAction(replyAction(clientId))
.build()
manager.notify(MESSAGE_TAG, clientId, notification)
}
/** The conversation came on screen, or ended with the connection. */
@Synchronized
fun clear(clientId: Int) {
conversations.remove(clientId)
manager.cancel(MESSAGE_TAG, clientId)
}
@Synchronized
fun clearAll() {
conversations.keys.toList().forEach(::clear)
clearPokes()
}
private fun open(clientId: Int?): PendingIntent {
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (clientId != null) intent.putExtra(EXTRA_CHAT, clientId)
return PendingIntent.getActivity(context, clientId ?: -1, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun replyAction(clientId: Int): Notification.Action {
val intent = Intent(context, ReplyReceiver::class.java).putExtra(EXTRA_CHAT, clientId)
// Mutable: the system fills the typed reply into it.
val pending = PendingIntent.getBroadcast(context, clientId, intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return Notification.Action.Builder(null, "Reply", pending)
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build())
.setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY)
.build()
}
companion object {
/** The client id of the private chat a notification opens or replies to. */
const val EXTRA_CHAT = "com.ts3client.android.CHAT"
const val KEY_REPLY = "reply"
private const val MESSAGES = "messages"
private const val POKES = "pokes"
private const val MESSAGE_TAG = "message"
private const val POKE_TAG = "poke"
private const val MAX_MESSAGES = 25
}
}

View File

@@ -0,0 +1,16 @@
package com.ts3client.android
import android.app.RemoteInput
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Sends what the user typed into a private message notification. */
class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
if (clientId < 0 || text.isNullOrBlank()) return
(context.applicationContext as Ts3Application).sessions.reply(clientId, text)
}
}

View File

@@ -21,9 +21,11 @@ import com.ts3client.net.ChannelNode
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack
import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -73,8 +75,9 @@ class SessionController(private val context: Context) {
private val bookmarkStore = Bookmarks.load()
private val identities = IdentityStore.load(settings)
private val audio = AndroidAudioBackend(context)
private val soundPlayer = audio.createSoundPlayer(settings)
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val notifier = MessageNotifier(context)
private val contactStore = ContactStore.load()
private val session = ServerSession(
settings, identities, audio, sounds, contactStore, Listener()
@@ -111,8 +114,21 @@ class SessionController(private val context: Context) {
set(value) {
field = value
if (value != null) markRead(value)
if (value is ChatKey.Private) notifier.clear(value.clientId)
}
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
@Volatile
var foreground = false
set(value) {
field = value
if (value) notifier.clearPokes()
}
/** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
private val lineIds = AtomicLong()
/** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */
@@ -265,6 +281,25 @@ class SessionController(private val context: Context) {
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
.also { _bookmarks.value = bookmarkStore.all().toList() }
// ---- sound packs ----
private val _soundPacks = MutableStateFlow(sounds.availablePacks())
val soundPacks: StateFlow<List<SoundPack>> = _soundPacks
val soundPack: SoundPack? get() = sounds.pack()
fun setSoundPack(pack: SoundPack) {
sounds.setPack(pack)
settings.save()
}
/** Unpacks the packs the app ships with, when this version has not yet. */
fun installSoundPacks() {
if (!SoundPackInstaller(context).install()) return
sounds.reload()
_soundPacks.value = sounds.availablePacks()
}
// ---- the local client ----
fun hasMicPermission() =
@@ -376,6 +411,19 @@ class SessionController(private val context: Context) {
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
}
fun requestChat(clientId: Int) {
_chatRequest.value = ChatKey.Private(clientId)
}
fun chatRequestHandled() {
_chatRequest.value = null
}
/** Answers a private message from its notification. */
fun reply(clientId: Int, text: String) {
if (session.isConnected) send(ChatKey.Private(clientId), text) else notifier.clear(clientId)
}
private fun append(key: ChatKey, title: String, html: String) {
if (html.isEmpty()) return
val line = ChatLine(lineIds.incrementAndGet(), html)
@@ -442,6 +490,7 @@ class SessionController(private val context: Context) {
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text)
append(key(scope, peerClientId), peerName, html)
if (scope == ChatScope.PRIVATE) notifier.sent(peerClientId, BBCode.toPlain(text))
}
override fun onStatus(status: String) {}
@@ -453,6 +502,7 @@ class SessionController(private val context: Context) {
override fun onDisconnected(reason: String?) {
network.onDisconnected()
notifier.clearAll()
_chats.update { it.copy(private = emptyList()) }
scheduleTree(structure = true)
refresh()
@@ -462,8 +512,10 @@ class SessionController(private val context: Context) {
override fun onInfoUpdated() {}
override fun onIconsUpdated() {}
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) =
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
if (scope == ChatScope.PRIVATE && !foreground) notifier.received(fromClientId, fromName, BBCode.toPlain(message))
}
override fun onChatHistory(scope: ChatScope, htmlEntries: List<String>) {
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
@@ -483,6 +535,7 @@ class SessionController(private val context: Context) {
override fun onPoke(fromName: String, message: String) {
_events.tryEmit(SessionEvent.Poke(fromName, message))
if (!foreground) notifier.poke(fromName, BBCode.toPlain(message))
}
override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message))

View File

@@ -0,0 +1,53 @@
package com.ts3client.android
import android.content.Context
import android.content.res.AssetManager
import com.ts3client.config.AppDirs
import java.io.File
/**
* Unpacks the sound packs bundled in the APK into the profile's `sound` folder, where
* [com.ts3client.sound.SoundNotifier] finds packs, as they are read as plain files.
* It happens once per installed version of the app; packs of other origins in the
* folder are left alone.
*/
class SoundPackInstaller(private val context: Context) {
private val root = File(AppDirs.profile(), ASSET_DIR)
private val marker = File(root, ".bundled")
/** Where a pack is unpacked first, outside [root] so it is never seen half written. */
private val staging = File(AppDirs.profile(), ".sound-staging")
/** @return whether the packs were (re)installed, so the list of them has to be read again */
fun install(): Boolean {
val version = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime.toString()
if (marker.isFile && marker.readText() == version) return false
val assets = context.assets
for (pack in assets.list(ASSET_DIR).orEmpty()) {
staging.deleteRecursively()
copy(assets, "$ASSET_DIR/$pack", staging)
val target = File(root, pack)
target.deleteRecursively()
root.mkdirs()
check(staging.renameTo(target)) { "Cannot install sound pack $pack" }
}
root.mkdirs()
marker.writeText(version)
return true
}
private fun copy(assets: AssetManager, path: String, to: File) {
val children = assets.list(path).orEmpty()
if (children.isEmpty()) {
to.parentFile?.mkdirs()
assets.open(path).use { input -> to.outputStream().use { input.copyTo(it) } }
return
}
to.mkdirs()
for (child in children) copy(assets, "$path/$child", File(to, child))
}
private companion object {
const val ASSET_DIR = "sound"
}
}

View File

@@ -0,0 +1,31 @@
package com.ts3client.android
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import com.ts3client.sound.SoundPlayer
/**
* Adds the phone's text-to-speech to a wave player, for packs that speak their
* notifications. Sentences asked for before the engine is up are dropped: they
* would come out late, past what they announce.
*/
class SpeakingSoundPlayer(context: Context, private val waves: SoundPlayer) : SoundPlayer by waves {
@Volatile
private var ready = false
private val tts = TextToSpeech(context.applicationContext) { status -> ready = status == TextToSpeech.SUCCESS }
override fun canSpeak() = true
override fun say(text: String, volume: Double) {
if (!ready) return
val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.toFloat()) }
tts.speak(text, TextToSpeech.QUEUE_ADD, params, null)
}
override fun shutdown() {
tts.shutdown()
waves.shutdown()
}
}

View File

@@ -13,5 +13,6 @@ class Ts3Application : Application() {
// Before any store loads: there is no home directory to find them in.
AppDirs.init(filesDir, filesDir.toPath().resolve("chats"))
sessions = SessionController(this)
Thread(sessions::installSoundPacks, "sound-packs").start()
}
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -91,6 +92,14 @@ fun ServerScreen(
var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) }
val chatRequest by vm.chatRequest.collectAsStateWithLifecycle()
LaunchedEffect(chatRequest) {
val key = chatRequest ?: return@LaunchedEffect
chatKey = if (chats[key] != null) key else vm.openPrivateChat(key.clientId)
tab = TAB_CHAT
vm.chatRequestHandled()
}
val viewing = if (tab == TAB_CHAT) conversation.key else null
LifecycleResumeEffect(viewing) {
vm.viewing = viewing

View File

@@ -52,6 +52,8 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
val outputs by vm.outputDevices.collectAsStateWithLifecycle()
val route by vm.route.collectAsStateWithLifecycle()
val level by vm.micLevel.collectAsStateWithLifecycle()
val packs by vm.soundPacks.collectAsStateWithLifecycle()
var pack by remember { mutableStateOf(vm.soundPack?.id()) }
LifecycleStartEffect(Unit) {
vm.watchLevel(true)
onStopOrDispose { vm.watchLevel(false) }
@@ -141,6 +143,19 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
change { outputVolume = it.toDouble() }
}
Section("Notification sounds")
if (packs.isEmpty()) {
Hint("This build of the app came without sound packs.")
} else {
Picker("Sound pack", packs.map { it.id() to it.name() }, pack) { id ->
packs.firstOrNull { it.id() == id }?.let(vm::setSoundPack)
pack = id
}
}
Level("Volume", settings.soundVolume.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) {
change { soundVolume = it.toDouble() }
}
}
}
}

View File

@@ -2,4 +2,6 @@
<resources>
<string name="app_name">TS3J</string>
<string name="connection_channel">Connection</string>
<string name="messages_channel">Private messages</string>
<string name="pokes_channel">Pokes</string>
</resources>