fix(steam): preserve Back taps and recover QAM controls

This commit is contained in:
Kurt Himebauch committed 2026-10-26 13:13:08 -04:00
1 parent dde1a55d00
commit fa5506df35
13 files changed
+395 -56

No files matched your search

@@ -257,7 +257,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val cursorResync = Runnable { if (syncClientCursor() && clientHidesCursor) cursorVisible = false }
private val uiHandler = Handler(Looper.getMainLooper())
private var pendingBackAction: Runnable? = null
private val backQamInput = com.droiddeck.launcher.input.BackQamInput { padBridge?.setBackQamPressed(it) }
private val backQamInput = com.droiddeck.launcher.input.BackQamInput(
setPressed = { padBridge?.setBackQamPressed(it) },
onTap = { performBackAction(double = it) },
)
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
private var drawerDirectionDownTime = 0L
private var drawerDirectionDeviceId = -1
@@ -1,10 +1,21 @@
package com.droiddeck.launcher.input
import android.view.KeyEvent
import android.os.Handler
import android.os.Looper
class BackQamInput(private val setPressed: (Boolean) -> Unit) {
private val held = mutableSetOf<Int>()
class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: (Boolean) -> Unit) {
private class Press(val hold: Runnable, var held: Boolean = false, var used: Boolean = false)
private val handler = Handler(Looper.getMainLooper())
private val presses = mutableMapOf<Int, Press>()
private val cancelled = mutableSetOf<Int>()
private var tapPending = false
private val singleTap = Runnable { tapPending = false; onTap(false) }
private fun cancelTap() {
handler.removeCallbacks(singleTap)
tapPending = false
}
fun onKeyEvent(event: KeyEvent, enabled: Boolean): Boolean {
if (!enabled) cancel()
@@ -12,31 +23,54 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit) {
val device = event.deviceId
return when (event.action) {
KeyEvent.ACTION_DOWN -> {
if (event.repeatCount > 0) return enabled || device in held || device in cancelled
if (event.repeatCount > 0) return enabled || device in presses || device in cancelled
cancelled.remove(device)
if (!enabled || event.isCanceled) return false
if (held.add(device) && held.size == 1) setPressed(true)
if (device in presses) return true
handler.removeCallbacks(singleTap)
val hold = Runnable {
val press = presses[device] ?: return@Runnable
val alreadyHeld = presses.values.any { it.held }
press.held = true
presses.values.forEach { it.used = true }
cancelTap()
if (!alreadyHeld) setPressed(true)
}
presses[device] = Press(hold, used = presses.values.any { it.held })
handler.postDelayed(hold, 500)
true
}
KeyEvent.ACTION_UP -> {
val released = held.remove(device)
val press = presses.remove(device)
val wasCancelled = cancelled.remove(device)
if (released && held.isEmpty()) setPressed(false)
released || wasCancelled || enabled
if (press != null) {
handler.removeCallbacks(press.hold)
if (press.held && presses.values.none { it.held }) setPressed(false)
if (!press.used && !event.isCanceled) {
if (tapPending) { cancelTap(); onTap(true) }
else { tapPending = true; handler.postDelayed(singleTap, 500) }
} else if (event.isCanceled) cancelTap()
}
press != null || wasCancelled || enabled
}
else -> device in held || device in cancelled
else -> device in presses || device in cancelled
}
}
fun cancel() {
if (held.isEmpty()) return
cancelled.addAll(held)
held.clear()
setPressed(false)
cancelTap()
val wasHeld = presses.values.any { it.held }
presses.values.forEach { handler.removeCallbacks(it.hold) }
cancelled.addAll(presses.keys)
presses.clear()
if (wasHeld) setPressed(false)
}
fun onDeviceRemoved(device: Int) {
cancelled.remove(device)
if (held.remove(device) && held.isEmpty()) setPressed(false)
val press = presses.remove(device) ?: return
handler.removeCallbacks(press.hold)
cancelTap()
if (press.held && presses.values.none { it.held }) setPressed(false)
}
}
@@ -2,6 +2,10 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.content.Intent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.database.ContentObserver
import android.media.AudioManager
import android.net.Uri
@@ -12,19 +16,20 @@ import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.R
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import org.json.JSONObject
import kotlin.math.roundToInt
class DeviceControlsComponent(context: Context, root: File) : SessionPart() {
class DeviceControlsComponent(context: Context, root: File, private val activityVisible: () -> Boolean = { false }) : SessionPart() {
private val context = context.applicationContext
private val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val directory = directory(root)
private val handler = Handler(Looper.getMainLooper())
private var running = false
private var pendingBrightness: Pair<Float, Long>? = null
private var pendingBrightness: Float? = null
private var permissionRequestAt = -5_000L
private var previous = ""
private var revision = 0L
@@ -41,10 +46,10 @@ class DeviceControlsComponent(context: Context, root: File) : SessionPart() {
override fun run() {
if (!running) return
consume()
pendingBrightness?.let { (value, time) ->
if (SystemClock.elapsedRealtime() - time > 120_000) pendingBrightness = null
else if (Settings.System.canWrite(context)) {
pendingBrightness?.let { value ->
if (Settings.System.canWrite(context)) {
pendingBrightness = null
context.getSystemService(NotificationManager::class.java).cancel(PERMISSION_NOTIFICATION)
brightness(value)
}
}
@@ -94,6 +99,7 @@ class DeviceControlsComponent(context: Context, root: File) : SessionPart() {
requests.stopWatching()
context.contentResolver.unregisterContentObserver(observer)
pendingBrightness = null
context.getSystemService(NotificationManager::class.java).cancel(PERMISSION_NOTIFICATION)
}
}
@@ -128,13 +134,23 @@ class DeviceControlsComponent(context: Context, root: File) : SessionPart() {
private fun brightness(value: Float) {
if (!Settings.System.canWrite(context)) {
pendingBrightness = value to SystemClock.elapsedRealtime()
pendingBrightness = value
val now = SystemClock.elapsedRealtime()
if (now - permissionRequestAt >= 5_000) {
permissionRequestAt = now
try {
context.startActivity(Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
Uri.parse("package:${context.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
Uri.parse("package:${context.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(NotificationChannel(PERMISSION_CHANNEL,
context.getString(R.string.brightness_permission_title), NotificationManager.IMPORTANCE_DEFAULT))
val action = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
manager.notify(PERMISSION_NOTIFICATION, Notification.Builder(context, PERMISSION_CHANNEL)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(context.getString(R.string.brightness_permission_title))
.setContentText(context.getString(R.string.brightness_permission_text))
.setContentIntent(action).setOnlyAlertOnce(true).setAutoCancel(true).build())
if (activityVisible()) context.startActivity(intent)
} catch (e: Exception) {
Log.w(TAG, "Could not open brightness permission", e)
}
@@ -161,6 +177,8 @@ class DeviceControlsComponent(context: Context, root: File) : SessionPart() {
companion object {
private const val TAG = "DeviceControls"
private const val PERMISSION_CHANNEL = "brightness_permission"
private const val PERMISSION_NOTIFICATION = 0x4442
private val REQUESTS = setOf("brightness.request", "volume.request", "mute.request")
@JvmStatic fun directory(root: File) = File(root, "etc/droiddeck-device-controls")
@JvmStatic fun prepareBacklight(context: Context, root: File): File {
@@ -630,7 +630,7 @@ class SessionService : Service() {
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
val deviceControls = com.droiddeck.launcher.runtime.DeviceControlsComponent(this, root)
val deviceControls = com.droiddeck.launcher.runtime.DeviceControlsComponent(this, root) { activityVisible && screenOn }
deviceControls.attach(this)
deviceControls.publish()
components.add(deviceControls)
@@ -306,15 +306,16 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
stringResource(R.string.unfolded_controls_with_controller_hint),
s.unfoldedControlsWithController, onChange = a.onUnfoldedControlsWithController,
)
if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
Note(stringResource(R.string.back_native_qam_hint))
} else if (steam) ChoiceRow(
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
Note(stringResource(R.string.back_native_qam_hint))
}
}
}
if (tab == SheetTab.AUDIO && steam) {
@@ -604,11 +604,12 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
if (a.steam && a.padType in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
Note(stringResource(R.string.back_native_qam_hint))
} else if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
if (a.steam && a.padType in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
Note(stringResource(R.string.back_native_qam_hint))
}
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
+3 -1
View File
@@ -306,7 +306,9 @@
<string name="mode_controller_x360">Xbox 360 controller</string>
<string name="mode_controller_note">Deck: Quick Access button and gyro. Steam Controller: the same, with grip sense on the back buttons. DualShock 4 and DualSense: gyro, and the trackpads as their touchpad. Xbox 360: open Quick Access with Guide+A.</string>
<string name="mode_back">Back</string>
<string name="back_native_qam_hint">Back acts as Quick Access. Hold it with another button for Steam shortcuts.</string>
<string name="back_native_qam_hint">Hold Back for 500 ms, then press another button for Steam shortcuts. Single and double taps keep the selected actions.</string>
<string name="brightness_permission_title">Allow brightness control</string>
<string name="brightness_permission_text">Tap to allow Steam to change Android screen brightness.</string>
<string name="back_menu_then_qam">Menu → QAM</string>
<string name="back_qam_then_menu">QAM → Menu</string>
<string name="mode_session">Session</string>
@@ -1,28 +1,76 @@
package com.droiddeck.launcher.input
import android.os.Looper
import android.view.KeyEvent
import java.time.Duration
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], manifest = Config.NONE)
class BackQamInputTest {
private val changes = mutableListOf<Boolean>()
private val input = BackQamInput(changes::add)
private val taps = mutableListOf<Boolean>()
private val input = BackQamInput(changes::add, taps::add)
@Test fun aHoldAndItsRepeatsProduceOnePressAndOneRelease() {
@Test fun singleTapWaitsForTheDoubleTapWindowWithoutPressingQam() {
send(KeyEvent.ACTION_DOWN)
advance(100)
send(KeyEvent.ACTION_UP)
advance(499)
assertTrue(taps.isEmpty())
advance(1)
assertEquals(listOf(false), taps)
assertTrue(changes.isEmpty())
}
@Test fun doubleTapProducesOnlyTheDoubleAction() {
repeat(2) {
send(KeyEvent.ACTION_DOWN)
advance(100)
send(KeyEvent.ACTION_UP)
advance(100)
}
advance(1000)
assertEquals(listOf(true), taps)
assertTrue(changes.isEmpty())
}
@Test fun aHoldStartsAt500msAndNeverProducesATap() {
assertTrue(send(KeyEvent.ACTION_DOWN))
advance(499)
assertTrue(changes.isEmpty())
advance(1)
for (repeat in 1..10) assertTrue(send(KeyEvent.ACTION_DOWN, repeat = repeat))
advance(4000)
assertEquals(listOf(true), changes)
assertTrue(send(KeyEvent.ACTION_UP))
advance(1000)
assertEquals(listOf(true, false), changes)
assertTrue(taps.isEmpty())
}
@Test fun holdingTheSecondPressCancelsTheFirstTap() {
send(KeyEvent.ACTION_DOWN)
send(KeyEvent.ACTION_UP)
advance(400)
send(KeyEvent.ACTION_DOWN)
advance(499)
assertTrue(taps.isEmpty())
advance(1)
assertEquals(listOf(true), changes)
send(KeyEvent.ACTION_UP)
advance(1000)
assertTrue(taps.isEmpty())
}
@Test fun otherButtonsAndSelectPassThroughWhileBackIsHeld() {
send(KeyEvent.ACTION_DOWN)
advance(500)
for (code in listOf(KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_BUTTON_SELECT)) {
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
assertFalse(input.onKeyEvent(KeyEvent(action, code), true))
@@ -37,20 +85,42 @@ class BackQamInputTest {
assertFalse(send(KeyEvent.ACTION_DOWN, enabled = false))
assertFalse(send(KeyEvent.ACTION_UP, enabled = false))
assertTrue(changes.isEmpty())
assertTrue(taps.isEmpty())
}
@Test fun losingFocusReleasesQamAndConsumesTheLateRelease() {
send(KeyEvent.ACTION_DOWN)
advance(500)
input.cancel()
input.cancel()
assertTrue(send(KeyEvent.ACTION_DOWN, repeat = 1, enabled = false))
assertTrue(send(KeyEvent.ACTION_UP, enabled = false))
advance(1000)
assertEquals(listOf(true, false), changes)
assertFalse(send(KeyEvent.ACTION_UP, enabled = false))
assertTrue(taps.isEmpty())
}
@Test fun cancellationBeforeTheThresholdPreventsBothHoldAndTap() {
send(KeyEvent.ACTION_DOWN)
advance(100)
input.cancel()
send(KeyEvent.ACTION_UP)
advance(1000)
assertTrue(changes.isEmpty())
assertTrue(taps.isEmpty())
}
@Test fun cancellationAlsoDiscardsAPendingSingleTap() {
send(KeyEvent.ACTION_DOWN)
send(KeyEvent.ACTION_UP)
input.cancel()
advance(1000)
assertTrue(taps.isEmpty())
}
@Test fun openingAnOverlayReleasesQamBeforeRoutingItsNextKey() {
send(KeyEvent.ACTION_DOWN)
advance(500)
assertFalse(input.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_B), false))
assertTrue(send(KeyEvent.ACTION_UP, enabled = false))
assertEquals(listOf(true, false), changes)
@@ -58,33 +128,39 @@ class BackQamInputTest {
@Test fun repeatsAfterCancellationCannotReassertQam() {
send(KeyEvent.ACTION_DOWN)
advance(500)
input.cancel()
assertTrue(send(KeyEvent.ACTION_DOWN, repeat = 1))
advance(1000)
assertEquals(listOf(true, false), changes)
assertTrue(send(KeyEvent.ACTION_DOWN))
advance(500)
assertTrue(send(KeyEvent.ACTION_UP))
assertEquals(listOf(true, false, true, false), changes)
}
@Test fun cancelledReleaseStillReleasesQam() {
@Test fun cancelledReleaseNeverProducesATap() {
send(KeyEvent.ACTION_DOWN)
assertTrue(input.onKeyEvent(KeyEvent(0, 100, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK,
0, 0, 4, 0, KeyEvent.FLAG_CANCELED), true))
assertEquals(listOf(true, false), changes)
advance(1000)
assertTrue(changes.isEmpty())
assertTrue(taps.isEmpty())
}
@Test fun removingOneDeviceDoesNotReleaseAnotherDevicesHold() {
send(KeyEvent.ACTION_DOWN, device = 4)
send(KeyEvent.ACTION_DOWN, device = 5)
advance(500)
input.onDeviceRemoved(4)
assertTrue(changes.last())
assertEquals(listOf(true), changes)
send(KeyEvent.ACTION_UP, device = 5)
assertFalse(changes.last())
send(KeyEvent.ACTION_DOWN, device = 4)
input.onDeviceRemoved(4)
assertFalse(changes.last())
assertEquals(listOf(true, false), changes)
assertTrue(taps.isEmpty())
}
private fun advance(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis))
private fun send(action: Int, repeat: Int = 0, device: Int = 4, enabled: Boolean = true) =
input.onKeyEvent(KeyEvent(0, 100, action, KeyEvent.KEYCODE_BACK, repeat, 0, device, 0), enabled)
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.media.AudioManager
import android.os.Looper
@@ -24,20 +25,21 @@ import org.robolectric.annotation.Implementation
import org.robolectric.shadows.ShadowSettings
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE, application = Application::class, shadows = [WriteSettingsShadow::class])
@Config(sdk = [28, 33], manifest = Config.NONE, application = Application::class, shadows = [WriteSettingsShadow::class])
class DeviceControlsComponentTest {
@get:Rule val tmp = TemporaryFolder()
private val context get() = RuntimeEnvironment.getApplication()
private val audio get() = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private lateinit var component: DeviceControlsComponent
private lateinit var directory: File
private var visible = true
@Before fun start() {
WriteSettingsShadow.writable = true
Settings.System.putInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
val root = tmp.newFolder()
directory = DeviceControlsComponent.directory(root)
component = DeviceControlsComponent(context, root)
component = DeviceControlsComponent(context, root) { visible }
component.publish()
component.start()
shadowOf(Looper.getMainLooper()).idle()
@@ -85,6 +87,35 @@ class DeviceControlsComponentTest {
assertEquals(1.0, JSONObject(File(directory, "state.json").readText()).getDouble("brightness"), 0.0001)
}
@Test fun hiddenPermissionRequestOffersANotificationAndRetainsTheLatestValue() {
visible = false
WriteSettingsShadow.writable = false
request("brightness", "0.25")
request("brightness", "0.75")
assertNull(shadowOf(context).nextStartedActivity)
val notifications = context.getSystemService(NotificationManager::class.java)
val notification = notifications.activeNotifications.single().notification
notification.contentIntent.send()
assertEquals(Settings.ACTION_MANAGE_WRITE_SETTINGS, shadowOf(context).nextStartedActivity.action)
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(121))
WriteSettingsShadow.writable = true
tick()
assertEquals(192, Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS))
assertTrue(notifications.activeNotifications.isEmpty())
}
@Test fun stoppingClearsThePermissionNotificationAndPendingRequest() {
visible = false
WriteSettingsShadow.writable = false
request("brightness", "0.75")
component.stop()
shadowOf(Looper.getMainLooper()).idle()
assertTrue(context.getSystemService(NotificationManager::class.java).activeNotifications.isEmpty())
WriteSettingsShadow.writable = true
tick()
assertEquals(128, Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS))
}
@Test fun stoppedSessionsDoNotConsumeNewCommands() {
component.stop()
shadowOf(Looper.getMainLooper()).idle()
+12 -5
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <stdio.h>
#include <pulsecore/core-rtclock.h>
#include <pulsecore/core-subscribe.h>
struct android_volume {
pa_sink *sink;
@@ -59,6 +60,10 @@ static void android_volume_refresh(pa_mainloop_api *api, pa_time_event *event, c
access(processing, F_OK) != 0 && access(mute_processing, F_OK) != 0 &&
android_volume_read(bridge->directory, &level, &maximum, &muted)) {
pa_cvolume volume;
if (bridge->sink->n_volume_steps != maximum + 1) {
bridge->sink->n_volume_steps = maximum + 1;
pa_subscription_post(bridge->sink->core, PA_SUBSCRIPTION_EVENT_SINK | PA_SUBSCRIPTION_EVENT_CHANGE, bridge->sink->index);
}
pa_cvolume_set(&volume, bridge->sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum);
if (!pa_cvolume_equal(&volume, &bridge->sink->real_volume)) pa_sink_volume_changed(bridge->sink, &volume);
if (bridge->sink->muted != !!muted) pa_sink_mute_changed(bridge->sink, !!muted);
@@ -72,14 +77,16 @@ static void android_volume_refresh(pa_mainloop_api *api, pa_time_event *event, c
static struct android_volume *android_volume_new(pa_sink *sink, const char *directory) {
unsigned level, maximum, muted;
if (!directory || !android_volume_read(directory, &level, &maximum, &muted)) return NULL;
if (!directory) return NULL;
struct android_volume *bridge = pa_xnew0(struct android_volume, 1);
bridge->sink = sink;
bridge->directory = pa_xstrdup(directory);
pa_cvolume_set(&sink->real_volume, sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum);
sink->reference_volume = sink->real_volume;
sink->muted = !!muted;
sink->n_volume_steps = maximum + 1;
if (android_volume_read(directory, &level, &maximum, &muted)) {
pa_cvolume_set(&sink->real_volume, sink->sample_spec.channels, (uint64_t)level * PA_VOLUME_NORM / maximum);
sink->reference_volume = sink->real_volume;
sink->muted = !!muted;
sink->n_volume_steps = maximum + 1;
}
pa_cvolume_reset(&sink->soft_volume, sink->sample_spec.channels);
pa_sink_set_set_volume_callback(sink, android_volume_set);
pa_sink_set_set_mute_callback(sink, android_volume_mute);
@@ -23,19 +23,21 @@ BRIGHTNESS_JS = r"""
if (window.__droiddeckBrightness) return "present";
const display = SteamClient.System?.Display;
if (!display?.RegisterForBrightnessChanges || !display?.SetBrightness) return "unavailable";
const self = { expected: null, updating: true };
const self = { expected: null, updating: true, revision: 0 };
self.set = value => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) return;
if (!self.updating && self.expected !== null && Math.abs(value - self.expected) < 0.0001) return;
self.expected = value;
self.updating = true;
const revision = ++self.revision;
setTimeout(() => { if (self.revision === revision) self.updating = false; }, 500);
display.SetBrightness(value);
};
self.subscription = display.RegisterForBrightnessChanges(state => {
const value = state?.flBrightness;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1 || self.expected === null) return;
if (Math.abs(value - self.expected) < 0.0001) { self.updating = false; return; }
if (self.updating) return;
if (self.updating) { self.updating = false; return; }
self.expected = value;
if (typeof window.__BINDING__ === "function") window.__BINDING__(String(value));
});
@@ -677,6 +679,7 @@ class Helper:
self.vr_launch = None
self.chooser = None
self.controls_state = None
self.controls_installed = False
def sync_controls(self):
try:
@@ -685,9 +688,17 @@ class Helper:
value = state.get("brightness")
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1:
return
if not self.controls_installed:
self.page.call("Runtime.addBinding", {"name": BRIGHTNESS_BINDING})
self.controls_installed = self.page.evaluate(BRIGHTNESS_JS) in ("installed", "present")
if not self.controls_installed:
return
if state != self.controls_state:
self.page.evaluate("window.__droiddeckBrightness?.set(%s)" % json.dumps(value))
self.controls_state = state
if self.page.evaluate("!!window.__droiddeckBrightness && (window.__droiddeckBrightness.set(%s), true)" % json.dumps(value)) is True:
self.controls_state = state
else:
self.controls_installed = False
self.controls_state = None
except (OSError, ValueError, TypeError):
pass
@@ -762,14 +773,13 @@ class Helper:
try:
if page.evaluate("typeof SteamClient === 'object' && typeof SteamClient.WebUITransport === 'object'") is not True:
raise LookupError("SteamClient is not ready")
if os.path.isfile(os.path.join(CONTROLS_DIR, "state.json")):
page.call("Runtime.addBinding", {"name": BRIGHTNESS_BINDING})
page.evaluate(BRIGHTNESS_JS)
self.controls_state = None
if self.files_only:
page.call("Runtime.addBinding", {"name": FILE_BINDING})
page.evaluate(FILE_JS)
self.page = page
self.controls_state = None
self.controls_installed = False
self.sync_controls()
return True
page.call("Runtime.addBinding", {"name": BINDING})
page.call("Runtime.addBinding", {"name": VR_BINDING})
@@ -789,6 +799,9 @@ class Helper:
page.ws.close()
raise
self.page = page
self.controls_state = None
self.controls_installed = False
self.sync_controls()
return True
def reconcile(self):
+101
View File
@@ -0,0 +1,101 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which("cc"), "C compiler required")
class AndroidVolumeTest(unittest.TestCase):
def test_missing_or_invalid_initial_state_recovers_without_restarting_the_sink(self):
header = Path(__file__).resolve().parents[1] / "aaudio-sink/android-volume.h"
harness = r'''
#define _GNU_SOURCE
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <unistd.h>
#define PA_VOLUME_NORM 65536U
#define PA_USEC_PER_MSEC 1000
#define PA_SINK_INIT 0
#define PA_SUBSCRIPTION_EVENT_SINK 1
#define PA_SUBSCRIPTION_EVENT_CHANGE 2
#define PA_MIN(a,b) ((a)<(b)?(a):(b))
#define pa_xnew0(type,n) ((type*)calloc(n,sizeof(type)))
#define pa_xstrdup strdup
#define pa_xfree free
#define pa_log_warn(...) ((void)0)
typedef struct { unsigned value; } pa_cvolume;
typedef struct pa_time_event { int value; } pa_time_event;
typedef struct pa_mainloop_api { void (*time_free)(pa_time_event*); } pa_mainloop_api;
typedef struct { pa_mainloop_api *mainloop; } pa_core;
typedef struct pa_sink {
void *userdata; int state; bool muted; unsigned n_volume_steps, index;
struct { unsigned channels; } sample_spec;
pa_cvolume real_volume, reference_volume, soft_volume;
pa_core *core;
void (*set_volume)(struct pa_sink*), (*set_mute)(struct pa_sink*);
} pa_sink;
struct android_volume;
struct userdata { struct android_volume *android_volume; };
static int restarts, frees;
static char *pa_sprintf_malloc(const char *format, ...) {
va_list ap; va_start(ap,format); char *result; assert(vasprintf(&result,format,ap)>=0); va_end(ap); return result;
}
static void pa_cvolume_set(pa_cvolume *volume, unsigned channels, unsigned value) { volume->value=value; }
static unsigned pa_cvolume_max(const pa_cvolume *volume) { return volume->value; }
static void pa_cvolume_reset(pa_cvolume *volume, unsigned channels) { volume->value=PA_VOLUME_NORM; }
static bool pa_cvolume_equal(const pa_cvolume *a, const pa_cvolume *b) { return a->value==b->value; }
static void pa_sink_volume_changed(pa_sink *sink, const pa_cvolume *volume) { sink->real_volume=*volume; }
static void pa_sink_mute_changed(pa_sink *sink, bool muted) { sink->muted=muted; }
static void pa_sink_set_set_volume_callback(pa_sink *sink, void (*callback)(pa_sink*)) { sink->set_volume=callback; }
static void pa_sink_set_set_mute_callback(pa_sink *sink, void (*callback)(pa_sink*)) { sink->set_mute=callback; }
static void pa_subscription_post(pa_core *core, int event, unsigned index) {}
static uint64_t pa_rtclock_now(void) { return 0; }
static void pa_core_rttime_restart(pa_core *core, pa_time_event *event, uint64_t time) { restarts++; }
static pa_time_event *pa_core_rttime_new(pa_core *core, uint64_t time,
void (*callback)(pa_mainloop_api*,pa_time_event*,const struct timeval*,void*), void *userdata) {
return calloc(1,sizeof(pa_time_event));
}
static void free_timer(pa_time_event *event) { frees++; free(event); }
#include "android-volume.h"
static void state(const char *directory, const char *text) {
char *path=pa_sprintf_malloc("%s/volume.state",directory);
FILE *f=fopen(path,"w"); assert(f); fputs(text,f); fclose(f); free(path);
}
int main(int argc, char **argv) {
pa_mainloop_api api={.time_free=free_timer}; pa_core core={.mainloop=&api};
struct userdata userdata={0};
pa_sink sink={.userdata=&userdata,.core=&core,.state=1,.sample_spec={.channels=2},.real_volume={PA_VOLUME_NORM}};
userdata.android_volume=android_volume_new(&sink,argv[1]);
assert(userdata.android_volume && userdata.android_volume->timer && sink.set_volume && sink.set_mute);
state(argv[1],"invalid\n");
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(sink.real_volume.value==PA_VOLUME_NORM && restarts==1);
state(argv[1],"3 15 1\n");
android_volume_refresh(&api,userdata.android_volume->timer,NULL,userdata.android_volume);
assert(sink.real_volume.value==3*PA_VOLUME_NORM/15 && sink.muted && sink.n_volume_steps==16);
assert(sink.soft_volume.value==PA_VOLUME_NORM && restarts==2);
sink.real_volume.value=2*PA_VOLUME_NORM/3; sink.set_volume(&sink);
char *request=pa_sprintf_malloc("%s/volume.request",argv[1]);
FILE *f=fopen(request,"r"); unsigned level; assert(f && fscanf(f,"%u",&level)==1 && level==10); fclose(f); free(request);
android_volume_free(userdata.android_volume); assert(frees==1);
return 0;
}
'''
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "pulsecore").mkdir()
for name in ("core-rtclock.h", "core-subscribe.h"):
(root / "pulsecore" / name).touch()
source = root / "test.c"
source.write_text(harness)
binary = root / "test"
subprocess.run(["cc", "-std=c11", "-I", str(root), "-I", str(header.parent),
str(source), "-o", str(binary)], check=True, capture_output=True)
subprocess.run([str(binary), str(root)], check=True, capture_output=True)
+53 -1
View File
@@ -15,6 +15,8 @@ class DeviceControlsTest(unittest.TestCase):
const assert = require('assert');
globalThis.window = globalThis;
const requests = [], callbacks = [];
const timers = [];
globalThis.setTimeout = callback => timers.push(callback);
let current = 1;
window.droiddeckBrightnessChanged = value => requests.push(Number(value));
globalThis.SteamClient = { System: { Display: {
@@ -39,6 +41,16 @@ assert.deepStrictEqual(requests, [0.8]);
for (const value of [NaN, Infinity, -1, 2, '0.5']) callbacks.forEach(cb => cb({ flBrightness: value }));
assert.deepStrictEqual(requests, [0.8]);
assert.strictEqual(callbacks.length, 1);
SteamClient.System.Display.SetBrightness = value => callbacks.forEach(cb => cb({ flBrightness: value + 0.1 }));
bridge.set(0.3);
assert.deepStrictEqual(requests, [0.8]);
callbacks.forEach(cb => cb({ flBrightness: 0.6 }));
assert.deepStrictEqual(requests, [0.8, 0.6]);
SteamClient.System.Display.SetBrightness = () => {};
bridge.set(0.65);
timers.forEach(callback => callback());
callbacks.forEach(cb => cb({ flBrightness: 0.7 }));
assert.deepStrictEqual(requests, [0.8, 0.6, 0.7]);
"""
with tempfile.TemporaryDirectory() as directory:
script = Path(directory) / "bridge.js"
@@ -65,7 +77,8 @@ assert.strictEqual(callbacks.length, 1);
globals_["CONTROLS_DIR"] = directory
helper = LIVE["Helper"]("/steam", 0)
calls = []
helper.page = type("Page", (), {"evaluate": lambda self, expression: calls.append(expression)})()
helper.controls_installed = True
helper.page = type("Page", (), {"evaluate": lambda self, expression: calls.append(expression) or True})()
path = Path(directory) / "state.json"
path.write_text(json.dumps({"brightness": 0.5, "revision": 1}))
helper.sync_controls()
@@ -77,6 +90,45 @@ assert.strictEqual(callbacks.length, 1);
finally:
globals_["CONTROLS_DIR"] = previous
def test_display_bridge_retries_binding_and_install_before_caching_state(self):
with tempfile.TemporaryDirectory() as directory:
globals_ = LIVE["Helper"].sync_controls.__globals__
previous = globals_["CONTROLS_DIR"]
try:
globals_["CONTROLS_DIR"] = directory
Path(directory, "state.json").write_text(json.dumps({"brightness": 0.5, "revision": 1}))
calls, installs, writes = [], ["unavailable", "installed", "present"], []
class Page:
present = True
def call(self, method, params):
calls.append((method, params))
def evaluate(self, expression):
if expression == LIVE["BRIGHTNESS_JS"]:
return installs.pop(0)
writes.append(expression)
return self.present
helper = LIVE["Helper"]("/steam", 0)
helper.page = Page()
helper.sync_controls()
self.assertIsNone(helper.controls_state)
self.assertFalse(writes)
helper.sync_controls()
helper.sync_controls()
self.assertEqual(len(calls), 2)
self.assertEqual(len(writes), 1)
self.assertEqual(calls[1], ("Runtime.addBinding", {"name": LIVE["BRIGHTNESS_BINDING"]}))
helper.page.present = False
Path(directory, "state.json").write_text(json.dumps({"brightness": 0.7, "revision": 2}))
helper.sync_controls()
self.assertIsNone(helper.controls_state)
self.assertFalse(helper.controls_installed)
helper.page.present = True
helper.sync_controls()
self.assertEqual(len(calls), 3)
self.assertEqual(helper.controls_state["brightness"], 0.7)
finally:
globals_["CONTROLS_DIR"] = previous
if __name__ == "__main__":
unittest.main()