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) 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 { android {
namespace = "com.ts3client.android" namespace = "com.ts3client.android"
compileSdk = 37 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_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <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 <application
android:name=".Ts3Application" android:name=".Ts3Application"
android:label="@string/app_name" android:label="@string/app_name"
@@ -19,6 +26,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -30,5 +38,9 @@
android:name=".ConnectionService" android:name=".ConnectionService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" /> android:foregroundServiceType="mediaPlayback|microphone" />
<receiver
android:name=".ReplyReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,5 +1,6 @@
package com.ts3client.android package com.ts3client.android
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -8,12 +9,36 @@ import com.ts3client.android.ui.App
import com.ts3client.android.ui.Ts3Theme import com.ts3client.android.ui.Ts3Theme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val sessions get() = (application as Ts3Application).sessions
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val sessions = (application as Ts3Application).sessions if (savedInstanceState == null) openRequestedChat(intent)
setContent { setContent {
Ts3Theme { App(sessions) } 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.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack
import com.ts3client.teamspeak.SyncItem import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -73,8 +75,9 @@ class SessionController(private val context: Context) {
private val bookmarkStore = Bookmarks.load() private val bookmarkStore = Bookmarks.load()
private val identities = IdentityStore.load(settings) private val identities = IdentityStore.load(settings)
private val audio = AndroidAudioBackend(context) 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 sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val notifier = MessageNotifier(context)
private val contactStore = ContactStore.load() private val contactStore = ContactStore.load()
private val session = ServerSession( private val session = ServerSession(
settings, identities, audio, sounds, contactStore, Listener() settings, identities, audio, sounds, contactStore, Listener()
@@ -111,8 +114,21 @@ class SessionController(private val context: Context) {
set(value) { set(value) {
field = value field = value
if (value != null) markRead(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() private val lineIds = AtomicLong()
/** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */ /** 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) } withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
.also { _bookmarks.value = bookmarkStore.all().toList() } .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 ---- // ---- the local client ----
fun hasMicPermission() = 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) } } 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) { private fun append(key: ChatKey, title: String, html: String) {
if (html.isEmpty()) return if (html.isEmpty()) return
val line = ChatLine(lineIds.incrementAndGet(), html) 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) { override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text) val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "${settings.nickname} (you)", text)
append(key(scope, peerClientId), peerName, html) append(key(scope, peerClientId), peerName, html)
if (scope == ChatScope.PRIVATE) notifier.sent(peerClientId, BBCode.toPlain(text))
} }
override fun onStatus(status: String) {} override fun onStatus(status: String) {}
@@ -453,6 +502,7 @@ class SessionController(private val context: Context) {
override fun onDisconnected(reason: String?) { override fun onDisconnected(reason: String?) {
network.onDisconnected() network.onDisconnected()
notifier.clearAll()
_chats.update { it.copy(private = emptyList()) } _chats.update { it.copy(private = emptyList()) }
scheduleTree(structure = true) scheduleTree(structure = true)
refresh() refresh()
@@ -462,8 +512,10 @@ class SessionController(private val context: Context) {
override fun onInfoUpdated() {} override fun onInfoUpdated() {}
override fun onIconsUpdated() {} 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)) 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>) { override fun onChatHistory(scope: ChatScope, htmlEntries: List<String>) {
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries) 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) { override fun onPoke(fromName: String, message: String) {
_events.tryEmit(SessionEvent.Poke(fromName, message)) _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)) 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. // Before any store loads: there is no home directory to find them in.
AppDirs.init(filesDir, filesDir.toPath().resolve("chats")) AppDirs.init(filesDir, filesDir.toPath().resolve("chats"))
sessions = SessionController(this) 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.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -91,6 +92,14 @@ fun ServerScreen(
var clientSheet by remember { mutableStateOf<Int?>(null) } var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet 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 val viewing = if (tab == TAB_CHAT) conversation.key else null
LifecycleResumeEffect(viewing) { LifecycleResumeEffect(viewing) {
vm.viewing = viewing vm.viewing = viewing

View File

@@ -52,6 +52,8 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
val outputs by vm.outputDevices.collectAsStateWithLifecycle() val outputs by vm.outputDevices.collectAsStateWithLifecycle()
val route by vm.route.collectAsStateWithLifecycle() val route by vm.route.collectAsStateWithLifecycle()
val level by vm.micLevel.collectAsStateWithLifecycle() val level by vm.micLevel.collectAsStateWithLifecycle()
val packs by vm.soundPacks.collectAsStateWithLifecycle()
var pack by remember { mutableStateOf(vm.soundPack?.id()) }
LifecycleStartEffect(Unit) { LifecycleStartEffect(Unit) {
vm.watchLevel(true) vm.watchLevel(true)
onStopOrDispose { vm.watchLevel(false) } onStopOrDispose { vm.watchLevel(false) }
@@ -141,6 +143,19 @@ fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) { Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
change { outputVolume = it.toDouble() } 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> <resources>
<string name="app_name">TS3J</string> <string name="app_name">TS3J</string>
<string name="connection_channel">Connection</string> <string name="connection_channel">Connection</string>
<string name="messages_channel">Private messages</string>
<string name="pokes_channel">Pokes</string>
</resources> </resources>

View File

@@ -74,6 +74,11 @@ public final class BBCode {
return out.toString(); return out.toString();
} }
/** The text BBCode reads as, markup dropped, for places that show no formatting. */
public static String toPlain(String input) {
return HtmlText.plain(toHtml(input));
}
/** /**
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything * Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
* outside those tags stays literal. The server log is written this way, so that a * outside those tags stays literal. The server log is written this way, so that a

View File

@@ -0,0 +1,15 @@
package com.ts3client.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Messages shown where no formatting renders, such as a system notification. */
class BBCodePlainTest {
@Test
void dropsMarkupAndKeepsText() {
assertEquals("Hello bold & <tags> and a link",
BBCode.toPlain("Hello [b]bold[/b] & <tags> and [url=https://x.y]a link[/url]"));
}
}