Skip to content
Draft
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
Binary file not shown.
Binary file not shown.
39 changes: 39 additions & 0 deletions app/src/main/java/app/gamenative/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the capture source is loaded at runtime while audio is already paused (the permission dialog can pause the activity, so this is a real path), load-module module-aaudio-source first brings the source up active and only afterwards is it suspended by a separate best-effort updateSource(true) call. Because loading an active source while the game/activity is suspended is exactly the background-capture the PR is trying to avoid, there is a brief window where the mic/privacy indicator is open during a paused state; and if the follow-up suspend fails (the suspend command is optional and only logged), the freshly loaded source stays active while the game remains paused for the rest of the session. Consider loading the module directly into a suspended state (if module-aaudio-source supports it) or treating a failed suspend after a successful load as a reason to unload the module again, so the paused/background guarantee holds. This is a minor edge concern given the explicitly non-blocking source handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java, line 309:

<comment>When the capture source is loaded at runtime while audio is already paused (the permission dialog can pause the activity, so this is a real path), `load-module module-aaudio-source` first brings the source up active and only afterwards is it suspended by a separate best-effort `updateSource(true)` call. Because loading an active source while the game/activity is suspended is exactly the background-capture the PR is trying to avoid, there is a brief window where the mic/privacy indicator is open during a paused state; and if the follow-up suspend fails (the suspend command is optional and only logged), the freshly loaded source stays active while the game remains paused for the rest of the session. Consider loading the module directly into a suspended state (if module-aaudio-source supports it) or treating a failed suspend after a successful load as a reason to unload the module again, so the paused/background guarantee holds. This is a minor edge concern given the explicitly non-blocking source handling.</comment>

<file context>
@@ -264,13 +287,26 @@ public void enableMicrophone() {
+            // 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);
             }
         });
</file context>

}
});
}

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;
}

}