diff --git a/app/src/main/assets/pulseaudio-gamenative-20260612.tzst b/app/src/main/assets/pulseaudio-gamenative-20260612.tzst deleted file mode 100644 index 7cdea4405f..0000000000 Binary files a/app/src/main/assets/pulseaudio-gamenative-20260612.tzst and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-gamenative-20260810.tzst b/app/src/main/assets/pulseaudio-gamenative-20260810.tzst new file mode 100644 index 0000000000..1f23216032 Binary files /dev/null and b/app/src/main/assets/pulseaudio-gamenative-20260810.tzst differ diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index c8ef8d9757..7946b8a986 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.ComponentCallbacks2 import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo +import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Color.TRANSPARENT import android.hardware.input.InputManager @@ -27,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import coil.ImageLoader import coil.disk.DiskCache @@ -58,6 +60,7 @@ import com.posthog.PostHog import com.skydoves.landscapist.coil.LocalCoilImageLoader import com.winlator.core.AppUtils import com.winlator.inputcontrols.ControllerManager +import com.winlator.xenvironment.components.PulseAudioComponent import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import java.util.EnumSet @@ -227,15 +230,51 @@ class MainActivity : ComponentActivity() { setContent { var hasNotificationPermission by remember { mutableStateOf(false) } + + // Games on the pulseaudio driver otherwise only get AAudioSink.monitor as a + // recording device, which is a loopback of their own output. Asked for here at + // startup rather than from XServerScreen: raising a system dialog once that + // screen is up backgrounds the activity while the container is still booting, + // which both suspends the environment right after setup and widens the window + // where the activity can be destroyed before the renderer view exists. + val micPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + Timber.i("RECORD_AUDIO permission granted: $isGranted") + if (isGranted) { + // No container is normally running this early, so this is usually a + // no-op; it only matters if the prompt is answered after one started. + PluviaApp.xEnvironment + ?.getComponent(PulseAudioComponent::class.java) + ?.enableMicrophone() + } + } + + // Requested only after the notification prompt resolves - the platform drops a + // permission request made while another dialog is still up. + fun requestMicrophoneIfNeeded() { + if (!BuildConfig.MODERN_XR && + ContextCompat.checkSelfPermission( + this@MainActivity, + Manifest.permission.RECORD_AUDIO, + ) != PackageManager.PERMISSION_GRANTED + ) { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { isGranted -> hasNotificationPermission = isGranted + requestMicrophoneIfNeeded() } LaunchedEffect(Unit) { if (!BuildConfig.MODERN_XR && !hasNotificationPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + requestMicrophoneIfNeeded() } } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index c713b9b97f..a1af89919c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -5002,7 +5002,7 @@ private suspend fun applyGeneralPatches( private fun refreshComponentsFiles(context: Context) { val extractionPairs = listOf( - "pulseaudio-gamenative-20260612.tzst" to File(context.filesDir, "pulseaudio") + "pulseaudio-gamenative-20260810.tzst" to File(context.filesDir, "pulseaudio") ) AssetUtils.extractComponentsWithVersionCheck( diff --git a/app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java b/app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java index d83f4f9ade..c3e85c8b12 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java @@ -1,6 +1,8 @@ package com.winlator.xenvironment.components; +import android.Manifest; import android.content.Context; +import android.content.pm.PackageManager; import com.winlator.core.AppUtils; import com.winlator.core.FileUtils; @@ -41,12 +43,14 @@ public class PulseAudioComponent extends EnvironmentComponent { private final UnixSocketConfig socketConfig; private final String SINK_NAME = "AAudioSink"; + private final String SOURCE_NAME = "AAudioSource"; private float volume = 1.0f; private byte performanceMode = 1; private final AtomicBoolean isPauseResumeRunning = new AtomicBoolean(false); private final AtomicBoolean isPaused = new AtomicBoolean(false); private boolean lowLatency = false; + private boolean micEnabled = false; private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); @@ -171,10 +175,27 @@ private void startPulseAudio() { if (lowLatency) { sinkParams += " low_latency=true"; } - FileUtils.writeString(configFile, String.join("\n", + // Without a capture source the only recording device PulseAudio offers is + // AAudioSink.monitor, which Wine hands to games as a microphone - so a game with + // voice chat ends up transmitting its own output back into the lobby. Load a real + // source when we are allowed to; module-aaudio-source makes itself the default + // source, which is what actually outranks the monitor. + String config = String.join("\n", "load-module module-native-protocol-unix auth-anonymous=1 auth-cookie-enabled=false socket=\""+socketConfig.path+"\"", "load-module module-aaudio-sink " + sinkParams - )); + ); + + if (hasMicrophonePermission(context)) { + config += "\nload-module module-aaudio-source source_name=" + SOURCE_NAME; + micEnabled = true; + } else { + // Not fatal: the daemon runs with --fail=false and simply comes up without a + // capture device, which is the behaviour before this change. + Timber.tag("PulseAudioComponent").i("RECORD_AUDIO not granted, starting without a capture source"); + micEnabled = false; + } + + FileUtils.writeString(configFile, config); String archName = AppUtils.getArchName(); File modulesDir = new File(workingDir, "modules"); @@ -221,12 +242,87 @@ private String execPactlCommand(String command) { return ProcessHelper.execWithOutput(workingDir + "/pactl " + command, envVars.toStringArray(), workingDir, true, 5); } - private boolean updateSink(boolean suspend) { - if (!suspend) { - return !execPactlCommand("suspend-sink " + SINK_NAME + " false").toLowerCase().contains("process timeout"); - } else { - return !execPactlCommand("suspend-sink " + SINK_NAME + " true").toLowerCase().contains("process timeout"); + private boolean hasMicrophonePermission(Context context) { + return context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + /** Suspend or resume the capture source, if one was loaded. No-op otherwise. */ + private void updateSource(boolean suspend) { + if (!micEnabled) return; + + String result = execPactlCommand("suspend-source " + SOURCE_NAME + " " + (suspend ? "true" : "false")) + .toLowerCase(); + + // pactl reports a missing source as "Failure: No such entity" on stderr, which + // execPactlCommand captures, so check for that as well as a timeout. + if (result.contains("failure") || result.contains("process timeout")) { + Timber.tag("PulseAudioComponent").w("Failed to %s source %s: %s", + suspend ? "suspend" : "resume", SOURCE_NAME, result.trim()); + + if (result.contains("no such entity")) { + // The module never loaded - most likely the pulseaudio asset predates + // module-aaudio-source. Stop issuing suspend-source rather than warning + // on every pause for the rest of the session. + Timber.tag("PulseAudioComponent").w("Capture source absent, disabling source suspend handling"); + micEnabled = false; + } } } + /** + * Load the capture source into an already running daemon. + * + * The daemon reads default.pa exactly once, when it spawns. If the user grants + * RECORD_AUDIO after that - which is the normal case on a first launch, since the + * permission dialog is answered while the container is still booting - there would + * otherwise be no capture device until the game is relaunched. Loading the module at + * runtime avoids that. + */ + public void enableMicrophone() { + if (singleThreadExecutor.isShutdown()) return; + + singleThreadExecutor.execute(() -> { + if (micEnabled) return; + + Context context = environment.getContext(); + if (!hasMicrophonePermission(context)) return; + + // On success pactl prints the new module index. ProcessHelper reports a failed + // exec as "Error: ..." and yields an empty string if nothing was captured, so + // treat both as failures rather than assuming the module loaded. + String result = execPactlCommand("load-module module-aaudio-source source_name=" + SOURCE_NAME); + String lower = result.toLowerCase().trim(); + if (lower.isEmpty() || lower.startsWith("error") || lower.contains("failure") + || lower.contains("process timeout")) { + Timber.tag("PulseAudioComponent").w("Failed to load capture source at runtime: %s", result.trim()); + return; + } + + micEnabled = true; + Timber.tag("PulseAudioComponent").i("Capture source loaded after permission grant"); + + // The permission dialog itself can pause the game, so the source may be + // arriving while audio is already suspended. Leaving it running would hold + // the microphone open until the next pause/resume cycle. + if (isPaused.get()) { + Timber.tag("PulseAudioComponent").d("Audio is paused, suspending the new capture source"); + updateSource(true); + } + }); + } + + private boolean updateSink(boolean suspend) { + String state = suspend ? "true" : "false"; + boolean sinkUpdated = !execPactlCommand("suspend-sink " + SINK_NAME + " " + state) + .toLowerCase().contains("process timeout"); + + // The capture source has to follow the sink. A suspended game must not keep + // holding the microphone open, or the Android privacy indicator stays lit and + // Android 14+ background-capture restrictions apply. A source failure is logged + // rather than blocking the pause/resume transition, since the source is optional. + updateSource(suspend); + + return sinkUpdated; + } + }