diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0c54292..ba6d99d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") { + 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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4b53d60..866122a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,13 @@ + + + + + + + @@ -30,5 +38,9 @@ android:name=".ConnectionService" android:exported="false" android:foregroundServiceType="mediaPlayback|microphone" /> + + 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 32980bb..eea4680 100644 --- a/android/app/src/main/java/com/ts3client/android/MainActivity.kt +++ b/android/app/src/main/java/com/ts3client/android/MainActivity.kt @@ -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) + } } diff --git a/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt b/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt new file mode 100644 index 0000000..c767389 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/MessageNotifier.kt @@ -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() + + private class Conversation(val peer: Person, val messages: MutableList) + + 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 + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt b/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt new file mode 100644 index 0000000..0fdd5cf --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ReplyReceiver.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 078c4cf..f039caa 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -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(null) + val chatRequest: StateFlow = _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> = _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) { 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)) diff --git a/android/app/src/main/java/com/ts3client/android/SoundPackInstaller.kt b/android/app/src/main/java/com/ts3client/android/SoundPackInstaller.kt new file mode 100644 index 0000000..1895665 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/SoundPackInstaller.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SpeakingSoundPlayer.kt b/android/app/src/main/java/com/ts3client/android/SpeakingSoundPlayer.kt new file mode 100644 index 0000000..734358c --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/SpeakingSoundPlayer.kt @@ -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() + } +} 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 8f8081a..a3f2ab3 100644 --- a/android/app/src/main/java/com/ts3client/android/Ts3Application.kt +++ b/android/app/src/main/java/com/ts3client/android/Ts3Application.kt @@ -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() } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index a4a12d7..1a314c5 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -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(null) } var channelSheet by remember { mutableStateOf(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 diff --git a/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt index 6179080..cc462ea 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SoundSettingsScreen.kt @@ -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() } + } } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 90dec7c..aaf8773 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ TS3J Connection + Private messages + Pokes diff --git a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java index 0c44c34..132de96 100644 --- a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java +++ b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java @@ -74,6 +74,11 @@ public final class BBCode { 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 * outside those tags stays literal. The server log is written this way, so that a diff --git a/ts3-client/core/src/test/java/com/ts3client/text/BBCodePlainTest.java b/ts3-client/core/src/test/java/com/ts3client/text/BBCodePlainTest.java new file mode 100644 index 0000000..9df318a --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/text/BBCodePlainTest.java @@ -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 & and a link", + BBCode.toPlain("Hello [b]bold[/b] & and [url=https://x.y]a link[/url]")); + } +}