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