Skip to content

Commit d7a90df

Browse files
feat: add audio input and output support
1 parent abe2424 commit d7a90df

24 files changed

Lines changed: 1261 additions & 8 deletions

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ the app's **About** page.
3030
| 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. |
3131
| 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`. |
3232
| 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. |
33+
| 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. |
3334

3435
### Current limitations
3536

@@ -48,13 +49,15 @@ the app's **About** page.
4849
- The embedded display is functional, but general-purpose GPU acceleration is
4950
not yet a supported app contract. Desktop responsiveness depends on the
5051
device, resolution, applications, and compositor choice.
51-
- Audio integration, background desktop recovery, multiple displays, and
52-
production-level desktop compatibility remain incomplete.
52+
- Background desktop recovery, multiple displays, and production-level desktop
53+
compatibility remain incomplete. Audio currently targets PulseAudio clients;
54+
direct ALSA-only applications may require a PulseAudio compatibility plugin.
5355

5456
See the [distribution catalogue](docs/DISTRIBUTION_CATALOGUE.md),
5557
[OCI image architecture](docs/OCI_IMAGES.md),
5658
[Linux application launcher](docs/LINUX_APPLICATION_LAUNCHER.md),
5759
[X11 runtime architecture](docs/X11_RUNTIME_ARCHITECTURE.md),
60+
[audio runtime](docs/AUDIO_RUNTIME.md),
5861
[performance notes](docs/PERFORMANCE.md), and
5962
[app updates](docs/APP_UPDATES.md) for current behavior and technical details.
6063

@@ -104,6 +107,7 @@ export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358"
104107
./app/src/main/cpp/build-runtime-probe.sh
105108
./tools/build-proot-assets.sh
106109
./tools/build-gnu-tar-assets.sh
110+
./tools/build-pulseaudio-assets.sh
107111
./gradlew :app:assembleRelease
108112
```
109113

THIRD_PARTY_NOTICES.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ retain their existing MIT grants and may also be distributed under GPLv3 as
7575
part of the combined application. Complete corresponding source includes this
7676
repository and all recursively pinned Termux:X11 submodules.
7777

78+
## PulseAudio and Termux runtime libraries
79+
80+
- PulseAudio project: <https://www.freedesktop.org/wiki/Software/PulseAudio/>
81+
- PulseAudio version: `17.0`, Termux package revision `3`
82+
- Binary package repository: <https://packages.termux.dev/apt/termux-main/>
83+
- Package versions and SHA-256 values: `tools/pulseaudio-packages.tsv`
84+
- Repackaging script: `tools/build-pulseaudio-assets.sh`
85+
- PulseAudio license: GNU Lesser General Public License version 2.1 or later
86+
87+
The APK contains a compact relocation of the official Termux PulseAudio
88+
runtime. It includes only the daemon, native TCP protocol, OpenSL ES
89+
sink/source modules, and their required shared libraries. uDroid does not
90+
expose PulseAudio beyond authenticated device-local loopback. The pinned
91+
manifest records every input package, version, repository path, and archive
92+
checksum used for all supported ABIs.
93+
The individual dependency packages retain their upstream licenses.
94+
7895
## PRoot-Distro rootfs recipes
7996

8097
- Project: <https://github.com/termux/proot-distro>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.randomcoder.udroid.audio
2+
3+
import androidx.test.core.app.ApplicationProvider
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
import org.junit.runner.RunWith
8+
import org.randomcoder.udroid.runtime.EventJournal
9+
import java.net.InetAddress
10+
import java.net.Socket
11+
import java.util.concurrent.Executors
12+
13+
@RunWith(AndroidJUnit4::class)
14+
class AudioRuntimeProbeTest {
15+
@Test
16+
fun packagedRuntimeStartsOutputOnAuthenticatedLoopback() {
17+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
18+
val executor = Executors.newCachedThreadPool()
19+
val controller = AudioServerController(context, EventJournal(context), executor)
20+
try {
21+
val started =
22+
controller.apply(
23+
AudioConfiguration(outputEnabled = true, microphoneEnabled = false),
24+
bootId = "audio-device-probe",
25+
)
26+
assertTrue(started.running)
27+
val cookie =
28+
controller.endpoint().hostAuthDirectory.resolve(AudioEndpoint.COOKIE_NAME)
29+
val deadline = System.nanoTime() + 5_000_000_000L
30+
var acceptingConnections = false
31+
while (!acceptingConnections && controller.current().running && System.nanoTime() < deadline) {
32+
acceptingConnections =
33+
runCatching {
34+
Socket(InetAddress.getLoopbackAddress(), AudioEndpoint.PORT).use { }
35+
true
36+
}.getOrDefault(false)
37+
Thread.sleep(20)
38+
}
39+
assertTrue("PulseAudio did not create its authentication cookie", cookie.isFile)
40+
assertTrue("PulseAudio did not listen on device-local loopback", acceptingConnections)
41+
assertTrue(controller.current().outputEnabled)
42+
assertTrue(!controller.current().microphoneEnabled)
43+
} finally {
44+
controller.stop("audio-device-probe")
45+
executor.shutdownNow()
46+
}
47+
}
48+
}

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
77
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
88
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
9+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
10+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
911
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
1012
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
13+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
1114
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
1215
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
1316

@@ -86,7 +89,7 @@
8689
android:name=".runtime.RuntimeSupervisorService"
8790
android:description="@string/supervisor_description"
8891
android:exported="false"
89-
android:foregroundServiceType="specialUse"
92+
android:foregroundServiceType="specialUse|mediaPlayback|microphone"
9093
android:stopWithTask="false">
9194
<property
9295
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
2.64 MB
Binary file not shown.
2.4 MB
Binary file not shown.
2.73 MB
Binary file not shown.

app/src/main/java/org/randomcoder/udroid/MainActivity.kt

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.activity.ComponentActivity
1616
import androidx.activity.SystemBarStyle
1717
import androidx.activity.compose.setContent
1818
import androidx.activity.enableEdgeToEdge
19+
import androidx.activity.result.contract.ActivityResultContracts
1920
import androidx.compose.runtime.getValue
2021
import androidx.compose.runtime.mutableStateOf
2122
import androidx.compose.runtime.setValue
@@ -25,6 +26,8 @@ import androidx.lifecycle.lifecycleScope
2526
import kotlinx.coroutines.Dispatchers
2627
import kotlinx.coroutines.launch
2728
import kotlinx.coroutines.withContext
29+
import org.randomcoder.udroid.audio.AudioConfiguration
30+
import org.randomcoder.udroid.audio.AudioConfigurationStore
2831
import org.randomcoder.udroid.catalog.DistroCatalogRepository
2932
import org.randomcoder.udroid.catalog.DistroCatalogState
3033
import org.randomcoder.udroid.catalog.DistroVariant
@@ -111,6 +114,8 @@ class MainActivity : ComponentActivity() {
111114
mutableStateOf(DesktopConfiguration(null, compositingEnabled = false, touchScaleEnabled = true))
112115
private var desktopScanLoading by mutableStateOf(false)
113116
private var desktopScanMessage by mutableStateOf<String?>(null)
117+
private var audioConfiguration by mutableStateOf(AudioConfiguration())
118+
private var audioConfigurationMessage by mutableStateOf<String?>(null)
114119
private var linuxApplicationsState by
115120
mutableStateOf<LinuxApplicationsState>(LinuxApplicationsState.Loading)
116121
private var linuxApplicationMessage by mutableStateOf<String?>(null)
@@ -119,6 +124,7 @@ class MainActivity : ComponentActivity() {
119124
private var pendingDesktopStart: PendingDesktopStart? = null
120125
private var pendingShortcutRootfsName: String? = null
121126
private var pendingTerminalRootfsName: String? = null
127+
private var pendingMicrophoneRootfsName: String? = null
122128
private var linuxApplicationsLoadGeneration = 0L
123129
private var desktopScanGeneration = 0L
124130
private var ociCatalogueLoadGeneration = 0L
@@ -128,9 +134,33 @@ class MainActivity : ComponentActivity() {
128134
private var runtimeService by mutableStateOf<RuntimeSupervisorService?>(null)
129135
private var runtimeServiceBound = false
130136
private val desktopConfigurationStore by lazy { DesktopConfigurationStore(this) }
137+
private val audioConfigurationStore by lazy { AudioConfigurationStore(this) }
131138
private val ociHubCatalogueRepository by lazy { OciHubCatalogRepository(this) }
132139
private val ociHubTagRepository by lazy { OciHubTagRepository(this) }
133140

141+
private val microphonePermissionLauncher =
142+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
143+
val rootfsName = pendingMicrophoneRootfsName
144+
pendingMicrophoneRootfsName = null
145+
if (rootfsName == null) return@registerForActivityResult
146+
if (granted) {
147+
val current = audioConfigurationStore.load(rootfsName)
148+
saveAudioConfiguration(
149+
rootfsName,
150+
current.copy(microphoneEnabled = true),
151+
)
152+
} else {
153+
if (selectedSystemRootfsName == rootfsName) {
154+
audioConfiguration =
155+
audioConfigurationStore
156+
.load(rootfsName)
157+
.copy(microphoneEnabled = false)
158+
audioConfigurationMessage =
159+
"Microphone access was not granted. Linux input remains off."
160+
}
161+
}
162+
}
163+
134164
private val runtimeServiceConnection =
135165
object : ServiceConnection {
136166
override fun onServiceConnected(
@@ -193,6 +223,8 @@ class MainActivity : ComponentActivity() {
193223
desktopConfiguration = desktopConfiguration,
194224
desktopScanLoading = desktopScanLoading,
195225
desktopScanMessage = desktopScanMessage,
226+
audioConfiguration = audioConfiguration,
227+
audioConfigurationMessage = audioConfigurationMessage,
196228
linuxApplicationsState = linuxApplicationsState,
197229
linuxApplicationMessage = linuxApplicationMessage,
198230
showInstallTerminal = showInstallTerminal,
@@ -226,6 +258,8 @@ class MainActivity : ComponentActivity() {
226258
onSelectDesktopEnvironment = { selectDesktopEnvironment(it) },
227259
onCompositingChanged = { updateCompositing(it) },
228260
onTouchScaleChanged = { updateTouchScale(it) },
261+
onAudioOutputChanged = { updateAudioOutput(it) },
262+
onMicrophoneChanged = { updateMicrophone(it) },
229263
onStartDesktop = { startSelectedDesktop() },
230264
onStopDesktop = { runtimeService?.stopDesktop() },
231265
onRestartDesktop = { restartSelectedDesktop() },
@@ -606,6 +640,7 @@ class MainActivity : ComponentActivity() {
606640
if (installedRootfses.none { it.name == rootfsName }) return
607641
selectedSystemRootfsName = rootfsName
608642
rootfsMaintenanceMessage = null
643+
loadAudioConfiguration(rootfsName)
609644
selectDestination(UdroidDestination.SYSTEM)
610645
}
611646

@@ -679,6 +714,9 @@ class MainActivity : ComponentActivity() {
679714
runCatching { desktopConfigurationStore.remove(rootfsName) }
680715
.exceptionOrNull()
681716
?.let { cleanupWarnings += it.message ?: "desktop settings" }
717+
runCatching { audioConfigurationStore.remove(rootfsName) }
718+
.exceptionOrNull()
719+
?.let { cleanupWarnings += it.message ?: "audio settings" }
682720
runCatching {
683721
LinuxApplicationShortcutPublisher(this@MainActivity)
684722
.disableForRootfs(rootfsName)
@@ -850,6 +888,86 @@ class MainActivity : ComponentActivity() {
850888
)
851889
}
852890

891+
private fun loadAudioConfiguration(rootfsName: String) {
892+
runCatching { audioConfigurationStore.load(rootfsName) }
893+
.onSuccess {
894+
audioConfiguration = it
895+
audioConfigurationMessage = null
896+
}.onFailure {
897+
audioConfiguration = AudioConfiguration()
898+
audioConfigurationMessage = it.message ?: "Could not load audio settings"
899+
}
900+
}
901+
902+
private fun updateAudioOutput(enabled: Boolean) {
903+
val rootfsName = selectedSystemRootfsName ?: return
904+
saveAudioConfiguration(
905+
rootfsName,
906+
audioConfiguration.copy(outputEnabled = enabled),
907+
)
908+
}
909+
910+
private fun updateMicrophone(enabled: Boolean) {
911+
val rootfsName = selectedSystemRootfsName ?: return
912+
if (!enabled) {
913+
pendingMicrophoneRootfsName = null
914+
saveAudioConfiguration(
915+
rootfsName,
916+
audioConfiguration.copy(microphoneEnabled = false),
917+
)
918+
return
919+
}
920+
if (
921+
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
922+
PackageManager.PERMISSION_GRANTED
923+
) {
924+
saveAudioConfiguration(
925+
rootfsName,
926+
audioConfiguration.copy(microphoneEnabled = true),
927+
)
928+
return
929+
}
930+
pendingMicrophoneRootfsName = rootfsName
931+
audioConfigurationMessage = "Allow microphone access to send input to Linux."
932+
microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
933+
}
934+
935+
private fun saveAudioConfiguration(
936+
rootfsName: String,
937+
configuration: AudioConfiguration,
938+
) {
939+
runCatching { audioConfigurationStore.save(rootfsName, configuration) }
940+
.onSuccess { saved ->
941+
val service = runtimeService
942+
val runningThisRootfs =
943+
service?.currentTerminalSession()?.isRunning == true &&
944+
service.currentTerminalSession()?.mSessionName == rootfsName
945+
if (selectedSystemRootfsName == rootfsName) {
946+
audioConfiguration = saved
947+
audioConfigurationMessage =
948+
if (runningThisRootfs) {
949+
"Applying audio settings to the running Linux system…"
950+
} else {
951+
"Audio settings will apply when this Linux system starts."
952+
}
953+
}
954+
if (runningThisRootfs) {
955+
service.applyAudioConfiguration(rootfsName, saved) { result ->
956+
if (selectedSystemRootfsName != rootfsName) return@applyAudioConfiguration
957+
audioConfigurationMessage =
958+
result.fold(
959+
onSuccess = { it.message },
960+
onFailure = {
961+
it.message ?: "Could not apply audio settings"
962+
},
963+
)
964+
}
965+
}
966+
}.onFailure {
967+
audioConfigurationMessage = it.message ?: "Could not save audio settings"
968+
}
969+
}
970+
853971
private fun saveDesktopConfiguration(
854972
rootfsName: String,
855973
configuration: DesktopConfiguration,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package org.randomcoder.udroid.audio
2+
3+
import android.content.Context
4+
5+
data class AudioConfiguration(
6+
val outputEnabled: Boolean = true,
7+
val microphoneEnabled: Boolean = false,
8+
)
9+
10+
class AudioConfigurationStore(context: Context) {
11+
private val preferences =
12+
context.applicationContext
13+
.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
14+
15+
fun load(rootfsName: String): AudioConfiguration =
16+
AudioConfiguration(
17+
outputEnabled =
18+
preferences.getBoolean(key(rootfsName, KEY_OUTPUT), true),
19+
microphoneEnabled =
20+
preferences.getBoolean(key(rootfsName, KEY_MICROPHONE), false),
21+
)
22+
23+
fun save(
24+
rootfsName: String,
25+
configuration: AudioConfiguration,
26+
): AudioConfiguration {
27+
check(
28+
preferences
29+
.edit()
30+
.putBoolean(key(rootfsName, KEY_OUTPUT), configuration.outputEnabled)
31+
.putBoolean(
32+
key(rootfsName, KEY_MICROPHONE),
33+
configuration.microphoneEnabled,
34+
).commit(),
35+
) {
36+
"Could not save audio settings for $rootfsName"
37+
}
38+
return configuration
39+
}
40+
41+
fun remove(rootfsName: String) {
42+
val prefix = "$rootfsName:"
43+
val editor = preferences.edit()
44+
preferences.all.keys
45+
.filter { it.startsWith(prefix) }
46+
.forEach(editor::remove)
47+
check(editor.commit()) {
48+
"Could not clear audio settings for $rootfsName"
49+
}
50+
}
51+
52+
private fun key(
53+
rootfsName: String,
54+
setting: String,
55+
): String = "$rootfsName:$setting"
56+
57+
private companion object {
58+
const val PREFERENCES_NAME = "audio-configuration"
59+
const val KEY_OUTPUT = "output"
60+
const val KEY_MICROPHONE = "microphone"
61+
}
62+
}

0 commit comments

Comments
 (0)