diff --git a/app/build.gradle b/app/build.gradle index 12c190c14..9a83a7dea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { applicationId "com.winnative.cmod" minSdk 26 targetSdk 28 - versionCode 1 + versionCode 2 versionName appVersionName externalNativeBuild { @@ -183,15 +183,10 @@ android { excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" } jniLibs { + // PulseAudio runs libpulseaudio.so directly from nativeLibraryDir and loads + // libpulse*/libltdl/libsndfile as its dependencies, so these must be packaged + // and extracted to disk (useLegacyPackaging) rather than excluded. useLegacyPackaging = true - excludes += [ - "lib/arm64-v8a/libltdl.so", - "lib/arm64-v8a/libpulseaudio.so", - "lib/arm64-v8a/libpulse.so", - "lib/arm64-v8a/libpulsecommon-13.0.so", - "lib/arm64-v8a/libpulsecore-13.0.so", - "lib/arm64-v8a/libsndfile.so" - ] } } diff --git a/app/src/main/assets/pulseaudio-bin/libltdl.so b/app/src/main/assets/pulseaudio-bin/libltdl.so deleted file mode 100644 index 48b910a2a..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libltdl.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-bin/libpulse.so b/app/src/main/assets/pulseaudio-bin/libpulse.so deleted file mode 100644 index bc8b723e0..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libpulse.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-bin/libpulseaudio.so b/app/src/main/assets/pulseaudio-bin/libpulseaudio.so deleted file mode 100644 index 59a044255..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libpulseaudio.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-bin/libpulsecommon-13.0.so b/app/src/main/assets/pulseaudio-bin/libpulsecommon-13.0.so deleted file mode 100644 index ce2e949f5..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libpulsecommon-13.0.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-bin/libpulsecore-13.0.so b/app/src/main/assets/pulseaudio-bin/libpulsecore-13.0.so deleted file mode 100644 index 031ff579f..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libpulsecore-13.0.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio-bin/libsndfile.so b/app/src/main/assets/pulseaudio-bin/libsndfile.so deleted file mode 100644 index dcf49b7fa..000000000 Binary files a/app/src/main/assets/pulseaudio-bin/libsndfile.so and /dev/null differ diff --git a/app/src/main/assets/pulseaudio.tzst b/app/src/main/assets/pulseaudio.tzst index 076a47a3e..7cdea4405 100644 Binary files a/app/src/main/assets/pulseaudio.tzst and b/app/src/main/assets/pulseaudio.tzst differ diff --git a/app/src/main/jniLibs/arm64-v8a/libltdl.so b/app/src/main/jniLibs/arm64-v8a/libltdl.so index 48b910a2a..5cd08c89d 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libltdl.so and b/app/src/main/jniLibs/arm64-v8a/libltdl.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libpulse.so b/app/src/main/jniLibs/arm64-v8a/libpulse.so index bc8b723e0..3443f4d4c 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libpulse.so and b/app/src/main/jniLibs/arm64-v8a/libpulse.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libpulseaudio.so b/app/src/main/jniLibs/arm64-v8a/libpulseaudio.so index 59a044255..bccfd71c8 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libpulseaudio.so and b/app/src/main/jniLibs/arm64-v8a/libpulseaudio.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libpulsecommon-13.0.so b/app/src/main/jniLibs/arm64-v8a/libpulsecommon-13.0.so index ce2e949f5..f09b73d0f 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libpulsecommon-13.0.so and b/app/src/main/jniLibs/arm64-v8a/libpulsecommon-13.0.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libpulsecore-13.0.so b/app/src/main/jniLibs/arm64-v8a/libpulsecore-13.0.so index 031ff579f..9f932277b 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libpulsecore-13.0.so and b/app/src/main/jniLibs/arm64-v8a/libpulsecore-13.0.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libsndfile.so b/app/src/main/jniLibs/arm64-v8a/libsndfile.so index dcf49b7fa..f7ab9d12b 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libsndfile.so and b/app/src/main/jniLibs/arm64-v8a/libsndfile.so differ diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index fce78b5d0..cf4a7d7f4 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -258,6 +258,7 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private boolean controllerAutoHidden = false; private boolean userOverrodeAutoHide = false; private XEnvironment environment; + private com.winlator.cmod.runtime.display.environment.AudioFocusHandler audioFocusHandler; private ComposeView displayHostComposeView; private FrameLayout xServerDisplayFrame; private ContainerManager containerManager; @@ -2341,6 +2342,15 @@ private int[] getCapturedPointerDelta(MotionEvent event) { }; } + private void ensureAudioFocusHandler() { + if (audioFocusHandler != null) return; + audioFocusHandler = + new com.winlator.cmod.runtime.display.environment.AudioFocusHandler( + this, + () -> { if (environment != null) environment.onPause(); }, + () -> { if (environment != null) environment.onResume(); }); + } + @Override public void onResume() { super.onResume(); @@ -2353,6 +2363,8 @@ public void onResume() { if (!cleaningUp && environment != null) { xServerView.onResume(); environment.onResume(); + ensureAudioFocusHandler(); + if (audioFocusHandler != null) audioFocusHandler.request(); } if (inputControlsView != null && touchpadView != null) { @@ -3748,6 +3760,10 @@ protected void onDestroy() { externalDisplayController.release(); externalDisplayController = null; } + if (audioFocusHandler != null) { + audioFocusHandler.release(); + audioFocusHandler = null; + } if (isDependencyInstall) { com.winlator.cmod.runtime.content.component.DependencyInstallBridge.complete(dependencyExitStatus); } diff --git a/app/src/main/runtime/display/environment/AudioFocusHandler.java b/app/src/main/runtime/display/environment/AudioFocusHandler.java new file mode 100644 index 000000000..d9bb1afd1 --- /dev/null +++ b/app/src/main/runtime/display/environment/AudioFocusHandler.java @@ -0,0 +1,107 @@ +package com.winlator.cmod.runtime.display.environment; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.util.Log; + +/** + * Complements the activity lifecycle for suspending/resuming guest audio around interruptions that + * do not cleanly pause the activity — most importantly incoming phone calls and VoIP calls. + * + *
The activity's onPause/onResume already suspend/resume audio, but a phone call arrives as a + * transient audio-focus loss and, when it ends, we get a precise focus-gain callback which is a more + * reliable "the call is over, restore audio now" signal than waiting for the activity to be brought + * back to the foreground. The suspend/resume callbacks here are idempotent with the lifecycle ones, + * so firing from both paths is safe. + * + *
Focus callbacks are delivered on the main thread; the suspend/resume work touches unix sockets, + * so it is dispatched to a background thread to keep the UI responsive. + */ +public class AudioFocusHandler { + private static final String TAG = "AudioFocusHandler"; + + private final AudioManager audioManager; + private final Runnable onSuspend; + private final Runnable onResume; + private final AudioFocusRequest focusRequest; + + private boolean requested = false; + private boolean suspendedByFocusLoss = false; + + public AudioFocusHandler(Context context, Runnable onSuspend, Runnable onResume) { + this.audioManager = (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + this.onSuspend = onSuspend; + this.onResume = onResume; + + AudioAttributes attributes = + new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + this.focusRequest = + new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attributes) + .setWillPauseWhenDucked(false) + .setOnAudioFocusChangeListener(this::onFocusChange) + .build(); + } + + /** Requests audio focus so we start receiving focus-change callbacks. Safe to call repeatedly. */ + public synchronized void request() { + if (audioManager == null || requested) return; + try { + int result = audioManager.requestAudioFocus(focusRequest); + requested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } catch (Exception e) { + Log.w(TAG, "requestAudioFocus failed: " + e.getMessage()); + } + } + + /** Abandons audio focus and stops callbacks. Call on teardown. */ + public synchronized void release() { + if (audioManager == null || !requested) return; + try { + audioManager.abandonAudioFocusRequest(focusRequest); + } catch (Exception e) { + Log.w(TAG, "abandonAudioFocus failed: " + e.getMessage()); + } + requested = false; + suspendedByFocusLoss = false; + } + + private void onFocusChange(int focusChange) { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + // Phone call / another app took over playback. Release the AAudio device. + synchronized (this) { + suspendedByFocusLoss = true; + } + dispatch(onSuspend); + break; + case AudioManager.AUDIOFOCUS_GAIN: + // Interruption ended; restore output. Only if we were the ones who suspended, so we + // don't fight a resume the activity lifecycle is already driving. + boolean shouldResume; + synchronized (this) { + shouldResume = suspendedByFocusLoss; + suspendedByFocusLoss = false; + } + if (shouldResume) dispatch(onResume); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + default: + // Ducking / unknown: leave playback alone. + break; + } + } + + private static void dispatch(Runnable task) { + if (task == null) return; + Thread thread = new Thread(task, "AudioFocusDispatch"); + thread.setDaemon(true); + thread.start(); + } +} diff --git a/app/src/main/runtime/display/environment/XEnvironment.java b/app/src/main/runtime/display/environment/XEnvironment.java index f95974f9a..ea34ccd1c 100644 --- a/app/src/main/runtime/display/environment/XEnvironment.java +++ b/app/src/main/runtime/display/environment/XEnvironment.java @@ -140,8 +140,8 @@ public void onPause() { } public void onResume() { - ALSAClient.setOutputSuspended(false); PulseAudioComponent pulseAudio = getComponent(PulseAudioComponent.class); if (pulseAudio != null) pulseAudio.resume(); + ALSAClient.setOutputSuspended(false); } } diff --git a/app/src/main/runtime/display/environment/components/PulseAudioComponent.java b/app/src/main/runtime/display/environment/components/PulseAudioComponent.java index fbbf06b97..e30cd7b47 100644 --- a/app/src/main/runtime/display/environment/components/PulseAudioComponent.java +++ b/app/src/main/runtime/display/environment/components/PulseAudioComponent.java @@ -1,28 +1,48 @@ package com.winlator.cmod.runtime.display.environment.components; import android.content.Context; -import android.media.AudioManager; -import android.os.Process; +import android.util.Log; import com.winlator.cmod.runtime.display.connector.UnixSocketConfig; import com.winlator.cmod.runtime.display.environment.EnvironmentComponent; import com.winlator.cmod.runtime.system.ProcessHelper; import com.winlator.cmod.runtime.wine.EnvVars; -import com.winlator.cmod.shared.android.AppUtils; import com.winlator.cmod.shared.io.FileUtils; +import java.io.BufferedReader; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +/** + * PulseAudio server management. + * + *
This mirrors the GameNative PulseAudio stack, which is the coherent, maintained build that + * reliably resumes audio after interruptions such as phone calls. The pieces that make resume work + * live in the native binaries, not here: + * + *
Pause/resume simply suspend and un-suspend the sink via {@code pactl suspend-sink}; the module
+ * closes the AAudio device on suspend and reopens it on resume.
+ */
public class PulseAudioComponent extends EnvironmentComponent {
+ private static final String TAG = "PulseAudioComponent";
+ private static final String SINK_NAME = "AAudioSink";
+
private final UnixSocketConfig socketConfig;
private final Options options;
- private static int pid = -1;
private static final Object lock = new Object();
+ private boolean isPaused = false;
public PulseAudioComponent(UnixSocketConfig socketConfig) {
this(socketConfig, new Options());
@@ -34,7 +54,7 @@ public PulseAudioComponent(UnixSocketConfig socketConfig, Options options) {
}
public static class Options {
- public static final int DEFAULT_LATENCY_MILLIS = 144;
+ public static final int DEFAULT_LATENCY_MILLIS = 40;
public static final int DEFAULT_FRAGMENT_MILLIS = 10;
public static final int DEFAULT_SAMPLE_RATE = 48000;
public static final int DEFAULT_ALTERNATE_SAMPLE_RATE = 44100;
@@ -156,221 +176,176 @@ private static float parseFloat(String value, float fallback) {
@Override
public void start() {
synchronized (lock) {
- stop();
- pid = execPulseAudio();
+ if (!isServerRunning()) {
+ killAllPulseAudioProcesses();
+ startPulseAudio();
+ isPaused = false;
+ }
}
}
@Override
public void stop() {
synchronized (lock) {
- if (pid != -1) {
- Process.killProcess(pid);
- pid = -1;
- }
+ updateSink(true);
+ isPaused = false;
+ killAllPulseAudioProcesses();
}
}
public void suspend() {
synchronized (lock) {
- if (pid != -1) ProcessHelper.suspendProcess(pid);
+ if (!isPaused && isServerRunning()) {
+ isPaused = true;
+ updateSink(true);
+ }
}
}
public void resume() {
synchronized (lock) {
- if (pid != -1) ProcessHelper.resumeProcess(pid);
+ if (isPaused) {
+ if (isServerRunning()) {
+ isPaused = false;
+ updateSink(false);
+ } else {
+ // Daemon died while backgrounded; relaunch it. default.pa re-creates the sink.
+ start();
+ }
+ }
}
}
- private void copyFromLibraryDir(File dst) {
- String[] libs =
- new String[] {
- "libltdl.so",
- "libpulseaudio.so",
- "libpulse.so",
- "libpulsecommon-13.0.so",
- "libpulsecore-13.0.so",
- "libsndfile.so"
- };
- for (int i = 0; i < libs.length; i++) {
- Path dstDir = Paths.get(dst.getAbsolutePath() + "/" + libs[i]);
- try (InputStream is =
- environment.getContext().getAssets().open("pulseaudio-bin/" + libs[i])) {
- if (is != null) {
- Files.copy(is, dstDir, StandardCopyOption.REPLACE_EXISTING);
- FileUtils.chmod(dstDir.toFile(), 0771);
+ public boolean isServerRunning() {
+ String info = execPactlCommand("info").toLowerCase(java.util.Locale.ROOT);
+ return info.contains("server name:") && !info.contains("connection failure");
+ }
+
+ private void updateSink(boolean suspend) {
+ execPactlCommand("suspend-sink " + SINK_NAME + " " + (suspend ? "true" : "false"));
+ }
+
+ private void killAllPulseAudioProcesses() {
+ File proc = new File("/proc");
+ String[] allPids =
+ proc.list((dir, name) -> new File(dir, name).isDirectory() && name.matches("[0-9]+"));
+ if (allPids == null) return;
+ boolean killed = false;
+ for (String pidStr : allPids) {
+ String cmdline = readProcCmdline(pidStr);
+ if (cmdline.contains("libpulseaudio.so")) {
+ try {
+ ProcessHelper.killProcess(Integer.parseInt(pidStr));
+ killed = true;
+ } catch (NumberFormatException ignored) {
}
- } catch (IOException e) {
- throw new RuntimeException(e);
+ }
+ }
+ if (killed) {
+ try {
+ Thread.sleep(200);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
}
}
}
- private int execPulseAudio() {
+ private static String readProcCmdline(String pid) {
+ try (FileInputStream fr = new FileInputStream("/proc/" + pid + "/cmdline")) {
+ byte[] bytes = fr.readAllBytes();
+ return new String(bytes, StandardCharsets.UTF_8).replace('\0', ' ');
+ } catch (IOException e) {
+ return "";
+ }
+ }
+
+ private void startPulseAudio() {
Context context = environment.getContext();
+ String nativeLibraryDir = context.getApplicationInfo().nativeLibraryDir;
+
File workingDir = new File(context.getFilesDir(), "/pulseaudio");
if (!workingDir.isDirectory()) {
workingDir.mkdirs();
FileUtils.chmod(workingDir, 0771);
}
- File configDir = new File(workingDir, ".config/pulse");
- if (!configDir.isDirectory()) configDir.mkdirs();
- File runtimeDir = new File(workingDir, "run");
- if (!runtimeDir.isDirectory()) runtimeDir.mkdirs();
+ // Clear any stale runtime state (e.g. cookie) from a previous run.
+ File configDir = new File(workingDir, ".config");
+ if (configDir.exists()) FileUtils.delete(configDir);
- int sampleRate =
- options.sampleRateOverridden ? options.sampleRate : getNativeOutputSampleRate(context);
- int alternateSampleRate =
- options.alternateSampleRateOverridden
- ? options.alternateSampleRate
- : getAlternateSampleRate(sampleRate);
- String channelMap = getChannelMap(options.channels);
-
- File daemonConfigFile = new File(configDir, "daemon.conf");
- FileUtils.writeString(
- daemonConfigFile,
- String.join(
- "\n",
- "high-priority = yes",
- "realtime-scheduling = no",
- "flat-volumes = no",
- "enable-deferred-volume = no",
- "resample-method = speex-float-1",
- "avoid-resampling = yes",
- "default-sample-format = s16le",
- "default-sample-rate = " + sampleRate,
- "alternate-sample-rate = " + alternateSampleRate,
- "default-sample-channels = " + options.channels,
- "default-channel-map = " + channelMap,
- "default-fragments = 4",
- "default-fragment-size-msec = " + options.fragmentMillis,
- ""));
+ boolean lowLatency = Options.PERFORMANCE_MODE_LOW_LATENCY.equals(options.performanceMode);
+ String sinkParams = "volume=" + options.volume + " performance_mode=1";
+ if (lowLatency) sinkParams += " low_latency=true";
File configFile = new File(workingDir, "default.pa");
FileUtils.writeString(
configFile,
String.join(
"\n",
- "load-module module-native-protocol-unix auth-anonymous=1 auth-cookie-enabled=0 socket=\""
+ "load-module module-native-protocol-unix auth-anonymous=1 auth-cookie-enabled=false socket=\""
+ socketConfig.path
+ "\"",
- "load-module module-aaudio-sink sink_name=AAudioSink rate=" + sampleRate,
- "set-default-sink AAudioSink",
- "set-sink-volume AAudioSink " + pulseVolumeHex(options.volume),
+ "load-module module-aaudio-sink " + sinkParams,
""));
- String archName = AppUtils.getArchName();
- File modulesDir = new File(workingDir, "modules/" + archName);
- patchAAudioSinkPerformanceMode(modulesDir);
- String systemLibPath = archName.equals("arm64") ? "/system/lib64" : "/system/lib";
+ File modulesDir = new File(workingDir, "modules");
ArrayList