Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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
```

Expand Down
17 changes: 17 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://www.freedesktop.org/wiki/Software/PulseAudio/>
- PulseAudio version: `17.0`, Termux package revision `3`
- Binary package repository: <https://packages.termux.dev/apt/termux-main/>
- 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: <https://github.com/termux/proot-distro>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<android.content.Context>()
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()
}
}
}
5 changes: 4 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

Expand Down Expand Up @@ -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">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
118 changes: 118 additions & 0 deletions app/src/main/java/org/randomcoder/udroid/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
Expand All @@ -25,6 +26,8 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.randomcoder.udroid.audio.AudioConfiguration
import org.randomcoder.udroid.audio.AudioConfigurationStore
import org.randomcoder.udroid.catalog.DistroCatalogRepository
import org.randomcoder.udroid.catalog.DistroCatalogState
import org.randomcoder.udroid.catalog.DistroVariant
Expand Down Expand Up @@ -111,6 +114,8 @@ class MainActivity : ComponentActivity() {
mutableStateOf(DesktopConfiguration(null, compositingEnabled = false, touchScaleEnabled = true))
private var desktopScanLoading by mutableStateOf(false)
private var desktopScanMessage by mutableStateOf<String?>(null)
private var audioConfiguration by mutableStateOf(AudioConfiguration())
private var audioConfigurationMessage by mutableStateOf<String?>(null)
private var linuxApplicationsState by
mutableStateOf<LinuxApplicationsState>(LinuxApplicationsState.Loading)
private var linuxApplicationMessage by mutableStateOf<String?>(null)
Expand All @@ -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
Expand All @@ -128,9 +134,33 @@ class MainActivity : ComponentActivity() {
private var runtimeService by mutableStateOf<RuntimeSupervisorService?>(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(
Expand Down Expand Up @@ -193,6 +223,8 @@ class MainActivity : ComponentActivity() {
desktopConfiguration = desktopConfiguration,
desktopScanLoading = desktopScanLoading,
desktopScanMessage = desktopScanMessage,
audioConfiguration = audioConfiguration,
audioConfigurationMessage = audioConfigurationMessage,
linuxApplicationsState = linuxApplicationsState,
linuxApplicationMessage = linuxApplicationMessage,
showInstallTerminal = showInstallTerminal,
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -606,6 +640,7 @@ class MainActivity : ComponentActivity() {
if (installedRootfses.none { it.name == rootfsName }) return
selectedSystemRootfsName = rootfsName
rootfsMaintenanceMessage = null
loadAudioConfiguration(rootfsName)
selectDestination(UdroidDestination.SYSTEM)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading