diff --git a/.gitignore b/.gitignore index e29f3b4..47b0081 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ out/ # Packages *.jar +!android/gradle/wrapper/gradle-wrapper.jar *.war *.zip *.tar.gz @@ -35,3 +36,9 @@ release.properties # OS .DS_Store + +# Android / Gradle +android/.gradle/ +android/.kotlin/ +android/**/.cxx/ +android/local.properties diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..be76544 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..fb64767 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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 ; +} + +# ts3j builds protocol handlers and packet bodies reflectively. +-keep class com.github.manevolent.ts3j.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7f32ee --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/ts3client/android/MainActivity.kt b/android/app/src/main/java/com/ts3client/android/MainActivity.kt new file mode 100644 index 0000000..d6b5200 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/MainActivity.kt @@ -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") + } + } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionViewModel.kt b/android/app/src/main/java/com/ts3client/android/SessionViewModel.kt new file mode 100644 index 0000000..3e8c108 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/SessionViewModel.kt @@ -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 = 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 = _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(" $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() +} diff --git a/android/app/src/main/java/com/ts3client/android/Ts3Application.kt b/android/app/src/main/java/com/ts3client/android/Ts3Application.kt new file mode 100644 index 0000000..e42045d --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/Ts3Application.kt @@ -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")) + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ea80664 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TS3J + diff --git a/android/audio/build.gradle.kts b/android/audio/build.gradle.kts new file mode 100644 index 0000000..a4b717d --- /dev/null +++ b/android/audio/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.ts3client.android.audio" + compileSdk = 37 + ndkVersion = "30.0.16248370" + + defaultConfig { + minSdk = 33 + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + externalNativeBuild { + cmake { + arguments += "-DANDROID_STL=c++_shared" + } + } + } + + buildFeatures { + prefab = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "4.1.2" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + api(project(":core")) + implementation(libs.oboe) +} diff --git a/android/audio/src/main/cpp/CMakeLists.txt b/android/audio/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..5cb97cd --- /dev/null +++ b/android/audio/src/main/cpp/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.22) +project(ts3audio LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) + +find_package(oboe REQUIRED CONFIG) + +# libopus from its release tarball, built static into our one library. +include(FetchContent) +FetchContent_Declare(opus + URL https://github.com/xiph/opus/releases/download/v1.5.2/opus-1.5.2.tar.gz + URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1) +set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE) +set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(OPUS_BUILD_SHARED_LIBRARY OFF CACHE BOOL "" FORCE) +set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF CACHE BOOL "" FORCE) +set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(opus) + +add_library(ts3audio SHARED audio_line.cpp opus_jni.cpp) +target_link_libraries(ts3audio PRIVATE oboe::oboe opus log) +target_compile_options(ts3audio PRIVATE -Wall -Wextra) diff --git a/android/audio/src/main/cpp/audio_line.cpp b/android/audio/src/main/cpp/audio_line.cpp new file mode 100644 index 0000000..a9fb8fa --- /dev/null +++ b/android/audio/src/main/cpp/audio_line.cpp @@ -0,0 +1,241 @@ +// An Oboe stream behind the blocking read/write interface of AudioCapture and AudioPlayback. +// +// The real-time callback never waits and never calls into Java: it only moves samples +// between the device and a lock-free ring, which the Java side fills or drains at its own +// pace, blocking while the ring is full (playback) or short (capture). + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int32_t kSampleRate = 48000; +constexpr int32_t kFrameSize = 960; // 20 ms +// Capture keeps some slack for a Java thread that is briefly late; playback keeps little, +// since everything queued is latency. +constexpr int32_t kCaptureFrames = 8; +constexpr int32_t kPlaybackFrames = 3; +constexpr auto kPollInterval = std::chrono::milliseconds(1); + +const char *kTag = "ts3audio"; + +/** Single-producer, single-consumer ring of 16-bit samples. */ +class SampleRing { +public: + explicit SampleRing(size_t capacity) : buffer_(capacity) {} + + size_t available() const { + return head_.load(std::memory_order_acquire) - tail_.load(std::memory_order_acquire); + } + + size_t space() const { return buffer_.size() - available(); } + + /** Producer side: stores as much of {@code data} as fits. */ + size_t write(const int16_t *data, size_t count) { + size_t head = head_.load(std::memory_order_relaxed); + size_t tail = tail_.load(std::memory_order_acquire); + size_t n = std::min(count, buffer_.size() - (head - tail)); + for (size_t i = 0; i < n; i++) buffer_[(head + i) % buffer_.size()] = data[i]; + head_.store(head + n, std::memory_order_release); + return n; + } + + /** Consumer side: takes up to {@code count} samples. */ + size_t read(int16_t *data, size_t count) { + size_t tail = tail_.load(std::memory_order_relaxed); + size_t head = head_.load(std::memory_order_acquire); + size_t n = std::min(count, head - tail); + for (size_t i = 0; i < n; i++) data[i] = buffer_[(tail + i) % buffer_.size()]; + tail_.store(tail + n, std::memory_order_release); + return n; + } + +private: + std::vector buffer_; + std::atomic head_{0}; + std::atomic tail_{0}; +}; + +class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback { +public: + AudioLine(bool input, int32_t deviceId, int32_t channels) + : input_(input), deviceId_(deviceId), channels_(channels), + ring_(static_cast(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {} + + int32_t channels() const { return channels_; } + + oboe::Result open() { + oboe::AudioStreamBuilder builder; + builder.setDirection(input_ ? oboe::Direction::Input : oboe::Direction::Output) + ->setSharingMode(oboe::SharingMode::Shared) + ->setPerformanceMode(oboe::PerformanceMode::LowLatency) + ->setFormat(oboe::AudioFormat::I16) + ->setFormatConversionAllowed(true) + ->setChannelCount(channels_) + ->setChannelConversionAllowed(true) + ->setSampleRate(kSampleRate) + ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium) + ->setDataCallback(this) + ->setErrorCallback(this); + if (deviceId_ > 0) builder.setDeviceId(deviceId_); + if (input_) { + // The platform's own voice processing stays off: the client runs TS3's. + builder.setInputPreset(oboe::InputPreset::VoiceRecognition); + } else { + builder.setUsage(oboe::Usage::Media)->setContentType(oboe::ContentType::Speech); + } + std::shared_ptr stream; + oboe::Result result = builder.openStream(stream); + if (result != oboe::Result::OK) return result; + std::lock_guard lock(mutex_); + if (closing_.load()) { // closed while a reopen was under way + stream->close(); + return oboe::Result::ErrorClosed; + } + stream_ = stream; + return result; + } + + oboe::Result start() { + std::lock_guard lock(mutex_); + return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed; + } + + /** Blocks until {@code count} samples are captured; fewer only once closing. */ + size_t read(int16_t *data, size_t count) { + size_t done = 0; + while (done < count && !closing_.load()) { + done += ring_.read(data + done, count - done); + if (done < count) std::this_thread::sleep_for(kPollInterval); + } + return done; + } + + /** Blocks until all samples are queued for playback, or the line closes. */ + void write(const int16_t *data, size_t count) { + size_t done = 0; + while (done < count && !closing_.load()) { + done += ring_.write(data + done, count - done); + if (done < count) std::this_thread::sleep_for(kPollInterval); + } + } + + void close() { + closing_.store(true); + std::lock_guard lock(mutex_); + if (stream_) { + stream_->stop(); + stream_->close(); + stream_.reset(); + } + } + + oboe::DataCallbackResult onAudioReady(oboe::AudioStream *, void *audioData, int32_t numFrames) override { + auto *samples = static_cast(audioData); + size_t count = static_cast(numFrames) * channels_; + if (input_) { + ring_.write(samples, count); // a reader that fell behind loses the newest audio + } else { + size_t played = ring_.read(samples, count); + if (played < count) std::memset(samples + played, 0, (count - played) * sizeof(int16_t)); + } + return oboe::DataCallbackResult::Continue; + } + + /** The device went away (headset unplugged, route changed): follow onto the new one. */ + void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override { + if (closing_.load()) return; + __android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening", + oboe::convertToText(error)); + deviceId_ = 0; + if (open() == oboe::Result::OK) start(); + } + +private: + const bool input_; + int32_t deviceId_; + const int32_t channels_; + SampleRing ring_; + std::atomic closing_{false}; + std::mutex mutex_; + std::shared_ptr stream_; +}; + +AudioLine *line(jlong handle) { + return reinterpret_cast(handle); +} + +thread_local std::vector scratch; + +} // namespace + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId, + jint channels) { + auto *created = new AudioLine(input, deviceId, channels); + oboe::Result result = created->open(); + if (result != oboe::Result::OK) { + delete created; + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result)); + return 0; + } + return reinterpret_cast(created); +} + +JNIEXPORT jint JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeChannels(JNIEnv *, jclass, jlong handle) { + return line(handle)->channels(); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeStart(JNIEnv *env, jclass, jlong handle) { + oboe::Result result = line(handle)->start(); + if (result != oboe::Result::OK) { + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result)); + } +} + +JNIEXPORT jint JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer, + jint offset, jint length) { + size_t samples = static_cast(length) / sizeof(int16_t); + scratch.resize(samples); + size_t read = line(handle)->read(scratch.data(), samples); + env->SetByteArrayRegion(buffer, offset, static_cast(read * sizeof(int16_t)), + reinterpret_cast(scratch.data())); + return static_cast(read * sizeof(int16_t)); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeWrite(JNIEnv *env, jclass, jlong handle, jbyteArray buffer, + jint offset, jint length) { + size_t samples = static_cast(length) / sizeof(int16_t); + scratch.resize(samples); + env->GetByteArrayRegion(buffer, offset, static_cast(samples * sizeof(int16_t)), + reinterpret_cast(scratch.data())); + line(handle)->write(scratch.data(), samples); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeClose(JNIEnv *, jclass, jlong handle) { + line(handle)->close(); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_OboeLine_nativeDelete(JNIEnv *, jclass, jlong handle) { + delete line(handle); +} + +} // extern "C" diff --git a/android/audio/src/main/cpp/opus_jni.cpp b/android/audio/src/main/cpp/opus_jni.cpp new file mode 100644 index 0000000..3008540 --- /dev/null +++ b/android/audio/src/main/cpp/opus_jni.cpp @@ -0,0 +1,109 @@ +// libopus for NativeOpus: handles are the raw encoder/decoder pointers. + +#include +#include + +#include +#include + +namespace { + +constexpr int kMaxPacketBytes = 4096; + +void throwOpus(JNIEnv *env, const char *call, int error) { + std::string message = std::string(call) + " failed: " + opus_strerror(error); + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), message.c_str()); +} + +OpusEncoder *encoder(jlong handle) { return reinterpret_cast(handle); } + +OpusDecoder *decoder(jlong handle) { return reinterpret_cast(handle); } + +} // namespace + +extern "C" { + +JNIEXPORT jlong JNICALL +Java_com_ts3client_android_audio_NativeOpus_encoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels, + jint application) { + int error = OPUS_OK; + OpusEncoder *created = opus_encoder_create(sampleRate, channels, application, &error); + if (error != OPUS_OK) { + throwOpus(env, "opus_encoder_create", error); + return 0; + } + return reinterpret_cast(created); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_NativeOpus_encoderCtl(JNIEnv *env, jclass, jlong handle, jint request, + jint value) { + int result = opus_encoder_ctl(encoder(handle), request, value); + if (result < 0) throwOpus(env, "opus_encoder_ctl", result); +} + +JNIEXPORT jbyteArray JNICALL +Java_com_ts3client_android_audio_NativeOpus_encode(JNIEnv *env, jclass, jlong handle, jfloatArray pcm, + jint frameSize) { + unsigned char packet[kMaxPacketBytes]; + jfloat *samples = env->GetFloatArrayElements(pcm, nullptr); + int length = opus_encode_float(encoder(handle), samples, frameSize, packet, kMaxPacketBytes); + env->ReleaseFloatArrayElements(pcm, samples, JNI_ABORT); + if (length < 0) { + throwOpus(env, "opus_encode_float", length); + return nullptr; + } + jbyteArray out = env->NewByteArray(length); + env->SetByteArrayRegion(out, 0, length, reinterpret_cast(packet)); + return out; +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_NativeOpus_encoderDestroy(JNIEnv *, jclass, jlong handle) { + opus_encoder_destroy(encoder(handle)); +} + +JNIEXPORT jlong JNICALL +Java_com_ts3client_android_audio_NativeOpus_decoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels) { + int error = OPUS_OK; + OpusDecoder *created = opus_decoder_create(sampleRate, channels, &error); + if (error != OPUS_OK) { + throwOpus(env, "opus_decoder_create", error); + return 0; + } + return reinterpret_cast(created); +} + +/** Decodes {@code packet}, or conceals {@code frameSize} samples when it is null. */ +JNIEXPORT jint JNICALL +Java_com_ts3client_android_audio_NativeOpus_decode(JNIEnv *env, jclass, jlong handle, jbyteArray packet, + jfloatArray out, jint frameSize) { + std::vector data; + if (packet != nullptr) { + data.resize(static_cast(env->GetArrayLength(packet))); + env->GetByteArrayRegion(packet, 0, static_cast(data.size()), reinterpret_cast(data.data())); + } + jfloat *samples = env->GetFloatArrayElements(out, nullptr); + int decoded = opus_decode_float(decoder(handle), packet != nullptr ? data.data() : nullptr, + static_cast(data.size()), samples, frameSize, 0); + env->ReleaseFloatArrayElements(out, samples, decoded < 0 ? JNI_ABORT : 0); + if (decoded < 0) throwOpus(env, "opus_decode_float", decoded); + return decoded; +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_NativeOpus_decoderReset(JNIEnv *, jclass, jlong handle) { + opus_decoder_ctl(decoder(handle), OPUS_RESET_STATE); +} + +JNIEXPORT void JNICALL +Java_com_ts3client_android_audio_NativeOpus_decoderDestroy(JNIEnv *, jclass, jlong handle) { + opus_decoder_destroy(decoder(handle)); +} + +JNIEXPORT jstring JNICALL +Java_com_ts3client_android_audio_NativeOpus_version0(JNIEnv *env, jclass) { + return env->NewStringUTF(opus_get_version_string()); +} + +} // extern "C" diff --git a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java new file mode 100644 index 0000000..654ff9b --- /dev/null +++ b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java @@ -0,0 +1,40 @@ +package com.ts3client.android.audio; + +import android.content.Context; + +import com.ts3client.audio.AudioBackend; +import com.ts3client.audio.AudioIo; +import com.ts3client.audio.StreamingVoiceOutput; +import com.ts3client.audio.opus.OpusCodec; + +/** Android's audio stack: Oboe for the devices, libopus built with the NDK. */ +public final class AndroidAudioBackend implements AudioBackend { + + private final AudioIo io; + private final OpusCodec opus = new NativeOpus(); + + public AndroidAudioBackend(Context context) { + this.io = new AndroidAudioIo(context.getApplicationContext()); + } + + @Override + public AudioIo io() { + return io; + } + + @Override + public OpusCodec opus() { + return opus; + } + + /** One low-latency stream for everyone, as mobile audio wants. */ + @Override + public StreamingVoiceOutput.Lines outputLines() { + return StreamingVoiceOutput.Lines.SHARED; + } + + @Override + public String description() { + return "Opus " + opus.version() + ", Oboe"; + } +} diff --git a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java new file mode 100644 index 0000000..63acc5c --- /dev/null +++ b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java @@ -0,0 +1,86 @@ +package com.ts3client.android.audio; + +import android.content.Context; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; + +import com.ts3client.audio.AudioCapture; +import com.ts3client.audio.AudioDevice; +import com.ts3client.audio.AudioIo; +import com.ts3client.audio.AudioPlayback; + +import java.util.ArrayList; +import java.util.List; + +/** The device's microphones and speakers, opened through Oboe. Device ids are Android's. */ +final class AndroidAudioIo implements AudioIo { + + private final AudioManager audioManager; + + AndroidAudioIo(Context context) { + this.audioManager = context.getSystemService(AudioManager.class); + } + + @Override + public List inputDevices() { + return devices(AudioManager.GET_DEVICES_INPUTS); + } + + @Override + public List outputDevices() { + return devices(AudioManager.GET_DEVICES_OUTPUTS); + } + + private List devices(int flags) { + List out = new ArrayList<>(); + out.add(AudioDevice.DEFAULT); + for (AudioDeviceInfo info : audioManager.getDevices(flags)) { + out.add(new AudioDevice(Integer.toString(info.getId()), info.getProductName() + " (" + typeName(info) + ")")); + } + return out; + } + + @Override + public AudioCapture openCapture(String deviceId, int preferredChannels) { + return OboeLine.capture(parseId(deviceId), preferredChannels); + } + + @Override + public AudioPlayback openPlayback(String deviceId, int preferredChannels) { + return OboeLine.playback(parseId(deviceId), preferredChannels); + } + + /** 0 lets the system choose, as it does for the default device. */ + private static int parseId(String deviceId) { + try { + return deviceId == null || deviceId.isEmpty() ? 0 : Integer.parseInt(deviceId); + } catch (NumberFormatException e) { + return 0; + } + } + + private static String typeName(AudioDeviceInfo info) { + switch (info.getType()) { + case AudioDeviceInfo.TYPE_BUILTIN_MIC: + return "built-in microphone"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + return "speaker"; + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + return "earpiece"; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + return "wired headset"; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return "wired headphones"; + case AudioDeviceInfo.TYPE_USB_HEADSET: + case AudioDeviceInfo.TYPE_USB_DEVICE: + return "USB"; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + case AudioDeviceInfo.TYPE_BLE_HEADSET: + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + return "Bluetooth"; + default: + return "type " + info.getType(); + } + } +} diff --git a/android/audio/src/main/java/com/ts3client/android/audio/NativeOpus.java b/android/audio/src/main/java/com/ts3client/android/audio/NativeOpus.java new file mode 100644 index 0000000..0673198 --- /dev/null +++ b/android/audio/src/main/java/com/ts3client/android/audio/NativeOpus.java @@ -0,0 +1,114 @@ +package com.ts3client.android.audio; + +import com.ts3client.audio.opus.CtlOpusEncoder; +import com.ts3client.audio.opus.LibOpus; +import com.ts3client.audio.opus.OpusCodec; +import com.ts3client.audio.opus.OpusDecoder; +import com.ts3client.audio.opus.OpusEncoder; + +/** libopus, built into the app with the NDK. */ +public final class NativeOpus implements OpusCodec { + + static { + System.loadLibrary("ts3audio"); + } + + @Override + public OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application) { + return new Encoder(encoderCreate(sampleRate, channels, LibOpus.application(application)), frameSize); + } + + @Override + public OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels) { + return new Decoder(decoderCreate(sampleRate, channels), maxFrameSize); + } + + @Override + public String version() { + return version0(); + } + + /** Methods may come from different threads, one at a time, so they only share the handle. */ + private static final class Encoder extends CtlOpusEncoder { + private final int frameSize; + private long handle; + + Encoder(long handle, int frameSize) { + this.handle = handle; + this.frameSize = frameSize; + } + + @Override + protected synchronized void ctl(int request, int value) { + if (handle != 0) encoderCtl(handle, request, value); + } + + @Override + public synchronized byte[] encode(float[] pcm) { + if (handle == 0) throw new IllegalStateException("encoder closed"); + return NativeOpus.encode(handle, pcm, frameSize); + } + + @Override + public synchronized void close() { + if (handle == 0) return; + encoderDestroy(handle); + handle = 0; + } + } + + private static final class Decoder implements OpusDecoder { + private final int maxFrameSize; + private long handle; + + Decoder(long handle, int maxFrameSize) { + this.handle = handle; + this.maxFrameSize = maxFrameSize; + } + + @Override + public int decode(byte[] packet, float[] out) { + return NativeOpus.decode(live(), packet, out, maxFrameSize); + } + + @Override + public int conceal(float[] out, int frameSize) { + return NativeOpus.decode(live(), null, out, Math.min(frameSize, maxFrameSize)); + } + + @Override + public void reset() { + if (handle != 0) decoderReset(handle); + } + + @Override + public void close() { + if (handle == 0) return; + decoderDestroy(handle); + handle = 0; + } + + private long live() { + if (handle == 0) throw new IllegalStateException("decoder closed"); + return handle; + } + } + + private static native long encoderCreate(int sampleRate, int channels, int application); + + private static native void encoderCtl(long handle, int request, int value); + + private static native byte[] encode(long handle, float[] pcm, int frameSize); + + private static native void encoderDestroy(long handle); + + private static native long decoderCreate(int sampleRate, int channels); + + private static native int decode(long handle, byte[] packet, float[] out, int frameSize); + + private static native void decoderReset(long handle); + + private static native void decoderDestroy(long handle); + + private static native String version0(); +} diff --git a/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java b/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java new file mode 100644 index 0000000..0e28338 --- /dev/null +++ b/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java @@ -0,0 +1,91 @@ +package com.ts3client.android.audio; + +import com.ts3client.audio.AudioCapture; +import com.ts3client.audio.AudioPlayback; + +/** + * An Oboe stream, as either an {@link AudioCapture} or an {@link AudioPlayback}. The native + * side keeps a ring between the real-time callback and these blocking calls. + */ +final class OboeLine implements AudioCapture, AudioPlayback { + + static { + System.loadLibrary("ts3audio"); + } + + /** Held across native reads and writes, so {@link #close} never frees a line in use. */ + private final Object io = new Object(); + private final int channels; + private volatile long handle; + + private OboeLine(boolean input, int deviceId, int channels) { + this.handle = nativeOpen(input, deviceId, channels); + this.channels = nativeChannels(handle); + } + + static OboeLine capture(int deviceId, int channels) { + return new OboeLine(true, deviceId, channels); + } + + static OboeLine playback(int deviceId, int channels) { + return new OboeLine(false, deviceId, channels); + } + + @Override + public int channels() { + return channels; + } + + @Override + public void start() { + long h = handle; + if (h != 0) nativeStart(h); + } + + @Override + public int read(byte[] buffer, int offset, int length) { + synchronized (io) { + long h = handle; + return h == 0 ? 0 : nativeRead(h, buffer, offset, length); + } + } + + @Override + public void write(byte[] buffer, int offset, int length) { + synchronized (io) { + long h = handle; + if (h != 0) nativeWrite(h, buffer, offset, length); + } + } + + /** Oboe has no drain; what is queued is at most a few frames and simply plays out. */ + @Override + public void drain() { + } + + @Override + public void close() { + long h = handle; + if (h == 0) return; + nativeClose(h); // wakes a blocked read or write + synchronized (io) { + if (handle == 0) return; + handle = 0; + nativeDelete(h); + } + } + + private static native long nativeOpen(boolean input, int deviceId, int channels); + + private static native int nativeChannels(long handle); + + private static native void nativeStart(long handle); + + private static native int nativeRead(long handle, byte[] buffer, int offset, int length); + + private static native void nativeWrite(long handle, byte[] buffer, int offset, int length); + + private static native void nativeClose(long handle); + + private static native void nativeDelete(long handle); +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..6062672 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts new file mode 100644 index 0000000..0931388 --- /dev/null +++ b/android/core/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` +} + +java { + sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/java"))) + sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/resources"))) +} + +tasks.withType().configureEach { + options.release = 17 + options.encoding = "UTF-8" +} + +dependencies { + api(project(":ts3j")) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..bfbcbd2 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.configuration-cache=true +android.useAndroidX=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..18a2cdc --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +[versions] +agp = "9.4.1" +kotlin = "2.4.20" +composeBom = "2026.09.00" +activityCompose = "1.13.0" +lifecycle = "2.11.0" +bouncycastle = "1.86" +ini4j = "0.5.1" +oboe = "1.11.0" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" } +oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..5097068 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9b7cb4a --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..03754c4 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..5a91cdc --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ts3-android" + +// core and ts3j compile the very sources the desktop's Maven build does. +include(":ts3j", ":core", ":audio", ":app") diff --git a/android/ts3j/build.gradle.kts b/android/ts3j/build.gradle.kts new file mode 100644 index 0000000..9bbe35f --- /dev/null +++ b/android/ts3j/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `java-library` +} + +java { + sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3j/src/main/java"))) +} + +tasks.withType().configureEach { + options.release = 17 + options.encoding = "UTF-8" +} + +dependencies { + // Android ships a BouncyCastle of its own, but under a different package, so they never meet. + api(libs.bouncycastle) + api(libs.ini4j) +}