Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package app.gamenative.ui.screen.xserver

import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.graphics.Color
import android.os.Build
Expand All @@ -25,6 +27,8 @@ import android.hardware.input.InputManager
import android.view.InputDevice
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import app.gamenative.BuildConfig
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
Expand Down Expand Up @@ -385,6 +389,34 @@ fun XServerScreen(
}
val activity = remember(context) { BrightnessManager.findActivity(context) }

// Games that open a recording device otherwise get AAudioSink.monitor, i.e. a loopback
// of their own output. Ask for the microphone here rather than at app startup so the
// prompt has context, and only for containers that actually use the pulseaudio driver.
// If it is denied, PulseAudioComponent simply starts without a capture source.
val micPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { isGranted ->
Timber.i("RECORD_AUDIO permission granted: $isGranted")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (isGranted) {
// The daemon reads default.pa once at spawn, and the container is usually
// already booting by the time this is answered, so load the capture source
// into the running daemon rather than making the user relaunch the game.
PluviaApp.xEnvironment
?.getComponent(PulseAudioComponent::class.java)
?.enableMicrophone()
}
}

LaunchedEffect(appId) {
if (!BuildConfig.MODERN_XR &&
container?.audioDriver == "pulseaudio" &&
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}

DisposableEffect(activity) {
if (activity == null) return@DisposableEffect onDispose { }

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,67 @@ private String execPactlCommand(String command) {
return ProcessHelper.execWithOutput(workingDir + "/pactl " + command, envVars.toStringArray(), workingDir, true, 5);
}

private boolean hasMicrophonePermission(Context context) {
return context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
}

/**
* 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;

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();
if (lower.contains("failure") || lower.contains("process timeout")) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Timber.tag("PulseAudioComponent").w("Failed to load capture source at runtime: %s", result.trim());
} else {
micEnabled = true;
Timber.tag("PulseAudioComponent").i("Capture source loaded after permission grant");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
});
}

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");
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. Failing to suspend the
// source is logged rather than blocking the pause/resume transition, since the
// source is optional.
if (micEnabled) {
String result = execPactlCommand("suspend-source " + SOURCE_NAME + " " + state).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;
}
}
}

return sinkUpdated;
}

}