diff --git a/README.md b/README.md
index 3bc72e5..7008fe3 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ the app's **About** page.
| Embedded X11 | Use the bundled Termux:X11 server and uDroid display surface without installing the separate Termux:X11 application. Keyboard, direct-pointer, trackpad-style, native multi-touch, and display settings are available inside uDroid. |
| Desktop environments | Detect installed X11 sessions from standard `xsessions` directories. Start, stop, or restart the selected desktop while uDroid reports which rootfs and desktop own `DISPLAY :0`. |
| Compositing | Enable or disable compositing for supported XFCE, Plasma, and MATE sessions, then apply the setting through a supervised desktop restart. GNOME is treated as compositor-required. |
+| Audio | Play Linux audio through Android with an app-owned PulseAudio/OpenSL ES bridge. Enable microphone input per Linux system with Android runtime consent; guest traffic stays on authenticated device-local loopback. |
### Current limitations
@@ -48,13 +49,15 @@ the app's **About** page.
- The embedded display is functional, but general-purpose GPU acceleration is
not yet a supported app contract. Desktop responsiveness depends on the
device, resolution, applications, and compositor choice.
-- Audio integration, background desktop recovery, multiple displays, and
- production-level desktop compatibility remain incomplete.
+- Background desktop recovery, multiple displays, and production-level desktop
+ compatibility remain incomplete. Audio currently targets PulseAudio clients;
+ direct ALSA-only applications may require a PulseAudio compatibility plugin.
See the [distribution catalogue](docs/DISTRIBUTION_CATALOGUE.md),
[OCI image architecture](docs/OCI_IMAGES.md),
[Linux application launcher](docs/LINUX_APPLICATION_LAUNCHER.md),
[X11 runtime architecture](docs/X11_RUNTIME_ARCHITECTURE.md),
+[audio runtime](docs/AUDIO_RUNTIME.md),
[performance notes](docs/PERFORMANCE.md), and
[app updates](docs/APP_UPDATES.md) for current behavior and technical details.
@@ -104,6 +107,7 @@ export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358"
./app/src/main/cpp/build-runtime-probe.sh
./tools/build-proot-assets.sh
./tools/build-gnu-tar-assets.sh
+./tools/build-pulseaudio-assets.sh
./gradlew :app:assembleRelease
```
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index e5662db..6775cab 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -75,6 +75,23 @@ retain their existing MIT grants and may also be distributed under GPLv3 as
part of the combined application. Complete corresponding source includes this
repository and all recursively pinned Termux:X11 submodules.
+## PulseAudio and Termux runtime libraries
+
+- PulseAudio project:
+- PulseAudio version: `17.0`, Termux package revision `3`
+- Binary package repository:
+- Package versions and SHA-256 values: `tools/pulseaudio-packages.tsv`
+- Repackaging script: `tools/build-pulseaudio-assets.sh`
+- PulseAudio license: GNU Lesser General Public License version 2.1 or later
+
+The APK contains a compact relocation of the official Termux PulseAudio
+runtime. It includes only the daemon, native TCP protocol, OpenSL ES
+sink/source modules, and their required shared libraries. uDroid does not
+expose PulseAudio beyond authenticated device-local loopback. The pinned
+manifest records every input package, version, repository path, and archive
+checksum used for all supported ABIs.
+The individual dependency packages retain their upstream licenses.
+
## PRoot-Distro rootfs recipes
- Project:
diff --git a/app/src/androidTest/java/org/randomcoder/udroid/audio/AudioRuntimeProbeTest.kt b/app/src/androidTest/java/org/randomcoder/udroid/audio/AudioRuntimeProbeTest.kt
new file mode 100644
index 0000000..1bc1253
--- /dev/null
+++ b/app/src/androidTest/java/org/randomcoder/udroid/audio/AudioRuntimeProbeTest.kt
@@ -0,0 +1,48 @@
+package org.randomcoder.udroid.audio
+
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.randomcoder.udroid.runtime.EventJournal
+import java.net.InetAddress
+import java.net.Socket
+import java.util.concurrent.Executors
+
+@RunWith(AndroidJUnit4::class)
+class AudioRuntimeProbeTest {
+ @Test
+ fun packagedRuntimeStartsOutputOnAuthenticatedLoopback() {
+ val context = ApplicationProvider.getApplicationContext()
+ val executor = Executors.newCachedThreadPool()
+ val controller = AudioServerController(context, EventJournal(context), executor)
+ try {
+ val started =
+ controller.apply(
+ AudioConfiguration(outputEnabled = true, microphoneEnabled = false),
+ bootId = "audio-device-probe",
+ )
+ assertTrue(started.running)
+ val cookie =
+ controller.endpoint().hostAuthDirectory.resolve(AudioEndpoint.COOKIE_NAME)
+ val deadline = System.nanoTime() + 5_000_000_000L
+ var acceptingConnections = false
+ while (!acceptingConnections && controller.current().running && System.nanoTime() < deadline) {
+ acceptingConnections =
+ runCatching {
+ Socket(InetAddress.getLoopbackAddress(), AudioEndpoint.PORT).use { }
+ true
+ }.getOrDefault(false)
+ Thread.sleep(20)
+ }
+ assertTrue("PulseAudio did not create its authentication cookie", cookie.isFile)
+ assertTrue("PulseAudio did not listen on device-local loopback", acceptingConnections)
+ assertTrue(controller.current().outputEnabled)
+ assertTrue(!controller.current().microphoneEnabled)
+ } finally {
+ controller.stop("audio-device-probe")
+ executor.shutdownNow()
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5ffee6b..d3f721e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,8 +6,11 @@
+
+
+
@@ -86,7 +89,7 @@
android:name=".runtime.RuntimeSupervisorService"
android:description="@string/supervisor_description"
android:exported="false"
- android:foregroundServiceType="specialUse"
+ android:foregroundServiceType="specialUse|mediaPlayback|microphone"
android:stopWithTask="false">
(null)
+ private var audioConfiguration by mutableStateOf(AudioConfiguration())
+ private var audioConfigurationMessage by mutableStateOf(null)
private var linuxApplicationsState by
mutableStateOf(LinuxApplicationsState.Loading)
private var linuxApplicationMessage by mutableStateOf(null)
@@ -119,6 +124,7 @@ class MainActivity : ComponentActivity() {
private var pendingDesktopStart: PendingDesktopStart? = null
private var pendingShortcutRootfsName: String? = null
private var pendingTerminalRootfsName: String? = null
+ private var pendingMicrophoneRootfsName: String? = null
private var linuxApplicationsLoadGeneration = 0L
private var desktopScanGeneration = 0L
private var ociCatalogueLoadGeneration = 0L
@@ -128,9 +134,33 @@ class MainActivity : ComponentActivity() {
private var runtimeService by mutableStateOf(null)
private var runtimeServiceBound = false
private val desktopConfigurationStore by lazy { DesktopConfigurationStore(this) }
+ private val audioConfigurationStore by lazy { AudioConfigurationStore(this) }
private val ociHubCatalogueRepository by lazy { OciHubCatalogRepository(this) }
private val ociHubTagRepository by lazy { OciHubTagRepository(this) }
+ private val microphonePermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ val rootfsName = pendingMicrophoneRootfsName
+ pendingMicrophoneRootfsName = null
+ if (rootfsName == null) return@registerForActivityResult
+ if (granted) {
+ val current = audioConfigurationStore.load(rootfsName)
+ saveAudioConfiguration(
+ rootfsName,
+ current.copy(microphoneEnabled = true),
+ )
+ } else {
+ if (selectedSystemRootfsName == rootfsName) {
+ audioConfiguration =
+ audioConfigurationStore
+ .load(rootfsName)
+ .copy(microphoneEnabled = false)
+ audioConfigurationMessage =
+ "Microphone access was not granted. Linux input remains off."
+ }
+ }
+ }
+
private val runtimeServiceConnection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -193,6 +223,8 @@ class MainActivity : ComponentActivity() {
desktopConfiguration = desktopConfiguration,
desktopScanLoading = desktopScanLoading,
desktopScanMessage = desktopScanMessage,
+ audioConfiguration = audioConfiguration,
+ audioConfigurationMessage = audioConfigurationMessage,
linuxApplicationsState = linuxApplicationsState,
linuxApplicationMessage = linuxApplicationMessage,
showInstallTerminal = showInstallTerminal,
@@ -226,6 +258,8 @@ class MainActivity : ComponentActivity() {
onSelectDesktopEnvironment = { selectDesktopEnvironment(it) },
onCompositingChanged = { updateCompositing(it) },
onTouchScaleChanged = { updateTouchScale(it) },
+ onAudioOutputChanged = { updateAudioOutput(it) },
+ onMicrophoneChanged = { updateMicrophone(it) },
onStartDesktop = { startSelectedDesktop() },
onStopDesktop = { runtimeService?.stopDesktop() },
onRestartDesktop = { restartSelectedDesktop() },
@@ -606,6 +640,7 @@ class MainActivity : ComponentActivity() {
if (installedRootfses.none { it.name == rootfsName }) return
selectedSystemRootfsName = rootfsName
rootfsMaintenanceMessage = null
+ loadAudioConfiguration(rootfsName)
selectDestination(UdroidDestination.SYSTEM)
}
@@ -679,6 +714,9 @@ class MainActivity : ComponentActivity() {
runCatching { desktopConfigurationStore.remove(rootfsName) }
.exceptionOrNull()
?.let { cleanupWarnings += it.message ?: "desktop settings" }
+ runCatching { audioConfigurationStore.remove(rootfsName) }
+ .exceptionOrNull()
+ ?.let { cleanupWarnings += it.message ?: "audio settings" }
runCatching {
LinuxApplicationShortcutPublisher(this@MainActivity)
.disableForRootfs(rootfsName)
@@ -850,6 +888,86 @@ class MainActivity : ComponentActivity() {
)
}
+ private fun loadAudioConfiguration(rootfsName: String) {
+ runCatching { audioConfigurationStore.load(rootfsName) }
+ .onSuccess {
+ audioConfiguration = it
+ audioConfigurationMessage = null
+ }.onFailure {
+ audioConfiguration = AudioConfiguration()
+ audioConfigurationMessage = it.message ?: "Could not load audio settings"
+ }
+ }
+
+ private fun updateAudioOutput(enabled: Boolean) {
+ val rootfsName = selectedSystemRootfsName ?: return
+ saveAudioConfiguration(
+ rootfsName,
+ audioConfiguration.copy(outputEnabled = enabled),
+ )
+ }
+
+ private fun updateMicrophone(enabled: Boolean) {
+ val rootfsName = selectedSystemRootfsName ?: return
+ if (!enabled) {
+ pendingMicrophoneRootfsName = null
+ saveAudioConfiguration(
+ rootfsName,
+ audioConfiguration.copy(microphoneEnabled = false),
+ )
+ return
+ }
+ if (
+ ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ saveAudioConfiguration(
+ rootfsName,
+ audioConfiguration.copy(microphoneEnabled = true),
+ )
+ return
+ }
+ pendingMicrophoneRootfsName = rootfsName
+ audioConfigurationMessage = "Allow microphone access to send input to Linux."
+ microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+
+ private fun saveAudioConfiguration(
+ rootfsName: String,
+ configuration: AudioConfiguration,
+ ) {
+ runCatching { audioConfigurationStore.save(rootfsName, configuration) }
+ .onSuccess { saved ->
+ val service = runtimeService
+ val runningThisRootfs =
+ service?.currentTerminalSession()?.isRunning == true &&
+ service.currentTerminalSession()?.mSessionName == rootfsName
+ if (selectedSystemRootfsName == rootfsName) {
+ audioConfiguration = saved
+ audioConfigurationMessage =
+ if (runningThisRootfs) {
+ "Applying audio settings to the running Linux system…"
+ } else {
+ "Audio settings will apply when this Linux system starts."
+ }
+ }
+ if (runningThisRootfs) {
+ service.applyAudioConfiguration(rootfsName, saved) { result ->
+ if (selectedSystemRootfsName != rootfsName) return@applyAudioConfiguration
+ audioConfigurationMessage =
+ result.fold(
+ onSuccess = { it.message },
+ onFailure = {
+ it.message ?: "Could not apply audio settings"
+ },
+ )
+ }
+ }
+ }.onFailure {
+ audioConfigurationMessage = it.message ?: "Could not save audio settings"
+ }
+ }
+
private fun saveDesktopConfiguration(
rootfsName: String,
configuration: DesktopConfiguration,
diff --git a/app/src/main/java/org/randomcoder/udroid/audio/AudioConfiguration.kt b/app/src/main/java/org/randomcoder/udroid/audio/AudioConfiguration.kt
new file mode 100644
index 0000000..ba0117e
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/audio/AudioConfiguration.kt
@@ -0,0 +1,62 @@
+package org.randomcoder.udroid.audio
+
+import android.content.Context
+
+data class AudioConfiguration(
+ val outputEnabled: Boolean = true,
+ val microphoneEnabled: Boolean = false,
+)
+
+class AudioConfigurationStore(context: Context) {
+ private val preferences =
+ context.applicationContext
+ .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+
+ fun load(rootfsName: String): AudioConfiguration =
+ AudioConfiguration(
+ outputEnabled =
+ preferences.getBoolean(key(rootfsName, KEY_OUTPUT), true),
+ microphoneEnabled =
+ preferences.getBoolean(key(rootfsName, KEY_MICROPHONE), false),
+ )
+
+ fun save(
+ rootfsName: String,
+ configuration: AudioConfiguration,
+ ): AudioConfiguration {
+ check(
+ preferences
+ .edit()
+ .putBoolean(key(rootfsName, KEY_OUTPUT), configuration.outputEnabled)
+ .putBoolean(
+ key(rootfsName, KEY_MICROPHONE),
+ configuration.microphoneEnabled,
+ ).commit(),
+ ) {
+ "Could not save audio settings for $rootfsName"
+ }
+ return configuration
+ }
+
+ fun remove(rootfsName: String) {
+ val prefix = "$rootfsName:"
+ val editor = preferences.edit()
+ preferences.all.keys
+ .filter { it.startsWith(prefix) }
+ .forEach(editor::remove)
+ check(editor.commit()) {
+ "Could not clear audio settings for $rootfsName"
+ }
+ }
+
+ private fun key(
+ rootfsName: String,
+ setting: String,
+ ): String = "$rootfsName:$setting"
+
+ private companion object {
+ const val PREFERENCES_NAME = "audio-configuration"
+ const val KEY_OUTPUT = "output"
+ const val KEY_MICROPHONE = "microphone"
+ }
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/audio/AudioRuntimeInstaller.kt b/app/src/main/java/org/randomcoder/udroid/audio/AudioRuntimeInstaller.kt
new file mode 100644
index 0000000..849591a
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/audio/AudioRuntimeInstaller.kt
@@ -0,0 +1,130 @@
+package org.randomcoder.udroid.audio
+
+import android.content.Context
+import android.os.Build
+import java.io.File
+import java.io.FileOutputStream
+import java.util.UUID
+import java.util.zip.ZipInputStream
+
+data class AudioRuntime(
+ val executable: File,
+ val libraryDirectory: File,
+ val moduleDirectory: File,
+)
+
+object AudioRuntimeInstaller {
+ private const val RUNTIME_VERSION = "17.0-3-2"
+ private const val VERSION_ENTRY = "VERSION"
+ private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64")
+ private val expectedEntries =
+ setOf(
+ "bin/pulseaudio",
+ "lib/libFLAC.so",
+ "lib/libandroid-execinfo.so",
+ "lib/libdbus-1.so",
+ "lib/libiconv.so",
+ "lib/libltdl.so",
+ "lib/libmp3lame.so",
+ "lib/libogg.so",
+ "lib/libopus.so",
+ "lib/libprotocol-native.so",
+ "lib/libpulse.so",
+ "lib/libpulsecommon-17.0.so",
+ "lib/libpulsecore-17.0.so",
+ "lib/libsndfile.so",
+ "lib/libsoxr.so",
+ "lib/libspeexdsp.so",
+ "lib/libvorbis.so",
+ "lib/libvorbisenc.so",
+ "modules/module-native-protocol-tcp.so",
+ "modules/module-sles-sink.so",
+ "modules/module-sles-source.so",
+ VERSION_ENTRY,
+ )
+
+ fun install(context: Context): AudioRuntime {
+ val abi = Build.SUPPORTED_ABIS.firstOrNull { it in supportedAbis }
+ checkNotNull(abi) {
+ "No packaged PulseAudio runtime supports ${Build.SUPPORTED_ABIS.joinToString()}"
+ }
+ val runtimeParent = File(context.filesDir, "runtime").apply { mkdirs() }
+ val destination = File(runtimeParent, "pulseaudio-$RUNTIME_VERSION-$abi")
+ if (!isComplete(destination)) {
+ val staging =
+ File(runtimeParent, ".pulseaudio-${UUID.randomUUID()}.staging").apply {
+ check(mkdirs()) { "Could not prepare PulseAudio runtime staging" }
+ }
+ try {
+ extract(context, "runtime/$abi/pulseaudio-runtime.zip", staging)
+ check(isComplete(staging)) { "Packaged PulseAudio runtime is incomplete" }
+ if (destination.exists()) {
+ check(destination.deleteRecursively()) {
+ "Could not replace the previous PulseAudio runtime"
+ }
+ }
+ check(staging.renameTo(destination)) {
+ "Could not atomically activate PulseAudio"
+ }
+ } finally {
+ if (staging.exists()) staging.deleteRecursively()
+ }
+ }
+ return AudioRuntime(
+ executable = File(destination, "bin/pulseaudio"),
+ libraryDirectory = File(destination, "lib"),
+ moduleDirectory = File(destination, "modules"),
+ )
+ }
+
+ private fun extract(
+ context: Context,
+ assetPath: String,
+ destination: File,
+ ) {
+ val extracted = linkedSetOf()
+ ZipInputStream(context.assets.open(assetPath).buffered()).use { input ->
+ while (true) {
+ val entry = input.nextEntry ?: break
+ val name = entry.name.removePrefix("./").removeSuffix("/")
+ if (name.isBlank()) continue
+ val output = File(destination, name)
+ val normalizedDestination = destination.canonicalFile.toPath()
+ val normalizedOutput = output.canonicalFile.toPath()
+ check(normalizedOutput.startsWith(normalizedDestination)) {
+ "Unsafe PulseAudio runtime entry: $name"
+ }
+ if (entry.isDirectory) {
+ check(output.mkdirs() || output.isDirectory) {
+ "Could not create PulseAudio runtime directory"
+ }
+ } else {
+ check(name in expectedEntries) { "Unexpected PulseAudio runtime entry: $name" }
+ val parent = checkNotNull(output.parentFile)
+ check(parent.mkdirs() || parent.isDirectory) {
+ "Could not create the parent directory for $name"
+ }
+ FileOutputStream(output).use { stream ->
+ input.copyTo(stream)
+ stream.fd.sync()
+ }
+ check(output.setReadable(true, true)) { "Could not make $name readable" }
+ if (name != VERSION_ENTRY) {
+ check(output.setExecutable(true, true)) {
+ "Could not make $name executable"
+ }
+ }
+ extracted += name
+ }
+ input.closeEntry()
+ }
+ }
+ check(extracted == expectedEntries) {
+ "PulseAudio runtime archive is missing ${expectedEntries - extracted}"
+ }
+ }
+
+ private fun isComplete(directory: File): Boolean =
+ expectedEntries.all { File(directory, it).isFile } &&
+ File(directory, VERSION_ENTRY).readText().trim() == "17.0-3"
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt b/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt
new file mode 100644
index 0000000..5ba6e8e
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt
@@ -0,0 +1,254 @@
+package org.randomcoder.udroid.audio
+
+import android.content.Context
+import org.randomcoder.udroid.runtime.AndroidExecutableCommand
+import org.randomcoder.udroid.runtime.EventJournal
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStreamReader
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.atomic.AtomicReference
+
+data class AudioEndpoint(
+ val hostAuthDirectory: File,
+) {
+ val guestAuthDirectory: String = GUEST_AUTH_DIRECTORY
+ val guestServer: String = GUEST_SERVER
+ val guestCookie: String = "$GUEST_AUTH_DIRECTORY/$COOKIE_NAME"
+
+ companion object {
+ const val GUEST_AUTH_DIRECTORY = "/tmp/.udroid-pulse"
+ const val GUEST_SERVER = "tcp:127.0.0.1:4713"
+ const val COOKIE_NAME = "cookie"
+ const val PORT = 4713
+ }
+}
+
+data class AudioSessionSnapshot(
+ val running: Boolean = false,
+ val outputEnabled: Boolean = false,
+ val microphoneEnabled: Boolean = false,
+ val message: String = "Audio is stopped",
+)
+
+class AudioServerController(
+ context: Context,
+ private val journal: EventJournal,
+ private val executor: ExecutorService,
+) {
+ private val appContext = context.applicationContext
+ private val ownedProcess = AtomicReference(null)
+ private val lock = Any()
+ private val authDirectory =
+ File(appContext.filesDir, "audio/transport").apply {
+ check(mkdirs() || isDirectory) { "Could not prepare PulseAudio authentication" }
+ }
+ private val stateDirectory =
+ File(appContext.filesDir, "audio/state").apply {
+ check(mkdirs() || isDirectory) { "Could not prepare PulseAudio state" }
+ }
+
+ fun endpoint(): AudioEndpoint = AudioEndpoint(authDirectory)
+
+ fun current(): AudioSessionSnapshot {
+ val owned = ownedProcess.get()
+ return if (owned?.process?.isAlive == true) {
+ AudioSessionSnapshot(
+ running = true,
+ outputEnabled = owned.configuration.outputEnabled,
+ microphoneEnabled = owned.configuration.microphoneEnabled,
+ message =
+ when {
+ owned.configuration.microphoneEnabled ->
+ "Speaker and microphone are connected"
+ owned.configuration.outputEnabled -> "Speaker is connected"
+ else -> "Audio bridge is connected"
+ },
+ )
+ } else {
+ AudioSessionSnapshot()
+ }
+ }
+
+ fun apply(
+ configuration: AudioConfiguration,
+ bootId: String?,
+ ): AudioSessionSnapshot =
+ synchronized(lock) {
+ val current = ownedProcess.get()
+ if (current?.process?.isAlive == true && current.configuration == configuration) {
+ return@synchronized this.current()
+ }
+ stopLocked(bootId)
+ if (!configuration.outputEnabled && !configuration.microphoneEnabled) {
+ return@synchronized AudioSessionSnapshot(message = "Audio is disabled")
+ }
+
+ val runtime = AudioRuntimeInstaller.install(appContext)
+ val cookie = File(authDirectory, AudioEndpoint.COOKIE_NAME)
+ val configurationFile =
+ File(appContext.cacheDir, "audio/default.pa").apply {
+ parentFile?.mkdirs()
+ writeText(PulseAudioConfiguration.render(configuration, cookie))
+ }
+ val command =
+ AndroidExecutableCommand.create(
+ runtime.executable,
+ "--daemonize=no",
+ "--fail=yes",
+ "--exit-idle-time=-1",
+ "--disable-shm=yes",
+ "--log-target=stderr",
+ "--log-level=notice",
+ "--dl-search-path=${runtime.moduleDirectory.absolutePath}",
+ "--file=${configurationFile.absolutePath}",
+ )
+ val process =
+ ProcessBuilder(command)
+ .directory(appContext.filesDir)
+ .redirectErrorStream(true)
+ .apply {
+ environment().clear()
+ environment().putAll(
+ mapOf(
+ "ANDROID_DATA" to "/data",
+ "ANDROID_ROOT" to "/system",
+ "ANDROID_RUNTIME_ROOT" to "/apex/com.android.runtime",
+ "ANDROID_TZDATA_ROOT" to "/apex/com.android.tzdata",
+ "HOME" to appContext.filesDir.absolutePath,
+ "LD_LIBRARY_PATH" to runtime.libraryDirectory.absolutePath,
+ "PATH" to "/system/bin",
+ "PULSE_RUNTIME_PATH" to authDirectory.absolutePath,
+ "PULSE_STATE_PATH" to stateDirectory.absolutePath,
+ "TMPDIR" to appContext.cacheDir.absolutePath,
+ ),
+ )
+ }.start()
+ val owned = OwnedAudioProcess(process, configuration)
+ check(ownedProcess.compareAndSet(null, owned)) {
+ process.destroyForcibly()
+ "Another audio server won the ownership race"
+ }
+ monitor(owned, bootId)
+ journal.append(
+ component = "audio",
+ severity = "info",
+ event = "server_started",
+ message = "PulseAudio started on authenticated loopback transport",
+ bootId = bootId,
+ fields =
+ mapOf(
+ "output" to configuration.outputEnabled,
+ "microphone" to configuration.microphoneEnabled,
+ "transport" to "tcp_loopback",
+ ),
+ )
+ current()
+ }
+
+ fun stop(bootId: String?) {
+ synchronized(lock) { stopLocked(bootId) }
+ }
+
+ private fun stopLocked(bootId: String?) {
+ val owned = ownedProcess.getAndSet(null) ?: return
+ if (owned.process.isAlive) {
+ owned.process.destroy()
+ if (owned.process.isAlive) owned.process.destroyForcibly()
+ }
+ journal.append(
+ component = "audio",
+ severity = "info",
+ event = "server_stopped",
+ message = "PulseAudio stopped",
+ bootId = bootId,
+ )
+ }
+
+ private fun monitor(
+ owned: OwnedAudioProcess,
+ bootId: String?,
+ ) {
+ executor.execute {
+ var lines = 0
+ runCatching {
+ BufferedReader(InputStreamReader(owned.process.inputStream)).useLines { output ->
+ output.forEach { line ->
+ if (lines++ < MAX_LOG_LINES) {
+ journal.append(
+ component = "audio",
+ severity = "debug",
+ event = "server_output",
+ message = line.take(MAX_LOG_CHARS),
+ bootId = bootId,
+ )
+ }
+ }
+ }
+ }.onFailure { error ->
+ // Closing the process stream interrupts a blocking reader during
+ // normal stop. Report only reads that fail while we still own a
+ // live server.
+ if (ownedProcess.get() === owned && owned.process.isAlive) {
+ journal.append(
+ component = "audio",
+ severity = "warning",
+ event = "server_log_read_failed",
+ message = error.message ?: "PulseAudio output could not be read",
+ bootId = bootId,
+ fields = mapOf("exception" to error.javaClass.name),
+ )
+ }
+ }
+ }
+ executor.execute {
+ val exitCode = runCatching { owned.process.waitFor() }.getOrNull() ?: return@execute
+ if (!ownedProcess.compareAndSet(owned, null)) return@execute
+ journal.append(
+ component = "audio",
+ severity = if (exitCode == 0) "info" else "error",
+ event = "server_exited",
+ message = "PulseAudio exited with code $exitCode",
+ bootId = bootId,
+ fields = mapOf("exit_code" to exitCode),
+ )
+ }
+ }
+
+ private data class OwnedAudioProcess(
+ val process: Process,
+ val configuration: AudioConfiguration,
+ )
+
+ private companion object {
+ const val MAX_LOG_LINES = 200
+ const val MAX_LOG_CHARS = 2_000
+ }
+}
+
+internal object PulseAudioConfiguration {
+ fun render(
+ configuration: AudioConfiguration,
+ cookie: File,
+ ): String =
+ buildList {
+ add(".fail")
+ if (configuration.outputEnabled) {
+ add("load-module module-sles-sink sink_name=udroid_output")
+ }
+ if (configuration.microphoneEnabled) {
+ add("load-module module-sles-source source_name=udroid_input")
+ }
+ add(
+ "load-module module-native-protocol-tcp " +
+ "listen=127.0.0.1 port=${AudioEndpoint.PORT} " +
+ "auth-cookie=${quotePulseArgument(cookie.absolutePath)} " +
+ "auth-cookie-enabled=1",
+ )
+ }.joinToString(separator = "\n", postfix = "\n")
+
+ private fun quotePulseArgument(value: String): String {
+ require('\n' !in value && '\r' !in value) { "Unsafe PulseAudio path" }
+ return "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
+ }
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt
index beee913..7aacf85 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt
@@ -1,6 +1,7 @@
package org.randomcoder.udroid.runtime
import android.content.Context
+import org.randomcoder.udroid.audio.AudioEndpoint
import org.randomcoder.udroid.install.ProotRuntime
import org.randomcoder.udroid.linuxapps.LinuxApplication
import java.io.File
@@ -18,6 +19,7 @@ object ProotApplicationLaunchBuilder {
rootfs: File,
x11SocketDirectory: File,
application: LinuxApplication,
+ audioEndpoint: AudioEndpoint? = null,
): ProotApplicationLaunch {
require(application.executable.isNotBlank()) { "Application executable is empty" }
require(x11SocketDirectory.isDirectory) { "The X11 socket directory is unavailable" }
@@ -35,6 +37,7 @@ object ProotApplicationLaunchBuilder {
guestWorkingDirectory = guestWorkingDirectory,
applicationArguments =
listOf(application.executable) + application.arguments,
+ audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath,
)
val temporaryDirectory =
File(context.cacheDir, "proot").apply {
@@ -69,6 +72,7 @@ object ProotApplicationLaunchBuilder {
guestHome: String,
guestWorkingDirectory: String,
applicationArguments: List,
+ audioAuthDirectory: String? = null,
): List {
require(applicationArguments.isNotEmpty())
return buildList {
@@ -80,6 +84,10 @@ object ProotApplicationLaunchBuilder {
addAndroidProotBindMounts()
add("-b")
add("$x11SocketDirectory:/tmp/.X11-unix")
+ if (audioAuthDirectory != null) {
+ add("-b")
+ add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}")
+ }
add("--cwd=$guestWorkingDirectory")
add("/usr/bin/env")
add("-i")
@@ -90,6 +98,10 @@ object ProotApplicationLaunchBuilder {
add("LANG=C.UTF-8")
add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
add("DISPLAY=:0")
+ if (audioAuthDirectory != null) {
+ add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}")
+ add("PULSE_COOKIE=${AudioEndpoint.GUEST_AUTH_DIRECTORY}/${AudioEndpoint.COOKIE_NAME}")
+ }
add("XDG_CURRENT_DESKTOP=UDROID")
add("GDK_BACKEND=x11")
add("QT_QPA_PLATFORM=xcb")
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt
index 9cc973b..b480fd6 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt
@@ -1,6 +1,7 @@
package org.randomcoder.udroid.runtime
import android.content.Context
+import org.randomcoder.udroid.audio.AudioEndpoint
import org.randomcoder.udroid.install.ProotRuntime
import org.randomcoder.udroid.install.RootfsInstallationPipeline
import java.io.File
@@ -13,6 +14,7 @@ object ProotDesktopLaunchBuilder {
x11SocketDirectory: File,
environment: DesktopEnvironment,
configuration: DesktopConfiguration,
+ audioEndpoint: AudioEndpoint? = null,
): ProotApplicationLaunch {
require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) {
"The selected Linux image is not ready"
@@ -27,6 +29,7 @@ object ProotDesktopLaunchBuilder {
guestHome = guestHome,
environment = environment,
configuration = configuration,
+ audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath,
hasDbusRunSession =
File(rootfs, "usr/bin/dbus-run-session").isFile ||
File(rootfs, "bin/dbus-run-session").isFile,
@@ -65,6 +68,7 @@ object ProotDesktopLaunchBuilder {
environment: DesktopEnvironment,
configuration: DesktopConfiguration,
hasDbusRunSession: Boolean,
+ audioAuthDirectory: String? = null,
): List =
buildList {
add(prootPath)
@@ -75,6 +79,10 @@ object ProotDesktopLaunchBuilder {
addAndroidProotBindMounts()
add("-b")
add("$x11SocketDirectory:/tmp/.X11-unix")
+ if (audioAuthDirectory != null) {
+ add("-b")
+ add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}")
+ }
add("--cwd=$guestHome")
add("/usr/bin/env")
add("-i")
@@ -85,6 +93,10 @@ object ProotDesktopLaunchBuilder {
add("LANG=C.UTF-8")
add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
add("DISPLAY=:0")
+ if (audioAuthDirectory != null) {
+ add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}")
+ add("PULSE_COOKIE=${AudioEndpoint.GUEST_AUTH_DIRECTORY}/${AudioEndpoint.COOKIE_NAME}")
+ }
add("XDG_SESSION_TYPE=x11")
add("XDG_CURRENT_DESKTOP=${environment.kind.desktopName}")
add("DESKTOP_SESSION=${environment.id}")
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt
index 42e5292..d03bb93 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt
@@ -1,6 +1,7 @@
package org.randomcoder.udroid.runtime
import android.content.Context
+import org.randomcoder.udroid.audio.AudioEndpoint
import org.randomcoder.udroid.install.ProotRuntime
import org.randomcoder.udroid.install.RootfsInstallationPipeline
import java.io.File
@@ -27,6 +28,7 @@ object ProotTerminalLaunchBuilder {
runtime: ProotRuntime,
rootfs: File = InstalledRootfsResolver.resolve(context),
x11SocketDirectory: File? = null,
+ audioEndpoint: AudioEndpoint? = null,
): ProotTerminalLaunch {
require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) {
"The selected Linux image is not ready"
@@ -56,6 +58,7 @@ object ProotTerminalLaunchBuilder {
guestHome = guestHome,
guestShell = guestShell,
x11SocketDirectory = x11SocketDirectory?.absolutePath,
+ audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath,
)
val environment =
buildEnvironment(
@@ -80,6 +83,7 @@ object ProotTerminalLaunchBuilder {
guestHome: String,
guestShell: String,
x11SocketDirectory: String? = null,
+ audioAuthDirectory: String? = null,
): Array =
buildList {
// TerminalSession passes this complete vector to execvp(), including argv[0].
@@ -94,6 +98,10 @@ object ProotTerminalLaunchBuilder {
add("-b")
add("$x11SocketDirectory:/tmp/.X11-unix")
}
+ if (audioAuthDirectory != null) {
+ add("-b")
+ add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}")
+ }
add("--cwd=$guestHome")
add("/usr/bin/env")
add("-i")
@@ -106,6 +114,10 @@ object ProotTerminalLaunchBuilder {
add("LANG=C.UTF-8")
add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
if (x11SocketDirectory != null) add("DISPLAY=:0")
+ if (audioAuthDirectory != null) {
+ add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}")
+ add("PULSE_COOKIE=${AudioEndpoint.GUEST_AUTH_DIRECTORY}/${AudioEndpoint.COOKIE_NAME}")
+ }
add(guestShell)
if (guestShell.endsWith("bash")) add("--login")
}.toTypedArray()
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt
index 81a1b5d..e8567d5 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt
@@ -11,6 +11,8 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.Build
import android.os.Binder
import android.os.Handler
import android.os.IBinder
@@ -21,6 +23,7 @@ import android.system.Os
import android.system.OsConstants
import android.util.Log
import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.termux.terminal.TerminalColors
import com.termux.terminal.TerminalSession
@@ -28,6 +31,10 @@ import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalView
import org.randomcoder.udroid.MainActivity
import org.randomcoder.udroid.UdroidApplication
+import org.randomcoder.udroid.audio.AudioConfiguration
+import org.randomcoder.udroid.audio.AudioConfigurationStore
+import org.randomcoder.udroid.audio.AudioServerController
+import org.randomcoder.udroid.audio.AudioSessionSnapshot
import org.randomcoder.udroid.install.ProotRuntimeInstaller
import org.randomcoder.udroid.linuxapps.LinuxApplication
import org.randomcoder.udroid.x11.X11ServerController
@@ -52,6 +59,10 @@ class RuntimeSupervisorService : Service() {
private val applicationProcesses = ConcurrentHashMap()
private val applicationExecutor = Executors.newCachedThreadPool()
private val x11Controller by lazy { X11ServerController(this, app.journal) }
+ private val audioConfigurationStore by lazy { AudioConfigurationStore(this) }
+ private val audioController by lazy {
+ AudioServerController(this, app.journal, applicationExecutor)
+ }
private val app: UdroidApplication
get() = application as UdroidApplication
@@ -189,8 +200,10 @@ class RuntimeSupervisorService : Service() {
return when {
action == ACTION_START_RUNTIME -> {
- startForeground(NOTIFICATION_ID, notification("Starting Linux terminal…"))
- startRuntime(intent.getStringExtra(EXTRA_ROOTFS_NAME))
+ val rootfsName = intent.getStringExtra(EXTRA_ROOTFS_NAME)
+ val configuration = effectiveAudioConfiguration(rootfsName, allowMicrophone = true)
+ startRuntimeForeground("Starting Linux terminal…", configuration)
+ startRuntime(rootfsName, configuration)
START_STICKY
}
@@ -200,7 +213,9 @@ class RuntimeSupervisorService : Service() {
}
action == null && persisted.desiredRunning -> {
- startForeground(NOTIFICATION_ID, notification("Recovering Linux terminal…"))
+ val configuration =
+ effectiveAudioConfiguration(persisted.rootfsName, allowMicrophone = false)
+ startRuntimeForeground("Recovering Linux terminal…", configuration)
app.journal.append(
component = "supervisor",
severity = "warning",
@@ -208,7 +223,7 @@ class RuntimeSupervisorService : Service() {
message = "Android recreated the desired Linux terminal session",
bootId = persisted.bootId,
)
- startRuntime()
+ startRuntime(persisted.rootfsName, configuration)
START_STICKY
}
@@ -228,6 +243,7 @@ class RuntimeSupervisorService : Service() {
desktopLaunchToken.set(null)
ownedDesktop.getAndSet(null)?.let { terminateDesktopProcess(it, OsConstants.SIGKILL) }
stopApplicationProcesses()
+ audioController.stop(app.runtimeState.current().bootId)
applicationExecutor.shutdownNow()
val snapshot = app.runtimeState.current()
app.journal.append(
@@ -245,6 +261,67 @@ class RuntimeSupervisorService : Service() {
fun currentDesktopSession(): DesktopSessionSnapshot = app.runtimeState.current().desktop
+ fun currentAudioSession(): AudioSessionSnapshot = audioController.current()
+
+ fun applyAudioConfiguration(
+ rootfsName: String,
+ configuration: AudioConfiguration,
+ callback: (Result) -> Unit,
+ ) {
+ val session = ownedSession.get()
+ if (session?.isRunning != true || session.mSessionName != rootfsName) {
+ callback(
+ Result.failure(
+ IllegalStateException("$rootfsName is not the running Linux system"),
+ ),
+ )
+ return
+ }
+ val effective =
+ if (
+ configuration.microphoneEnabled &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ configuration.copy(microphoneEnabled = false)
+ } else {
+ configuration
+ }
+ startRuntimeForeground("Applying Linux audio settings…", effective)
+ val bootId = app.runtimeState.current().bootId
+ applicationExecutor.execute {
+ runCatching { audioController.apply(effective, bootId) }
+ .onSuccess {
+ mainHandler.post {
+ updateNotification(
+ if (it.microphoneEnabled) {
+ "Linux audio and microphone are connected"
+ } else if (it.outputEnabled) {
+ "Linux audio is connected"
+ } else {
+ "Linux terminal is running"
+ },
+ )
+ publishState(app.runtimeState.current())
+ callback(Result.success(it))
+ }
+ }.onFailure { error ->
+ app.journal.append(
+ component = "audio",
+ severity = "error",
+ event = "configuration_failed",
+ message = error.message ?: "Could not apply Linux audio settings",
+ bootId = bootId,
+ fields = mapOf("exception" to error.javaClass.name),
+ )
+ mainHandler.post {
+ publishState(app.runtimeState.current())
+ callback(Result.failure(error))
+ }
+ }
+ }
+ }
+
fun attachTerminalView(view: TerminalView) {
check(Looper.myLooper() == Looper.getMainLooper()) {
"Terminal views must attach on the main thread"
@@ -378,6 +455,7 @@ class RuntimeSupervisorService : Service() {
rootfs = rootfs,
x11SocketDirectory = socketDirectory,
application = application,
+ audioEndpoint = audioController.endpoint(),
)
val process =
ProcessBuilder(launch.command)
@@ -516,6 +594,7 @@ class RuntimeSupervisorService : Service() {
x11SocketDirectory = socketDirectory,
environment = request.environment,
configuration = request.configuration,
+ audioEndpoint = audioController.endpoint(),
)
val pidFile =
File(cacheDir, "desktop-process-$launchToken.pid").apply {
@@ -825,7 +904,11 @@ class RuntimeSupervisorService : Service() {
if (signal == OsConstants.SIGKILL) owned.pidFile.delete()
}
- private fun startRuntime(requestedRootfsName: String? = null) {
+ private fun startRuntime(
+ requestedRootfsName: String? = null,
+ audioConfiguration: AudioConfiguration =
+ effectiveAudioConfiguration(requestedRootfsName, allowMicrophone = false),
+ ) {
val existing = ownedSession.get()
if (existing?.isRunning == true && existing.pid > 0) {
if (requestedRootfsName != null && existing.mSessionName != requestedRootfsName) {
@@ -879,12 +962,24 @@ class RuntimeSupervisorService : Service() {
runCatching {
val runtime = ProotRuntimeInstaller.install(this)
val rootfs = InstalledRootfsResolver.resolve(this, requestedRootfsName)
+ runCatching { audioController.apply(audioConfiguration, bootId) }
+ .onFailure { error ->
+ app.journal.append(
+ component = "audio",
+ severity = "error",
+ event = "server_start_failed",
+ message = error.message ?: "PulseAudio failed to start",
+ bootId = bootId,
+ fields = mapOf("exception" to error.javaClass.name),
+ )
+ }
val x11SocketDirectory = x11Controller.start(rootfs, bootId)
ProotTerminalLaunchBuilder.create(
context = this,
runtime = runtime,
rootfs = rootfs,
x11SocketDirectory = x11SocketDirectory,
+ audioEndpoint = audioController.endpoint(),
)
}.mapCatching { launch ->
configureTerminalColors()
@@ -994,6 +1089,7 @@ class RuntimeSupervisorService : Service() {
app.runtimeState.update { RuntimeStateMachine.afterProcessExit(it, exitCode) }
stopApplicationProcesses()
x11Controller.stop(beforeExit.bootId)
+ audioController.stop(beforeExit.bootId)
publishState(next)
app.journal.append(
component = "terminal",
@@ -1016,6 +1112,7 @@ class RuntimeSupervisorService : Service() {
stopDesktopProcess(restarting = false)
stopApplicationProcesses()
x11Controller.stop(current.bootId)
+ audioController.stop(current.bootId)
val stopping =
app.runtimeState.update {
it.copy(
@@ -1207,6 +1304,67 @@ class RuntimeSupervisorService : Service() {
)
}
+ private fun effectiveAudioConfiguration(
+ rootfsName: String?,
+ allowMicrophone: Boolean,
+ ): AudioConfiguration {
+ val resolvedName =
+ rootfsName
+ ?: runCatching { app.rootfsRegistry.active()?.name }.getOrNull()
+ ?: return AudioConfiguration()
+ val saved = audioConfigurationStore.load(resolvedName)
+ val microphoneGranted =
+ ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ return saved.copy(
+ microphoneEnabled =
+ saved.microphoneEnabled && allowMicrophone && microphoneGranted,
+ )
+ }
+
+ private fun startRuntimeForeground(
+ text: String,
+ configuration: AudioConfiguration,
+ ) {
+ val type =
+ when {
+ Build.VERSION.SDK_INT >= 34 -> {
+ var value = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ if (configuration.outputEnabled) {
+ value = value or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ }
+ if (configuration.microphoneEnabled) {
+ value = value or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ }
+ value
+ }
+ Build.VERSION.SDK_INT >= 30 -> {
+ var value = 0
+ if (configuration.outputEnabled) {
+ value = value or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ }
+ if (configuration.microphoneEnabled) {
+ value = value or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ }
+ value
+ }
+ Build.VERSION.SDK_INT >= 29 -> {
+ if (configuration.outputEnabled) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ } else {
+ 0
+ }
+ }
+ else -> 0
+ }
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification(text),
+ type,
+ )
+ }
+
private fun notification(text: String): Notification {
val openIntent =
PendingIntent.getActivity(
diff --git a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
index aa641fd..b1bf270 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
@@ -89,6 +89,7 @@ import org.json.JSONObject
import org.randomcoder.udroid.BuildConfig
import org.randomcoder.udroid.catalog.DistroCatalogState
import org.randomcoder.udroid.catalog.DistroVariant
+import org.randomcoder.udroid.audio.AudioConfiguration
import org.randomcoder.udroid.install.InstallProgress
import org.randomcoder.udroid.linuxapps.LinuxApplication
import org.randomcoder.udroid.linuxapps.LinuxApplicationsState
@@ -174,6 +175,8 @@ fun UdroidApp(
desktopConfiguration: DesktopConfiguration,
desktopScanLoading: Boolean,
desktopScanMessage: String?,
+ audioConfiguration: AudioConfiguration,
+ audioConfigurationMessage: String?,
linuxApplicationsState: LinuxApplicationsState,
linuxApplicationMessage: String?,
showInstallTerminal: Boolean,
@@ -197,6 +200,8 @@ fun UdroidApp(
onSelectDesktopEnvironment: (String) -> Unit,
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
+ onAudioOutputChanged: (Boolean) -> Unit,
+ onMicrophoneChanged: (Boolean) -> Unit,
onStartDesktop: () -> Unit,
onStopDesktop: () -> Unit,
onRestartDesktop: () -> Unit,
@@ -309,6 +314,8 @@ fun UdroidApp(
desktopConfiguration = desktopConfiguration,
desktopScanLoading = desktopScanLoading,
desktopScanMessage = desktopScanMessage,
+ audioConfiguration = audioConfiguration,
+ audioConfigurationMessage = audioConfigurationMessage,
linuxApplicationsState = linuxApplicationsState,
linuxApplicationMessage = linuxApplicationMessage,
showInstallTerminal = showInstallTerminal,
@@ -331,6 +338,8 @@ fun UdroidApp(
onSelectDesktopEnvironment = onSelectDesktopEnvironment,
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
+ onAudioOutputChanged = onAudioOutputChanged,
+ onMicrophoneChanged = onMicrophoneChanged,
onStartDesktop = onStartDesktop,
onStopDesktop = onStopDesktop,
onRestartDesktop = onRestartDesktop,
@@ -373,6 +382,8 @@ fun UdroidApp(
desktopConfiguration = desktopConfiguration,
desktopScanLoading = desktopScanLoading,
desktopScanMessage = desktopScanMessage,
+ audioConfiguration = audioConfiguration,
+ audioConfigurationMessage = audioConfigurationMessage,
linuxApplicationsState = linuxApplicationsState,
linuxApplicationMessage = linuxApplicationMessage,
showInstallTerminal = showInstallTerminal,
@@ -395,6 +406,8 @@ fun UdroidApp(
onSelectDesktopEnvironment = onSelectDesktopEnvironment,
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
+ onAudioOutputChanged = onAudioOutputChanged,
+ onMicrophoneChanged = onMicrophoneChanged,
onStartDesktop = onStartDesktop,
onStopDesktop = onStopDesktop,
onRestartDesktop = onRestartDesktop,
@@ -451,6 +464,8 @@ private fun ManagementPane(
desktopConfiguration: DesktopConfiguration,
desktopScanLoading: Boolean,
desktopScanMessage: String?,
+ audioConfiguration: AudioConfiguration,
+ audioConfigurationMessage: String?,
linuxApplicationsState: LinuxApplicationsState,
linuxApplicationMessage: String?,
showInstallTerminal: Boolean,
@@ -473,6 +488,8 @@ private fun ManagementPane(
onSelectDesktopEnvironment: (String) -> Unit,
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
+ onAudioOutputChanged: (Boolean) -> Unit,
+ onMicrophoneChanged: (Boolean) -> Unit,
onStartDesktop: () -> Unit,
onStopDesktop: () -> Unit,
onRestartDesktop: () -> Unit,
@@ -665,6 +682,8 @@ private fun ManagementPane(
configuration = desktopConfiguration,
scanLoading = desktopScanLoading,
scanMessage = desktopScanMessage,
+ audioConfiguration = audioConfiguration,
+ audioConfigurationMessage = audioConfigurationMessage,
resetAvailable =
selectedRootfs.name in resettableRootfsNames ||
selectedDistro != null,
@@ -691,6 +710,8 @@ private fun ManagementPane(
onSelectEnvironment = onSelectDesktopEnvironment,
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
+ onAudioOutputChanged = onAudioOutputChanged,
+ onMicrophoneChanged = onMicrophoneChanged,
onStartDesktop = onStartDesktop,
onStopTerminal = onStop,
onStopDesktop = onStopDesktop,
diff --git a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
index 1289447..f13e260 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import org.randomcoder.udroid.audio.AudioConfiguration
import org.randomcoder.udroid.catalog.DistroVariant
import org.randomcoder.udroid.catalog.LinuxDistribution
import org.randomcoder.udroid.runtime.DesktopCompositorSupport
@@ -75,6 +76,8 @@ fun LinuxSystemPage(
configuration: DesktopConfiguration,
scanLoading: Boolean,
scanMessage: String?,
+ audioConfiguration: AudioConfiguration,
+ audioConfigurationMessage: String?,
resetAvailable: Boolean,
maintenanceInProgress: Boolean,
maintenanceMessage: String?,
@@ -85,6 +88,8 @@ fun LinuxSystemPage(
onSelectEnvironment: (String) -> Unit,
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
+ onAudioOutputChanged: (Boolean) -> Unit,
+ onMicrophoneChanged: (Boolean) -> Unit,
onStartDesktop: () -> Unit,
onStopTerminal: () -> Unit,
onStopDesktop: () -> Unit,
@@ -218,6 +223,22 @@ fun LinuxSystemPage(
}
}
+ item(key = "audio-label") {
+ UdroidSectionLabel(
+ text = "Audio",
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ item(key = "audio-settings") {
+ AudioSettingsPanel(
+ configuration = audioConfiguration,
+ runtimeRunning = runtimeOwnsSystem && snapshot.phase == RuntimePhase.RUNNING,
+ message = audioConfigurationMessage,
+ onOutputChanged = onAudioOutputChanged,
+ onMicrophoneChanged = onMicrophoneChanged,
+ )
+ }
+
item(key = "desktop-label") {
UdroidSectionLabel(
text = "Desktop session",
@@ -553,6 +574,55 @@ private enum class FilesystemConfirmation {
DELETE,
}
+@Composable
+private fun AudioSettingsPanel(
+ configuration: AudioConfiguration,
+ runtimeRunning: Boolean,
+ message: String?,
+ onOutputChanged: (Boolean) -> Unit,
+ onMicrophoneChanged: (Boolean) -> Unit,
+) {
+ Surface(
+ color = Color.Transparent,
+ border = BorderStroke(1.dp, UdroidLine),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column {
+ SettingRow(
+ title = "Device speaker",
+ detail =
+ if (runtimeRunning) {
+ "Play Linux audio through Android. Changes apply to this session."
+ } else {
+ "Play Linux audio through Android when this system starts."
+ },
+ checked = configuration.outputEnabled,
+ enabled = true,
+ onCheckedChange = onOutputChanged,
+ )
+ Divider(color = UdroidLine)
+ SettingRow(
+ title = "Device microphone",
+ detail =
+ "Off by default. Android asks before Linux can receive microphone input " +
+ "and shows its privacy indicator while active.",
+ checked = configuration.microphoneEnabled,
+ enabled = true,
+ onCheckedChange = onMicrophoneChanged,
+ )
+ message?.let {
+ Divider(color = UdroidLine)
+ Text(
+ text = it,
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
+ color = UdroidMuted,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun OperationalStatePanel(
snapshot: RuntimeSnapshot,
diff --git a/app/src/test/java/org/randomcoder/udroid/audio/PulseAudioConfigurationTest.kt b/app/src/test/java/org/randomcoder/udroid/audio/PulseAudioConfigurationTest.kt
new file mode 100644
index 0000000..647a6b5
--- /dev/null
+++ b/app/src/test/java/org/randomcoder/udroid/audio/PulseAudioConfigurationTest.kt
@@ -0,0 +1,37 @@
+package org.randomcoder.udroid.audio
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class PulseAudioConfigurationTest {
+ @Test
+ fun `output only never loads microphone capture`() {
+ val configuration =
+ PulseAudioConfiguration.render(
+ AudioConfiguration(outputEnabled = true, microphoneEnabled = false),
+ File("/data/user/0/org.randomcoder.udroid/files/audio/transport/cookie"),
+ )
+
+ assertTrue("module-sles-sink" in configuration)
+ assertFalse("module-sles-source" in configuration)
+ assertFalse("module-native-protocol-unix" in configuration)
+ assertTrue("module-native-protocol-tcp" in configuration)
+ assertTrue("listen=127.0.0.1" in configuration)
+ assertTrue("auth-cookie-enabled=1" in configuration)
+ assertFalse("auth-anonymous=1" in configuration)
+ }
+
+ @Test
+ fun `microphone capture is loaded only after it is enabled`() {
+ val configuration =
+ PulseAudioConfiguration.render(
+ AudioConfiguration(outputEnabled = false, microphoneEnabled = true),
+ File("/data/user/0/org.randomcoder.udroid/files/audio/transport/cookie"),
+ )
+
+ assertFalse("module-sles-sink" in configuration)
+ assertTrue("module-sles-source" in configuration)
+ }
+}
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt
index 6daae41..6e25bb4 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt
@@ -79,9 +79,13 @@ class DesktopEnvironmentScannerTest {
touchScaleEnabled = true,
),
hasDbusRunSession = true,
+ audioAuthDirectory = "/data/audio/transport",
)
assertTrue(arguments.contains("/usr/bin/dbus-run-session"))
+ assertTrue(arguments.contains("/data/audio/transport:/tmp/.udroid-pulse"))
+ assertTrue(arguments.contains("PULSE_SERVER=tcp:127.0.0.1:4713"))
+ assertTrue(arguments.contains("PULSE_COOKIE=/tmp/.udroid-pulse/cookie"))
assertTrue(arguments.contains("GDK_SCALE=2"))
val script = arguments[arguments.indexOf("-lc") + 1]
assertTrue(script.contains("-s false"))
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt
index 5e9be3e..c2a53a8 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt
@@ -21,12 +21,16 @@ class ProotApplicationLaunchTest {
"--title",
"Hello; touch /tmp/not-a-command",
),
+ audioAuthDirectory = "/data/audio/transport",
)
assertEquals("/data/proot", arguments.first())
assertTrue(arguments.contains("DISPLAY=:0"))
assertTrue(arguments.contains("GDK_BACKEND=x11"))
assertTrue(arguments.contains("--cwd=/root/Documents"))
+ assertTrue(arguments.contains("/data/audio/transport:/tmp/.udroid-pulse"))
+ assertTrue(arguments.contains("PULSE_SERVER=tcp:127.0.0.1:4713"))
+ assertTrue(arguments.contains("PULSE_COOKIE=/tmp/.udroid-pulse/cookie"))
assertEquals(
listOf("/usr/bin/demo", "--title", "Hello; touch /tmp/not-a-command"),
arguments.takeLast(3),
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt
index 7b5b416..a291d2c 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt
@@ -101,6 +101,33 @@ class ProotTerminalLaunchTest {
assertTrue("DISPLAY=:0" in arguments)
}
+ @Test
+ fun `authenticated loopback audio is exported to the guest`() {
+ val arguments =
+ ProotTerminalLaunchBuilder.buildArguments(
+ linker = "linker64",
+ prootPath = "proot",
+ rootfsPath = "rootfs",
+ guestHome = "/root",
+ guestShell = "/bin/bash",
+ audioAuthDirectory = "/data/user/0/udroid/files/audio/transport",
+ )
+
+ assertTrue(
+ arguments
+ .toList()
+ .windowed(2)
+ .contains(
+ listOf(
+ "-b",
+ "/data/user/0/udroid/files/audio/transport:/tmp/.udroid-pulse",
+ ),
+ ),
+ )
+ assertTrue("PULSE_SERVER=tcp:127.0.0.1:4713" in arguments)
+ assertTrue("PULSE_COOKIE=/tmp/.udroid-pulse/cookie" in arguments)
+ }
+
@Test
fun `proot loader and temporary storage stay app private`() {
val environment =
diff --git a/docs/AUDIO_RUNTIME.md b/docs/AUDIO_RUNTIME.md
new file mode 100644
index 0000000..997c34a
--- /dev/null
+++ b/docs/AUDIO_RUNTIME.md
@@ -0,0 +1,66 @@
+# Audio runtime
+
+uDroid provides Linux playback and optional microphone input through an
+app-owned PulseAudio server. PulseAudio talks to Android through its OpenSL ES
+sink and source modules; PRoot clients talk to PulseAudio over authenticated
+device-local loopback.
+
+```mermaid
+flowchart LR
+ A["Linux application in PRoot"] -->|"PulseAudio native protocol on loopback"| B["uDroid PulseAudio"]
+ B -->|"OpenSL ES output"| C["Android speaker or selected output"]
+ D["Android microphone"] -->|"OpenSL ES input - opt in"| B
+```
+
+The listener is bound only to `127.0.0.1`; it never uses Wi-Fi, mobile data, or
+an external network interface. PRoot does not reliably translate
+pathname-based Unix socket `connect()` calls, so uDroid uses PulseAudio's
+loopback transport and bind-mounts only its private authentication cookie into
+supervised guests. Terminal, desktop, and graphical application launches
+receive:
+
+```text
+PULSE_SERVER=tcp:127.0.0.1:4713
+PULSE_COOKIE=/tmp/.udroid-pulse/cookie
+```
+
+## Using it
+
+Open an installed Linux system in uDroid and use its **Audio** controls.
+Speaker playback is enabled by default. Microphone input is off by default and
+requires Android's microphone permission. Android shows its microphone privacy
+indicator while capture is active.
+
+Linux applications need a PulseAudio-compatible client library. Many desktop
+images already include one. If `pactl` or `paplay` is missing, install your
+distribution's PulseAudio client utilities; the server itself remains on the
+Android side and does not need to run inside the rootfs.
+
+Useful guest checks:
+
+```sh
+printf '%s\n' "$PULSE_SERVER"
+pactl info
+pactl list short sinks
+pactl list short sources
+```
+
+## Lifecycle
+
+- Settings are stored per installed rootfs.
+- Changing a setting updates a running rootfs without restarting its desktop.
+- The audio server stops with the supervised Linux runtime.
+- Android service recreation restores playback, but deliberately leaves the
+ microphone off until the user returns to uDroid and starts or enables it
+ again.
+- Resetting or deleting a rootfs clears its saved audio settings.
+
+## Packaged runtime
+
+`tools/build-pulseaudio-assets.sh` reproduces the ABI-specific runtime archives
+from checksum-pinned official Termux packages listed in
+`tools/pulseaudio-packages.tsv`. Only PulseAudio, the native TCP protocol, the
+OpenSL ES sink/source, and their ELF dependency closure are packaged.
+
+This bridge is independent of fake-media-accel. Audio PCM transport and video
+codec acceleration have different lifecycles and data paths.
diff --git a/tools/build-pulseaudio-assets.sh b/tools/build-pulseaudio-assets.sh
new file mode 100755
index 0000000..fcd03ac
--- /dev/null
+++ b/tools/build-pulseaudio-assets.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPOSITORY_URL="https://packages.termux.dev/apt/termux-main"
+PACKAGE_MANIFEST="$ROOT/tools/pulseaudio-packages.tsv"
+PULSEAUDIO_VERSION="17.0-3"
+
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/udroid-pulseaudio.XXXXXX")"
+trap 'rm -rf "$WORK"' EXIT
+
+require_tool() {
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "Missing required tool: $1" >&2
+ exit 1
+ }
+}
+
+require_tool bsdtar
+require_tool curl
+require_tool shasum
+require_tool zip
+
+fetch_checked() {
+ local repository_path="$1"
+ local destination="$2"
+ local expected="$3"
+ curl --fail --location --silent --show-error \
+ "$REPOSITORY_URL/$repository_path" \
+ --output "$destination"
+ local actual
+ actual="$(shasum -a 256 "$destination" | cut -d ' ' -f 1)"
+ if [[ "$actual" != "$expected" ]]; then
+ echo "Checksum mismatch for $repository_path" >&2
+ echo "expected $expected" >&2
+ echo "actual $actual" >&2
+ exit 1
+ fi
+}
+
+copy_runtime_file() {
+ local source="$1"
+ local destination="$2"
+ [[ -e "$source" ]] || {
+ echo "Missing PulseAudio runtime file: $source" >&2
+ exit 1
+ }
+ mkdir -p "$(dirname "$destination")"
+ # Dereference Termux's prefix-relative library symlinks. The app runtime
+ # deliberately uses a compact, relocatable directory instead.
+ cp -L "$source" "$destination"
+ chmod 755 "$destination"
+}
+
+build_abi() {
+ local android_abi="$1"
+ local termux_arch="$2"
+ local abi_work="$WORK/$android_abi"
+ local package_root="$abi_work/packages"
+ local extracted_root="$abi_work/extracted"
+ local stage="$abi_work/stage"
+ mkdir -p "$package_root" "$extracted_root" "$stage/bin" "$stage/lib" "$stage/modules"
+
+ local matched=0
+ while IFS='|' read -r arch package version repository_path sha256; do
+ [[ -z "$arch" || "$arch" == \#* ]] && continue
+ [[ "$arch" == "$termux_arch" ]] || continue
+ matched=$((matched + 1))
+ local deb="$package_root/$package.deb"
+ local unpacked="$package_root/$package"
+ fetch_checked "$repository_path" "$deb" "$sha256"
+ mkdir -p "$unpacked"
+ bsdtar -xf "$deb" -C "$unpacked"
+ local data_archive
+ data_archive="$(find "$unpacked" -maxdepth 1 -type f -name 'data.tar.*' -print -quit)"
+ [[ -n "$data_archive" ]] || {
+ echo "No data archive in $package $version" >&2
+ exit 1
+ }
+ bsdtar -xf "$data_archive" -C "$extracted_root"
+ done < "$PACKAGE_MANIFEST"
+ [[ "$matched" -gt 0 ]] || {
+ echo "No PulseAudio packages are pinned for $termux_arch" >&2
+ exit 1
+ }
+
+ local prefix="$extracted_root/data/data/com.termux/files/usr"
+ copy_runtime_file "$prefix/bin/pulseaudio" "$stage/bin/pulseaudio"
+
+ local library
+ for library in \
+ libFLAC.so \
+ libandroid-execinfo.so \
+ libdbus-1.so \
+ libiconv.so \
+ libltdl.so \
+ libmp3lame.so \
+ libogg.so \
+ libopus.so \
+ libprotocol-native.so \
+ libpulse.so \
+ libsndfile.so \
+ libsoxr.so \
+ libspeexdsp.so \
+ libvorbis.so \
+ libvorbisenc.so; do
+ copy_runtime_file "$prefix/lib/$library" "$stage/lib/$library"
+ done
+ copy_runtime_file \
+ "$prefix/lib/pulseaudio/libpulsecommon-17.0.so" \
+ "$stage/lib/libpulsecommon-17.0.so"
+ copy_runtime_file \
+ "$prefix/lib/pulseaudio/libpulsecore-17.0.so" \
+ "$stage/lib/libpulsecore-17.0.so"
+
+ local module
+ for module in \
+ module-native-protocol-tcp.so \
+ module-sles-sink.so \
+ module-sles-source.so; do
+ copy_runtime_file \
+ "$prefix/lib/pulseaudio/modules/$module" \
+ "$stage/modules/$module"
+ done
+
+ printf '%s\n' "$PULSEAUDIO_VERSION" > "$stage/VERSION"
+ # Keep the checked-in archive byte-for-byte reproducible. zip stores each
+ # input mtime even with -X, so normalize files and feed a sorted file list.
+ find "$stage" -type f -exec touch -t 202001010000 {} +
+ local destination="$ROOT/app/src/main/assets/runtime/$android_abi/pulseaudio-runtime.zip"
+ local staging_archive="$abi_work/pulseaudio-runtime.zip"
+ (
+ cd "$stage"
+ find . -type f -print | LC_ALL=C sort | zip -9 -X -q "$staging_archive" -@
+ )
+ install -m 644 "$staging_archive" "$destination"
+ echo "$android_abi: $(shasum -a 256 "$destination" | cut -d ' ' -f 1)"
+}
+
+if [[ "$#" -eq 0 ]]; then
+ set -- arm64-v8a armeabi-v7a x86_64
+fi
+
+for abi in "$@"; do
+ case "$abi" in
+ arm64-v8a) build_abi "$abi" aarch64 ;;
+ armeabi-v7a) build_abi "$abi" arm ;;
+ x86_64) build_abi "$abi" x86_64 ;;
+ *)
+ echo "Unsupported ABI: $abi" >&2
+ exit 2
+ ;;
+ esac
+done
diff --git a/tools/pulseaudio-packages.tsv b/tools/pulseaudio-packages.tsv
new file mode 100644
index 0000000..92bdadc
--- /dev/null
+++ b/tools/pulseaudio-packages.tsv
@@ -0,0 +1,40 @@
+# termux_arch|package|version|repository_path|sha256
+aarch64|pulseaudio|17.0-3|pool/main/p/pulseaudio/pulseaudio_17.0-3_aarch64.deb|a5f891d4ce1110ad18e9a7bfccdc8cbd4474e65dea5b6f5ccfb921bd10420162
+aarch64|dbus|1.16.2-3|pool/main/d/dbus/dbus_1.16.2-3_aarch64.deb|1b6d5e349708414acbbb1404210e32d4e3f390890d02e162639dec1016a8657b
+aarch64|libandroid-execinfo|0.1-3|pool/main/liba/libandroid-execinfo/libandroid-execinfo_0.1-3_aarch64.deb|725dd2c6da7fc96e860fcc928e18aa9fb0e85e0bdcaf4f8ce0f0654ca6860fc2
+aarch64|libltdl|2.6.2|pool/main/libl/libltdl/libltdl_2.6.2_aarch64.deb|7b4643fe9d0ab42c0c3c8a1018640816bdb43d422f2268a5e4ea80675624f539
+aarch64|libsndfile|1.2.2-2|pool/main/libs/libsndfile/libsndfile_1.2.2-2_aarch64.deb|f71cca07c3209d6baadd9bd51ab239d66ea89b5dc10bcb86637050b9463ad8da
+aarch64|libsoxr|0.1.3-8|pool/main/libs/libsoxr/libsoxr_0.1.3-8_aarch64.deb|a1c5f66aae3b9900e9a492b43901b2b98586870c7905feb9f3c835cc9af464be
+aarch64|speexdsp|1.2.1-1|pool/main/s/speexdsp/speexdsp_1.2.1-1_aarch64.deb|21cd5d122a8d24dffb9b2926dff7059f8f42cde7c5a91f27ea5c9b4f40356be0
+aarch64|libiconv|1.18-1|pool/main/libi/libiconv/libiconv_1.18-1_aarch64.deb|b19e6f348034bb48d2a5590b5cb242769f682c476717374d134d004cc663dc84
+aarch64|libflac|1.5.0-1|pool/main/libf/libflac/libflac_1.5.0-1_aarch64.deb|d1d6fcac6fd6d15c36f34464cb01d5e2140e321d8448dfc396cd7eb1536b1f6c
+aarch64|libmp3lame|3.100-7|pool/main/libm/libmp3lame/libmp3lame_3.100-7_aarch64.deb|8c28c74209eb5c31675b4e9c1ee5193c1bf22c49ff5965612dcf9aede5d870d9
+aarch64|libogg|1.3.6-1|pool/main/libo/libogg/libogg_1.3.6-1_aarch64.deb|4f1700a532f251ad3600f44f88cd764f1f6b0542d3343391117944b47a92802d
+aarch64|libopus|1.6.1|pool/main/libo/libopus/libopus_1.6.1_aarch64.deb|005a9335ce0ec0f510bbe6e0229c901d6f920cc375fcda56461e96cef70f8ff0
+aarch64|libvorbis|1.3.7-4|pool/main/libv/libvorbis/libvorbis_1.3.7-4_aarch64.deb|6bfd70c4b570b85594bd06116b75a6f8109ec58cbc123e938b630a6eefdedf4e
+arm|pulseaudio|17.0-3|pool/main/p/pulseaudio/pulseaudio_17.0-3_arm.deb|1ee15b0a3178d06b0e1230ed96d817ac19bc7159405d107b686d1d99c486ba24
+arm|dbus|1.16.2-3|pool/main/d/dbus/dbus_1.16.2-3_arm.deb|fd768990cc06fd291caddeecc4ff840c09b5c184ee924deca00acb4da731c423
+arm|libandroid-execinfo|0.1-3|pool/main/liba/libandroid-execinfo/libandroid-execinfo_0.1-3_arm.deb|9d78f90e7e8f9e02edb176b40acd288c00e4d4c25f22c068656a2e0da71fdd38
+arm|libltdl|2.6.2|pool/main/libl/libltdl/libltdl_2.6.2_arm.deb|9de613abbcb09ec605510a927dda1d55508f5fcbc7e5ef7768700dea03be81d5
+arm|libsndfile|1.2.2-2|pool/main/libs/libsndfile/libsndfile_1.2.2-2_arm.deb|12e105220776337d003e291f05bed7bd7992a816da36cc1a947b10887f818c2c
+arm|libsoxr|0.1.3-8|pool/main/libs/libsoxr/libsoxr_0.1.3-8_arm.deb|7673fe2865f99bb00b41e98d6cc2d57629f438fc55b04ed7c9c26c6ca7340e2f
+arm|speexdsp|1.2.1-1|pool/main/s/speexdsp/speexdsp_1.2.1-1_arm.deb|7351c7ab2c95879483756c1c52fd6e4f5c55599bef1e9be5a353a97d6ffc157f
+arm|libiconv|1.18-1|pool/main/libi/libiconv/libiconv_1.18-1_arm.deb|a30b5127915dc228bd92f4057184d61ff6eebbca22cb9f26bc5f5cc43c4f3cf3
+arm|libflac|1.5.0-1|pool/main/libf/libflac/libflac_1.5.0-1_arm.deb|303943c28e47750a220ca914c5e75d6c7dd087862e85ba1ac55bbe10930a7cd2
+arm|libmp3lame|3.100-7|pool/main/libm/libmp3lame/libmp3lame_3.100-7_arm.deb|fc958f59b8b8dfdd7f07f0c11b8c43a55fda04c64f32ff0030e7298147c99113
+arm|libogg|1.3.6-1|pool/main/libo/libogg/libogg_1.3.6-1_arm.deb|8f7f1754210f878556040f5f8de5cfe50b1c55c5aa1f6a07aa35276622184999
+arm|libopus|1.6.1|pool/main/libo/libopus/libopus_1.6.1_arm.deb|a28ceddf3fa2fdf02308e9444712d727839b077f44c0e210f7fedd9dcabb3651
+arm|libvorbis|1.3.7-4|pool/main/libv/libvorbis/libvorbis_1.3.7-4_arm.deb|d6fa26aa0ce8146bdda0f39affe45a649ed23365e6b905da9314ec73cf14e12f
+x86_64|pulseaudio|17.0-3|pool/main/p/pulseaudio/pulseaudio_17.0-3_x86_64.deb|76e4af9ec98e356758743144d6f00900b0d0956943cf5728b2eb4d10208b550d
+x86_64|dbus|1.16.2-3|pool/main/d/dbus/dbus_1.16.2-3_x86_64.deb|7860940419ff89270227063267f7aab3e3b7a91789359d9296190e50a361aff7
+x86_64|libandroid-execinfo|0.1-3|pool/main/liba/libandroid-execinfo/libandroid-execinfo_0.1-3_x86_64.deb|348582b47b4b6dec8c13147437d8bd93d029239bc95f30635074cad08efb6220
+x86_64|libltdl|2.6.2|pool/main/libl/libltdl/libltdl_2.6.2_x86_64.deb|9a71bef5c3ba9da2a90ec6dfa0fd99e1802cb03cc3ec37059340c6c7b7f441fd
+x86_64|libsndfile|1.2.2-2|pool/main/libs/libsndfile/libsndfile_1.2.2-2_x86_64.deb|b1464c23bbef58256b837a2ffafbc372e9464c295490e68e9307c22945e9055a
+x86_64|libsoxr|0.1.3-8|pool/main/libs/libsoxr/libsoxr_0.1.3-8_x86_64.deb|3cd66761119a4f4cb621a701279c2b2edc9a6c4e9f1872dbfc8058fb76c6a66f
+x86_64|speexdsp|1.2.1-1|pool/main/s/speexdsp/speexdsp_1.2.1-1_x86_64.deb|9d8b3656fb666b524cd46ef6c37b3323033e7398400746fc09945dc004fb0e7f
+x86_64|libiconv|1.18-1|pool/main/libi/libiconv/libiconv_1.18-1_x86_64.deb|c171356580d85f58d3eedc9c5f49b563d80afb18c92cbbb0382295d539bc0719
+x86_64|libflac|1.5.0-1|pool/main/libf/libflac/libflac_1.5.0-1_x86_64.deb|2f07360bfa8c876eeb73cd566c4c0bde19345645dcae4fbc6db14cc12301c6e3
+x86_64|libmp3lame|3.100-7|pool/main/libm/libmp3lame/libmp3lame_3.100-7_x86_64.deb|098490855464eefed965c91b8484d275ba840042d882b66dc6346a96d0f5f70e
+x86_64|libogg|1.3.6-1|pool/main/libo/libogg/libogg_1.3.6-1_x86_64.deb|55dd0908feb56bec69fdf1c0aaeedb73f6c74cc28495c0d640172845863c9eed
+x86_64|libopus|1.6.1|pool/main/libo/libopus/libopus_1.6.1_x86_64.deb|724f0215e78a21a02f8dd01dd6c78d6cea71457c8080be99f984f5d8c8f5e4d9
+x86_64|libvorbis|1.3.7-4|pool/main/libv/libvorbis/libvorbis_1.3.7-4_x86_64.deb|9515c03de58faa17e2e19deb78da09289c90b939c17fcbaf4b171aebfefb65bf