Add the Android app: Gradle build, Oboe audio, connection screen

android/ builds core and ts3j from the same sources as the desktop's
Maven build, so the app shares all of the client's logic:

- audio: Oboe streams behind AudioCapture/AudioPlayback, with a lock-free
  ring between the real-time callback and the blocking Java side, and
  libopus 1.5.2 built with the NDK behind OpusCodec. Speakers are mixed
  onto one stream. Capture uses the VoiceRecognition preset, leaving
  noise suppression and gain to the client's own processing.
- app: a plain connection screen to try the port out - connect,
  microphone, mute, deafen, channel chat and a log - until the real UI.

Targets Android 13 and up (minSdk 33) on arm64 and x86_64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:24:21 +00:00
parent e211ffa7b6
commit 086b913c8e
26 changed files with 1555 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".Ts3Application"
android:label="@string/app_name"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,119 @@
package com.ts3client.android
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
Surface(Modifier.fillMaxSize()) {
ConnectionScreen(viewModel())
}
}
}
}
}
/** A bare connection screen for trying the port out; the real UI replaces it. */
@Composable
private fun ConnectionScreen(vm: SessionViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
var address by remember { mutableStateOf(vm.lastAddress) }
var nickname by remember { mutableStateOf(vm.nickname) }
var message by remember { mutableStateOf("") }
val context = LocalContext.current
val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) vm.setMicrophoneActive(true)
}
Column(
Modifier.safeDrawingPadding().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("${state.title} — ${state.status}", style = MaterialTheme.typography.titleMedium)
if (state.connected) Text("${state.channels} channels, ${state.clients} clients")
OutlinedTextField(address, { address = it }, Modifier.fillMaxWidth(), label = { Text("Server") },
singleLine = true, enabled = !state.busy)
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth(), label = { Text("Nickname") },
singleLine = true, enabled = !state.busy)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.busy) {
Button(onClick = vm::disconnect) { Text("Disconnect") }
} else {
Button(onClick = { vm.connect(address, nickname) }, enabled = address.isNotBlank()) { Text("Connect") }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(state.micActive, onClick = {
if (state.micActive) {
vm.setMicrophoneActive(false)
} else if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) {
vm.setMicrophoneActive(true)
} else {
micPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}, label = { Text("Microphone") })
FilterChip(state.micMuted, onClick = { vm.setMicMuted(!state.micMuted) }, label = { Text("Mute") })
FilterChip(state.deafened, onClick = { vm.setDeafened(!state.deafened) }, label = { Text("Deafen") })
}
val listState = rememberLazyListState()
LaunchedEffect(state.log.size) {
if (state.log.isNotEmpty()) listState.animateScrollToItem(state.log.size - 1)
}
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState) {
items(state.log) { Text(it, style = MaterialTheme.typography.bodySmall) }
}
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
OutlinedTextField(message, { message = it }, Modifier.weight(1f), label = { Text("Channel chat") },
singleLine = true, enabled = state.connected)
Button(onClick = { vm.sendChannelMessage(message); message = "" },
enabled = state.connected && message.isNotBlank(), modifier = Modifier.padding(start = 8.dp)) {
Text("Send")
}
}
}
}

View File

@@ -0,0 +1,123 @@
package com.ts3client.android
import android.app.Application
import android.text.Html
import androidx.lifecycle.AndroidViewModel
import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings
import com.ts3client.contacts.ContactStore
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/** What the connection screen shows. */
data class SessionState(
val title: String = "",
val status: String = "Not connected",
val connected: Boolean = false,
val busy: Boolean = false,
val micActive: Boolean = false,
val micMuted: Boolean = false,
val deafened: Boolean = false,
val channels: Int = 0,
val clients: Int = 0,
val log: List<String> = emptyList(),
)
/**
* Holds one [ServerSession] and mirrors what it reports into [state]. Session callbacks
* arrive on connection threads; [MutableStateFlow] takes updates from any of them.
*/
class SessionViewModel(app: Application) : AndroidViewModel(app) {
private val settings = Settings.load()
private val audio = AndroidAudioBackend(app)
private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) }
private val session = ServerSession(
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
)
private val _state = MutableStateFlow(SessionState(title = session.title()))
val state: StateFlow<SessionState> = _state
val nickname: String get() = settings.nickname
val lastAddress: String get() = settings.lastAddress
fun connect(address: String, nickname: String) {
val host = address.substringBefore(':').trim()
val port = address.substringAfter(':', "9987").trim().toIntOrNull() ?: 9987
settings.lastAddress = address
settings.nickname = nickname
settings.save()
session.connect(host, port, nickname, "", "", "", "")
}
fun disconnect() = session.disconnect()
/** Hands the microphone to the connection; needs RECORD_AUDIO granted. */
fun setMicrophoneActive(active: Boolean) {
session.setMicrophoneActive(active)
refresh()
}
fun setMicMuted(muted: Boolean) = session.setMicMuted(muted)
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
fun sendChannelMessage(text: String) = session.sendChat(ChatScope.CHANNEL, 0, text)
override fun onCleared() {
Thread { session.shutdown() }.start()
}
private fun refresh() {
val model = session.connection().model
_state.update {
it.copy(
title = session.title(),
status = session.status(),
connected = session.isConnected(),
busy = session.isBusy(),
micActive = session.connection().isMicrophoneActive,
micMuted = session.isMicMuted(),
deafened = session.isDeafened(),
channels = model.buildTree().sumOf { countChannels(it) },
clients = model.allClients().size,
)
}
}
private fun countChannels(node: com.ts3client.net.ChannelNode): Int =
1 + node.children.sumOf { countChannels(it) }
private fun log(line: String) {
_state.update { it.copy(log = (it.log + line).takeLast(500)) }
}
private inner class Listener : ServerSession.Listener {
override fun onSessionChanged() = refresh()
override fun onSystemMessage(text: String) = log(text)
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) =
log("<you> $text")
override fun onStatus(status: String) {}
override fun onConnected() = refresh()
override fun onDisconnected(reason: String?) = refresh()
override fun onModelChanged() = refresh()
override fun onInfoUpdated() {}
override fun onIconsUpdated() {}
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) =
log("<$fromName> $message")
override fun onTalkStateChanged(clientId: Int, talking: Boolean) {}
override fun onError(message: String) {}
override fun onPoke(fromName: String, message: String) {}
override fun onServerLog(message: String) = log(plain(message))
}
private fun plain(html: String) = Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT).toString().trim()
}

View File

@@ -0,0 +1,12 @@
package com.ts3client.android
import android.app.Application
import com.ts3client.config.AppDirs
class Ts3Application : Application() {
override fun onCreate() {
super.onCreate()
// Before any store loads: there is no home directory to find them in.
AppDirs.init(filesDir, filesDir.toPath().resolve("chats"))
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TS3J</string>
</resources>