mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
fix(steam): preserve Back taps and recover QAM controls
This commit is contained in:
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user