diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 7599185ff..f4bb0b6e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt index 4ba4e3391..ef8042aa6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt @@ -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() +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() private val cancelled = mutableSetOf() + 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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt index c88666dfb..3e0eaf8b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt @@ -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? = 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 252f94103..c5b5f8415 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 78fc3b00c..598995cd9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index bed8cccbd..5ea179ea1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6cf0674c2..0e600fceb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -306,7 +306,9 @@ Xbox 360 controller 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. Back - Back acts as Quick Access. Hold it with another button for Steam shortcuts. + Hold Back for 500 ms, then press another button for Steam shortcuts. Single and double taps keep the selected actions. + Allow brightness control + Tap to allow Steam to change Android screen brightness. Menu → QAM QAM → Menu Session diff --git a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt index 37a7abda0..3b3c45992 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt @@ -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() - private val input = BackQamInput(changes::add) + private val taps = mutableListOf() + 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) } diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt index 9e0da30aa..fa96dacc9 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DeviceControlsComponentTest.kt @@ -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() diff --git a/tools/aaudio-sink/android-volume.h b/tools/aaudio-sink/android-volume.h index b7f4ec9d1..6a31befeb 100644 --- a/tools/aaudio-sink/android-volume.h +++ b/tools/aaudio-sink/android-volume.h @@ -1,6 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #include #include +#include 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); diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 595563019..4c46216c2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -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): diff --git a/tools/tests/test_android_volume.py b/tools/tests/test_android_volume.py new file mode 100644 index 000000000..8246f34fe --- /dev/null +++ b/tools/tests/test_android_volume.py @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#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) diff --git a/tools/tests/test_device_controls.py b/tools/tests/test_device_controls.py index 3a513a89f..9f914c5d3 100644 --- a/tools/tests/test_device_controls.py +++ b/tools/tests/test_device_controls.py @@ -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()