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:
55
android/app/build.gradle.kts
Normal file
55
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,55 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ts3client.android"
|
||||
compileSdk = 37
|
||||
ndkVersion = "30.0.16248370"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ts3client.android"
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// BouncyCastle ships Java 9+ module metadata Android has no use for.
|
||||
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":audio"))
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
}
|
||||
10
android/app/proguard-rules.pro
vendored
Normal file
10
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# ts3j looks up SRV records through JNDI only where it exists; Android lacks it.
|
||||
-dontwarn javax.naming.**
|
||||
|
||||
# Reached from native code by name.
|
||||
-keepclasseswithmembernames class com.ts3client.android.audio.** {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# ts3j builds protocol handlers and packet bodies reflectively.
|
||||
-keep class com.github.manevolent.ts3j.** { *; }
|
||||
22
android/app/src/main/AndroidManifest.xml
Normal file
22
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
119
android/app/src/main/java/com/ts3client/android/MainActivity.kt
Normal file
119
android/app/src/main/java/com/ts3client/android/MainActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TS3J</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user