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