From 5d07c575e2005af382439f67513fd410f32b15b5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:46 -0400 Subject: [PATCH 001/156] fix: skip DirectAudio setup for Proton queries (#1044) --- .../overlay/usr/local/bin/steam-compatibility | 4 +++ tools/tests/test_game_environment.py | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index cc68575e5..fb84fd94d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -244,6 +244,10 @@ bl_vacdiag() { BL_DIRECTAUDIO_SETUP = """ bl_directaudio() { + case "${verb:-}" in + run|waitforexitandrun|runinprefix) ;; + *) return 0 ;; + esac base=$1 [ -n "${BL_DIRECTAUDIO:-}" ] || return 0 [ -d "$BL_DIRECTAUDIO" ] || return 0 diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index d22331e6e..9c8a98c20 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -849,12 +849,12 @@ class DirectAudioPrefixTest(unittest.TestCase): (self.windows / folder).mkdir(parents=True) self.reg = self.compat / "pfx/user.reg" - def run_setup(self): - script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n' + def run_setup(self, verb="waitforexitandrun"): + script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n' env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat)) env.pop("WINEDLLPATH", None) env.pop("WINE_AUDIO_DRIVER", None) - return subprocess.run(["bash", "-c", script, "bash", str(self.tool)], env=env, capture_output=True, text=True, check=True) + return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb], env=env, capture_output=True, text=True, check=True) def audio_values(self): found, current = [], None @@ -869,6 +869,27 @@ class DirectAudioPrefixTest(unittest.TestCase): path = self.windows / folder / "winedirectaudio.drv" return os.readlink(path) if path.is_symlink() else None + def test_non_launch_verbs_preserve_audio_configuration(self): + for verb in ("getcompatpath", "getnativepath", "destroyprefix"): + with self.subTest(verb=verb): + self.reg.write_text('WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\Drivers] 1\n"Audio"="pulse"\n') + before = self.reg.read_bytes() + result = self.run_setup(verb) + self.assertEqual(self.reg.read_bytes(), before) + self.assertIsNone(self.link("system32")) + self.assertIsNone(self.link("syswow64")) + self.assertIn("WINEDLLPATH=\n", result.stdout) + self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout) + + def test_other_windows_launch_verbs_still_set_up_audio(self): + self.reg.write_text("WINE REGISTRY Version 2\n") + for verb in ("run", "runinprefix"): + with self.subTest(verb=verb): + result = self.run_setup(verb) + self.assertIn("DirectAudio ready", result.stderr) + self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout) + self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv")) + def test_the_driver_is_placed_and_selected_the_way_wine_reads_it(self): self.reg.write_text("WINE REGISTRY Version 2\n;; All keys relative to \\\\User\\\\S-1-5-21-0-0-0-1000\n\n#arch=win64\n") result = self.run_setup() From 632f13d1b229c4ed8b5616f191ed1dbb6d896334 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:23:04 -0400 Subject: [PATCH 002/156] fix: keep second-screen controls in their own activity (#1041) --- app/src/main/AndroidManifest.xml | 9 ++ .../com/droiddeck/launcher/SessionActivity.kt | 6 +- .../launcher/session/SessionService.kt | 1 + ...resentation.kt => SecondScreenControls.kt} | 57 ++----- .../launcher/ui/SecondScreenWindow.kt | 139 ++++++++++++++++++ .../launcher/ui/SecondScreenActivityTest.kt | 61 ++++++++ 6 files changed, 222 insertions(+), 51 deletions(-) rename app/src/main/java/com/droiddeck/launcher/ui/{SecondScreenPresentation.kt => SecondScreenControls.kt} (90%) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SecondScreenWindow.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/SecondScreenActivityTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 757d21bf8..81b3adcf4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -153,6 +153,15 @@ android:resizeableActivity="true" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" /> + + 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 f8484b9e1..e9f7fb390 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1479,6 +1479,7 @@ class SessionService : Service() { } override fun onTaskRemoved(rootIntent: Intent?) { + if (rootIntent?.component?.className == com.droiddeck.launcher.ui.SecondScreenActivity::class.java.name) return if (pipTask || xrActivityVisible) { Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy") activityVisible = false diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenControls.kt similarity index 90% rename from app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt rename to app/src/main/java/com/droiddeck/launcher/ui/SecondScreenControls.kt index 25b795921..908b00e0a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenControls.kt @@ -1,21 +1,17 @@ package com.droiddeck.launcher.ui import com.droiddeck.launcher.R -import android.app.Presentation import android.content.ClipboardManager import android.content.Context import android.graphics.Canvas import android.graphics.Color -import android.os.Bundle import android.util.Log -import android.view.Display import android.view.Gravity import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewConfiguration -import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.FrameLayout @@ -37,9 +33,9 @@ import com.termux.terminal.TerminalSessionClient import com.termux.view.TerminalView import com.termux.view.TerminalViewClient -class SecondScreenPresentation( +internal class SecondScreenControls( + context: Context, private val sessionActivity: SessionActivity, - display: Display, private val sendPointer: (x: Float, y: Float, width: Float, height: Float) -> Unit, private val sendButton: (button: Int, pressed: Boolean) -> Unit, private val sendWheel: (steps: Int) -> Unit, @@ -47,12 +43,8 @@ class SecondScreenPresentation( private val onQam: () -> Unit, private val onClose: () -> Unit, private val pad: PadBridge? = null, -) : Presentation(sessionActivity, display) { - private val root = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(Color.rgb(15, 18, 22)) - isFocusableInTouchMode = true - } +) : LinearLayout(context) { + private val root get() = this private var currentMode = SecondScreenMode.NONE private var keyboardHost: KeyboardHost? = null private var terminalSession: TerminalSession? = null @@ -60,35 +52,22 @@ class SecondScreenPresentation( private var terminalPid = -1 private var ctrlActive = false private var altActive = false - private var created = false - init { - setCancelable(false) - setCanceledOnTouchOutside(false) - setOnDismissListener { closeTerminal() } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - created = true - window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - setContentView(root) - applyImmersiveMode() - renderMode() + orientation = VERTICAL + setBackgroundColor(Color.rgb(15, 18, 22)) + isFocusableInTouchMode = true } fun showMode(mode: SecondScreenMode) { - if (currentMode == mode && isShowing) return + if (currentMode == mode) return currentMode = mode - if (created) renderMode() + renderMode() } fun closeControls() { closeTerminal() keyboardHost?.hide() keyboardHost = null - dismiss() } override fun dispatchKeyEvent(event: KeyEvent): Boolean { @@ -98,14 +77,6 @@ class SecondScreenPresentation( return super.dispatchKeyEvent(event) } - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - applyImmersiveMode() - sessionActivity.refreshClipboard() - } - } - private fun renderMode() { keyboardHost?.hide() keyboardHost = null @@ -291,16 +262,6 @@ class SecondScreenPresentation( private fun weightParams() = LinearLayout.LayoutParams(-1, 0, 1f) - private fun applyImmersiveMode() { - @Suppress("DEPRECATION") - window?.decorView?.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - or View.SYSTEM_UI_FLAG_FULLSCREEN - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) - } - private inner class TrackpadView(context: Context) : View(context) { private val gestures = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), object : PointerGestures.Listener { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenWindow.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenWindow.kt new file mode 100644 index 000000000..55efd2a00 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenWindow.kt @@ -0,0 +1,139 @@ +package com.droiddeck.launcher.ui + +import android.app.Activity +import android.app.ActivityOptions +import android.content.Intent +import android.os.Bundle +import android.view.Display +import android.view.View +import android.view.WindowManager +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.input.PadBridge +import com.droiddeck.launcher.input.SecondScreenMode +import com.droiddeck.launcher.session.SessionState +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicLong + +class SecondScreenWindow( + private val sessionActivity: SessionActivity, + val display: Display, + private val sendPointer: (Float, Float, Float, Float) -> Unit, + private val sendButton: (Int, Boolean) -> Unit, + private val sendWheel: (Int) -> Unit, + private val onSteamMenu: () -> Unit, + private val onQam: () -> Unit, + private val onClose: () -> Unit, + private val pad: PadBridge? = null, +) { + private val token = tokens.incrementAndGet() + private var activity = WeakReference(null) + private var controls: SecondScreenControls? = null + private var mode = SecondScreenMode.NONE + private var launching = false + private var closed = false + val window get() = activity.get()?.window + val isShowing get() = !closed && (launching || activity.get()?.isFinishing == false) + + fun show() { + if (isShowing || closed) return + owners[token] = WeakReference(this) + launching = true + try { + val intent = Intent(sessionActivity, SecondScreenActivity::class.java) + .putExtra(OWNER, token) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + sessionActivity.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(display.displayId).toBundle()) + } catch (error: Exception) { + launching = false + owners.remove(token) + throw error + } + } + + fun showMode(mode: SecondScreenMode) { + this.mode = mode + controls?.showMode(mode) + } + + fun closeControls() { + closed = true + launching = false + owners.remove(token) + controls?.closeControls() + controls = null + activity.get()?.finishAndRemoveTask() + activity.clear() + } + + internal fun attach(host: SecondScreenActivity): View { + controls?.closeControls() + activity = WeakReference(host) + launching = false + return SecondScreenControls(host, sessionActivity, sendPointer, sendButton, sendWheel, + onSteamMenu, onQam, onClose, pad).also { + controls = it + it.showMode(mode) + } + } + + internal fun detach(host: SecondScreenActivity) { + if (activity.get() !== host) return + controls?.closeControls() + controls = null + activity.clear() + if (host.isFinishing && !closed) onClose() + } + + internal fun refreshClipboard() = sessionActivity.refreshClipboard() + + companion object { + private const val OWNER = "secondScreenOwner" + private val tokens = AtomicLong() + private val owners = mutableMapOf>() + internal fun find(intent: Intent): SecondScreenWindow? = + owners[intent.getLongExtra(OWNER, 0)]?.get()?.takeUnless { it.closed } + } +} + +class SecondScreenActivity : Activity() { + private var owner: SecondScreenWindow? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val host = SecondScreenWindow.find(intent) + if (host == null || !SessionState.running) { + finishAndRemoveTask() + return + } + owner = host + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + setContentView(host.attach(this)) + applyImmersiveMode() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + applyImmersiveMode() + owner?.refreshClipboard() + } + } + + @Deprecated("Back is handled by the session controls") + override fun onBackPressed() = Unit + + override fun onDestroy() { + owner?.detach(this) + owner = null + super.onDestroy() + } + + private fun applyImmersiveMode() { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenActivityTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenActivityTest.kt new file mode 100644 index 000000000..040ed3ff3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenActivityTest.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import android.content.Intent +import android.hardware.display.DisplayManager +import android.view.Display +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, manifest = Config.NONE, sdk = [28, 33]) +@LooperMode(LooperMode.Mode.PAUSED) +class SecondScreenActivityTest { + @Before fun setUp() { + SessionState.running = true + SessionState.stopRequested = false + } + + @After fun tearDown() { + SessionState.running = false + SessionState.stopRequested = false + } + + @Test fun removingControlsTaskDoesNotStopTheRunningSession() { + val service = Robolectric.buildService(SessionService::class.java).get() + service.onTaskRemoved(Intent(service, SecondScreenActivity::class.java)) + assertTrue(SessionState.running) + assertFalse(SessionState.stopRequested) + } + + @Test fun activityRestoredWithoutItsSessionOwnerFinishes() { + val activity = Robolectric.buildActivity(SecondScreenActivity::class.java).create().get() + assertTrue(activity.isFinishing) + assertTrue(SessionState.running) + } + + @Test fun closingBeforeTheSecondaryActivityStartsRejectsTheLateLaunch() { + val session = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = session.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + val controls = SecondScreenWindow(session, display, { _, _, _, _ -> }, { _, _ -> }, {}, {}, {}, {}) + controls.show() + val intent = shadowOf(session).nextStartedActivity + controls.closeControls() + val activity = Robolectric.buildActivity(SecondScreenActivity::class.java, intent).create().get() + assertTrue(activity.isFinishing) + assertFalse(controls.isShowing) + assertTrue(SessionState.running) + } +} From 1fbe103ffebe611236155b475cd2dfe545c5c2b7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:23:41 -0400 Subject: [PATCH 003/156] feat: auto driver picks come from a Drivers-CI manifest with per-GPU fallbacks (#977) --- .../com/droiddeck/launcher/DriverMenus.kt | 11 ++- .../droiddeck/launcher/core/DeviceReport.kt | 3 +- .../droiddeck/launcher/core/DownloadSource.kt | 1 + .../com/droiddeck/launcher/gpu/AutoPicks.kt | 64 ++++++++++++++++++ .../com/droiddeck/launcher/gpu/DriverPairs.kt | 18 +++-- .../droiddeck/launcher/gpu/TurnipReleases.kt | 8 +++ .../droiddeck/launcher/gpu/AutoPicksTest.kt | 67 +++++++++++++++++++ 7 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 631d55ccf..7ccbba7f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableIntStateOf import com.droiddeck.launcher.gpu.AutoDriverTrial +import com.droiddeck.launcher.gpu.AutoPicks import com.droiddeck.launcher.gpu.DriverBundle import com.droiddeck.launcher.gpu.DriverPairs import com.droiddeck.launcher.gpu.GpuInfo @@ -179,7 +180,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl val lm = LinuxVulkanDriverManager(activity) val td = TurnipDriver(activity) val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs) + val recommended = if (QuestExperimental.isSupported()) null else recommendedKey(pairs) pairRows = pairs.map { p -> val bundle = p.bundle?.let { installedBundle(it) } val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) } @@ -196,6 +197,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl }.sortedByDescending { it.recommended } } + private fun recommendedKey(pairs: List) = DriverPairs.recommendedKey( + gpu, pairs, TurnipReleases.autoPicks(activity), AutoDriverTrial.bad(activity), + blob = AutoPicks.blobVersion(com.droiddeck.launcher.gpu.VulkanInfo.cachedOrNull()?.get("driver_version")), + ) + fun chooseMode(next: String) { pairModeOpen = next == GPU_MODE_PAIR setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX) @@ -255,12 +261,13 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl Thread({ val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity) + runCatching { com.droiddeck.launcher.gpu.VulkanInfo.query() } ui.post { releaseChecking = false refreshReleaseRows() refreshPairs() val pairs = DriverPairs.from(TurnipReleases.cached(activity)) - val key = DriverPairs.recommendedKey(gpu, pairs) + val key = recommendedKey(pairs) val pair = pairs.firstOrNull { it.key == key } val row = pairRows.firstOrNull { it.key == key } when { diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 44c19ee61..1290884bb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -202,7 +202,8 @@ object DeviceReport { k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") k("Driver mode", SessionPrefs.gpuDriverMode(context) + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( - com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: "")) + com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context), + com.droiddeck.launcher.gpu.AutoDriverTrial.bad(context))?.let { " (recommended pair: $it)" } ?: "")) val turnip = TurnipDriver(context) val androidChoice = SessionPrefs.androidDriver(context) k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt index a613bf614..870e8ed42 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt @@ -38,6 +38,7 @@ object DownloadSource { private val CATALOGS = listOf( "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/", "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/", + "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/", ) data class Mirror(val host: String, val api: Boolean) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt new file mode 100644 index 000000000..76b6dddc9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoPicks.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family +import org.json.JSONArray +import org.json.JSONObject + +object AutoPicks { + const val URL = "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/main/auto-drivers.json" + const val SCHEMA = 1 + const val NEWEST_DD_TURNIP = DriverPairs.DD_TURNIP + + class Rule( + val families: Set, + val models: Set = emptySet(), + val oneUi8Gen2: Boolean? = null, + val minSdk: Int = 0, + val blobMin: Int = 0, + val blobMax: Int = Int.MAX_VALUE, + val prefer: List, + ) { + fun matches(gpu: GpuInfo, sdk: Int, blob: Int?): Boolean = + gpu.family in families && + (models.isEmpty() || gpu.model in models) && + (oneUi8Gen2 == null || gpu.oneUi8Gen2 == oneUi8Gen2) && + sdk >= minSdk && + (blobMin == 0 && blobMax == Int.MAX_VALUE || blob != null && blob in blobMin..blobMax) + } + + val BUILT_IN = listOf( + Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)), + Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)), + Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)), + Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)), + Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)), + ) + + fun parse(text: String?): List? = runCatching { + val json = JSONObject(text ?: return null) + if (json.optInt("schemaVersion") != SCHEMA) return null + val rules = json.getJSONArray("rules") + (0 until rules.length()).mapNotNull { i -> rule(rules.getJSONObject(i)) }.takeIf { it.isNotEmpty() } + }.getOrNull() + + private fun rule(o: JSONObject): Rule? { + val families = strings(o.optJSONArray("families")).mapNotNull { name -> Family.values().firstOrNull { it.name == name } }.toSet() + val prefer = strings(o.optJSONArray("prefer")) + if (families.isEmpty() || prefer.isEmpty()) return null + val models = o.optJSONArray("models")?.let { a -> (0 until a.length()).map { a.getInt(it) }.toSet() } ?: emptySet() + return Rule( + families, models, + if (o.has("oneUi8Gen2")) o.getBoolean("oneUi8Gen2") else null, + o.optInt("minSdk", 0), o.optInt("blobMin", 0), o.optInt("blobMax", Int.MAX_VALUE), prefer, + ) + } + + private fun strings(a: JSONArray?): List = a?.let { (0 until it.length()).map { i -> it.getString(i) } }.orEmpty() + + fun preferences(gpu: GpuInfo, rules: List, sdk: Int, blob: Int?): List = + rules.firstOrNull { it.matches(gpu, sdk, blob) }?.prefer.orEmpty() + + fun blobVersion(driverVersion: String?): Int? = driverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt index f737b4d4c..c99bfb5f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt @@ -61,13 +61,17 @@ object DriverPairs { } } - fun recommendedKey(gpu: GpuInfo, pairs: List): String? = when (gpu.family) { - Family.NOT_ADRENO -> null - Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED - Family.A7XX_LOW -> BANNER_710 - Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER - Family.A6XX -> BANNER - Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER + fun recommendedKey( + gpu: GpuInfo, pairs: List, + rules: List = AutoPicks.BUILT_IN, bad: Set = emptySet(), + sdk: Int = android.os.Build.VERSION.SDK_INT, blob: Int? = null, + ): String? { + if (gpu.family == Family.NOT_ADRENO) return null + val keys = AutoPicks.preferences(gpu, rules, sdk, blob) + .ifEmpty { AutoPicks.preferences(gpu, AutoPicks.BUILT_IN, sdk, blob) } + .mapNotNull { if (it == AutoPicks.NEWEST_DD_TURNIP) newestDdTurnip(pairs) else it } + return keys.firstOrNull { key -> pairs.any { it.key == key && it.complete && AutoDriverTrial.release(it) !in bad } } + ?: keys.firstOrNull() } fun newestDdTurnip(pairs: List): String? = pairs diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index 0cf3225ca..43540c0e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -19,6 +19,7 @@ object TurnipReleases { private const val PREFS = "turnip_releases" private const val KEY_RELEASE = "latest" private const val KEY_DOWNLOADS = "downloads" + private const val KEY_PICKS = "autoPicks" class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, val sha256: String = "", val pair: String = "", val bundle: Boolean = false) @@ -57,6 +58,9 @@ object TurnipReleases { }, ) + fun autoPicks(context: Context): List = + AutoPicks.parse(prefs(context).getString(KEY_PICKS, null)) ?: AutoPicks.BUILT_IN + fun cached(context: Context): Check? { val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null return runCatching { parse(JSONObject(raw)) }.getOrNull() @@ -100,6 +104,10 @@ object TurnipReleases { } } if (failed.size == SOURCES.size) throw lastError ?: IOException("no source answered") + runCatching { get(context, AutoPicks.URL, AutoPicks.URL) } + .onFailure { Log.i(TAG, "auto picks: ${it.message}") } + .getOrNull()?.takeIf { AutoPicks.parse(it) != null } + ?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() } val stored = JSONObject().put("assets", assets).put("latest", latest) .put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis()) prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply() diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt new file mode 100644 index 000000000..83f32a36e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/AutoPicksTest.kt @@ -0,0 +1,67 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AutoPicksTest { + private fun gpu(model: Int) = GpuInfo("Adreno $model", model, GpuInfo.familyOf(true, model), "", false) + + private fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + + private fun half(name: String, tag: String, linux: Boolean, pair: String) = + TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair) + + private val pairs = DriverPairs.from(TurnipReleases.Check( + listOf( + bundle("DD-Turnip-v0.3.0"), + half("Turnip-r6.zip", "r6", false, DriverPairs.BANNER), + half("Turnip-r6-Linux.zip", "r6", true, DriverPairs.BANNER), + ), + emptyList(), emptyList(), 0L, + )) + + private val manifest = """ + {"schemaVersion": 1, "rules": [ + {"families": ["A7XX"], "models": [740], "blobMin": 700, "prefer": ["banner"]}, + {"families": ["A7XX", "A6XX"], "prefer": ["dd-turnip", "banner"]}, + {"families": ["A9XX"], "prefer": ["banner"]} + ]} + """.trimIndent() + + @Test fun theManifestOrdersPreferencesAndTheNewestDdTurnipResolves() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(2, rules.size) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(650), pairs, rules)) + } + + @Test fun aReleaseMarkedBadFallsThroughToTheNextPreference() { + val rules = AutoPicks.parse(manifest)!! + val dd = pairs.first { it.key.startsWith(DriverPairs.DD_TURNIP) } + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), pairs, rules, bad = setOf(AutoDriverTrial.release(dd)))) + } + + @Test fun systemDriverRulesOnlyMatchAKnownBlob() { + val rules = AutoPicks.parse(manifest)!! + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 744)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = 676)) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v0.3.0"), DriverPairs.recommendedKey(gpu(740), pairs, rules, blob = null)) + assertEquals(744, AutoPicks.blobVersion("512.744.0")) + } + + @Test fun aGpuTheManifestDoesNotCoverUsesTheBuiltInTable() { + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs, AutoPicks.parse(manifest)!!)) + } + + @Test fun anotherSchemaOrNoUsableRulesIsIgnored() { + assertNull(AutoPicks.parse("""{"schemaVersion": 2, "rules": [{"families": ["A7XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("""{"schemaVersion": 1, "rules": [{"families": ["A9XX"], "prefer": ["banner"]}]}""")) + assertNull(AutoPicks.parse("not json")) + } +} From 25082786aaada81c57f63be9107ca7cf07aea272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:59:39 +0300 Subject: [PATCH 004/156] fix: added games are handed to Steam right away and retried only if they never take focus (#994) --- .../overlay/usr/local/bin/droiddeck-session | 56 ++++----- tools/tests/test_steam_cold_launch.py | 111 +++++++++--------- 2 files changed, 83 insertions(+), 84 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 4fd738b89..1566a1ba7 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -593,41 +593,37 @@ case $mode in done < <(xprop -spy -root "${atoms[@]}" 2>/dev/null) ) & steam_hold_launch() { - _bl_launch_ready= + local game=$1 shortcut= waited=0 status=0 + _bl_focused() { xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p'; } + _bl_hand_off() { + printf '%s\n' "$game" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game" + } _bl_end=$((SECONDS + 120)) - until [ "$(xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p')" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do + until [ "$(_bl_focused)" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do sleep 1 done - # With a large library the overview can stay missing for 30+ minutes while Steam already takes the launch URL. - _bl_overview_status=1 - _bl_overview_tries=0 - while [ "$_bl_overview_tries" -lt 2 ]; do - if /usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$1" 45; then - _bl_overview_status=0 - else - _bl_overview_status=$? - fi - if [ "$_bl_overview_status" -ne 1 ]; then - break - fi - _bl_overview_tries=$((_bl_overview_tries + 1)) - echo "== steam: shortcut overview not ready yet (attempt $_bl_overview_tries of 2)" - sleep 2 + # Shortcut CGameIDs are 64-bit; Steam app ids fit in 32 bits and keep the old short delay. + [ "${#game}" -gt 10 ] && shortcut=1 + [ -n "$shortcut" ] || sleep 4 + echo "== steam: interface up; launching the requested game $game" + _bl_hand_off + [ -n "$shortcut" ] || return 0 + # Steam can drop a shortcut launch it gets before the shortcut's overview exists (large libraries). + # Hand it over again only if the game never took focus, so a launch that worked is not started twice. + while [ "$(_bl_focused)" = 769 ] && [ "$waited" -lt 20 ]; do + sleep 1 + waited=$((waited + 1)) done - if [ "$_bl_overview_status" -eq 0 ]; then - echo "== steam: shortcut overview ready; launching the requested game $1" - _bl_launch_ready=1 - elif [ "$_bl_overview_status" -eq 3 ]; then - echo "== steam: not a shortcut CGameID; keeping the existing launch path" - sleep 4 - _bl_launch_ready=1 + [ "$(_bl_focused)" = 769 ] || return 0 + echo "== steam: the game is not in front after about 20 s; waiting for the shortcut overview" + /usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$game" 45 || status=$? + if [ "$status" -eq 3 ]; then + echo "== steam: not a shortcut CGameID; not launching again" + elif [ "$(_bl_focused)" = 769 ]; then + echo "== steam: launching the requested game again $game (overview status $status)" + _bl_hand_off else - echo "== steam: shortcut overview not ready after about 90 s; launching the requested game anyway $1" - _bl_launch_ready=1 - fi - if [ "$_bl_launch_ready" = 1 ]; then - echo "== steam: interface up; launching the requested game $1" - printf '%s\n' "$1" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game" + echo "== steam: the game took focus while waiting; not launching again" fi } if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py index 0065bdbeb..2c80a2546 100644 --- a/tools/tests/test_steam_cold_launch.py +++ b/tools/tests/test_steam_cold_launch.py @@ -1,4 +1,4 @@ -"""Cold shortcut launches wait for Steam's CGameID-to-app overview mapping.""" +"""Requested Steam games are handed over as soon as the interface is up; a shortcut is handed over again only if it never took focus.""" import os from pathlib import Path import subprocess @@ -12,6 +12,7 @@ START = SOURCE.index(" steam_hold_launch() {") END = SOURCE.index(' if [ -s "$HOME/.bl-autolaunch" ]; then', START) assert START >= 0 LAUNCH_BLOCK = textwrap.dedent(SOURCE[START:END]) +SHORTCUT = str((3551182975 << 32) | 0x02000000) class SteamColdLaunchTest(unittest.TestCase): @@ -26,93 +27,95 @@ class SteamColdLaunchTest(unittest.TestCase): self.agent_args = self.root / "agent-args" self.sleep_args = self.root / "sleep-args" self.agent_count = self.root / "agent-count" + self.focus_count = self.root / "focus-count" + # FOCUS lists the focused app per xprop call; the last value repeats. xprop = self.bin / "xprop" - xprop.write_text("#!/bin/sh\necho 'GAMESCOPE_FOCUSED_APP(CARDINAL) = 769'\n") + xprop.write_text( + "#!/bin/sh\n" + "n=$(cat \"$FOCUS_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$FOCUS_COUNT\"\n" + "i=0; last=769\n" + "for f in $FOCUS; do i=$((i + 1)); last=$f; [ \"$i\" -eq \"$n\" ] && break; done\n" + "echo \"GAMESCOPE_FOCUSED_APP(CARDINAL) = $last\"\n") xprop.chmod(0o755) agent = self.bin / "droiddeck-agent" agent.write_text( "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$AGENT_ARGS\"\n" + "printf '%s\n' \"$@\" > \"$AGENT_ARGS\"\n" "n=$(cat \"$AGENT_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$AGENT_COUNT\"\n" - "i=0; last=1\n" - "for s in $AGENT_STATUS; do i=$((i + 1)); last=$s; [ \"$i\" -eq \"$n\" ] && exit \"$s\"; done\n" - "exit \"$last\"\n") + "exit \"$AGENT_STATUS\"\n") agent.chmod(0o755) sleep = self.bin / "sleep" - sleep.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"$SLEEP_ARGS\"\n") + sleep.write_text("#!/bin/sh\nprintf '%s\n' \"$@\" >> \"$SLEEP_ARGS\"\n") sleep.chmod(0o755) self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent)) - def run_launch(self, target, helper_status): + def run_launch(self, target, focus="769", helper_status=0): env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), - AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status)) - script = self.block + "\nprintf 'ARG:%s\\n' \"$@\"\nwait\n" + AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status), + FOCUS=focus, FOCUS_COUNT=str(self.focus_count)) + script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n" url = target if target.startswith("steam://") else "steam://rungameid/" + target result = subprocess.run(["bash", "-c", script, "cold-launch", url], env=env, text=True, capture_output=True, timeout=10) self.assertEqual(0, result.returncode, result.stderr) return result - def test_shortcut_overview_gate_releases_original_64_bit_cgameid_without_fixed_sleep(self): - game_id = str((3551182975 << 32) | 0x02000000) - result = self.run_launch(game_id, 0) - self.assertEqual("steam-game", (self.launch_dir / "steam-game").name) - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip()) - self.assertEqual(["--wait-steam-shortcut-overview", game_id, "45"], self.agent_args.read_text().splitlines()) - self.assertFalse(self.sleep_args.exists(), result.stdout) + def hand_offs(self, result): + return [l for l in result.stdout.splitlines() if "launching the requested game" in l] - def test_native_app_id_keeps_legacy_bounded_launch_path(self): - game_id = "413150" - self.run_launch(game_id, 3) - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip()) - self.assertEqual(["--wait-steam-shortcut-overview", game_id, "45"], self.agent_args.read_text().splitlines()) + def test_a_shortcut_that_takes_focus_is_handed_over_once_without_waiting_for_the_overview(self): + result = self.run_launch(SHORTCUT, focus="769 769 4242") + self.assertEqual(SHORTCUT, (self.launch_dir / "steam-game").read_text().strip()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertFalse(self.agent_args.exists(), result.stdout) + self.assertEqual(["1"], self.sleep_args.read_text().split()) + + def test_a_shortcut_that_never_takes_focus_is_handed_over_again_after_the_overview_wait(self): + result = self.run_launch(SHORTCUT, focus="769", helper_status=0) + self.assertEqual(2, len(self.hand_offs(result)), result.stdout) + self.assertIn("launching the requested game again", result.stdout) + self.assertEqual(["--wait-steam-shortcut-overview", SHORTCUT, "45"], self.agent_args.read_text().splitlines()) + self.assertEqual(["1"] * 20, self.sleep_args.read_text().split()) + + def test_a_timed_out_overview_still_gets_one_more_hand_off(self): + result = self.run_launch(SHORTCUT, focus="769", helper_status=1) + self.assertEqual(2, len(self.hand_offs(result)), result.stdout) + self.assertIn("overview status 1", result.stdout) + + def test_a_game_that_takes_focus_during_the_overview_wait_is_not_started_twice(self): + focus = " ".join(["769"] * 23) + " 4242" + result = self.run_launch(SHORTCUT, focus=focus, helper_status=0) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertIn("took focus while waiting", result.stdout) + self.assertNotIn("launching the requested game again", result.stdout) + + def test_native_app_id_keeps_the_short_delay_and_is_handed_over_once(self): + result = self.run_launch("413150") + self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) self.assertEqual("4", self.sleep_args.read_text().strip()) + self.assertFalse(self.agent_args.exists()) - def test_a_shortcut_whose_overview_never_appears_is_launched_after_a_bounded_wait(self): - game_id = str((3551182975 << 32) | 0x02000000) - result = self.run_launch(game_id, 1) - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) - self.assertEqual(["--wait-steam-shortcut-overview", game_id, "45"], self.agent_args.read_text().splitlines()) - self.assertEqual("2", self.agent_count.read_text().strip()) - self.assertEqual(["2", "2"], self.sleep_args.read_text().split()) - self.assertIn("launching the requested game anyway", result.stdout) - - def test_a_late_overview_is_still_waited_for_once_more(self): - game_id = str((3551182975 << 32) | 0x02000000) - result = self.run_launch(game_id, "1 0") - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) - self.assertEqual("2", self.agent_count.read_text().strip()) - self.assertEqual(["2"], self.sleep_args.read_text().split()) - self.assertIn("attempt 1 of 2", result.stdout) - self.assertNotIn("anyway", result.stdout) - - def test_an_agent_failure_other_than_a_timeout_launches_without_retrying(self): - game_id = str((3551182975 << 32) | 0x02000000) - result = self.run_launch(game_id, 2) - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) - self.assertEqual("1", self.agent_count.read_text().strip()) - self.assertFalse(self.sleep_args.exists()) - - def test_a_restart_for_a_game_waits_for_its_overview_too(self): - game_id = str((3551182975 << 32) | 0x02000000) + def test_a_restart_for_a_game_uses_the_same_hand_off(self): loop = SOURCE[SOURCE.index(' if [ -s "$HOME/.bl-restart-game" ]; then'):SOURCE.index(' if [ -f "$HOME/.bl-restart-once" ]; then')] self.assertIn('steam_hold_launch "$_bl_again" &', loop) self.assertNotIn("set -- \"steam://rungameid/", loop) - script = self.block + '\nsteam_hold_launch "%s"\n' % game_id + script = self.block + '\nsteam_hold_launch "%s"\n' % SHORTCUT env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), - AGENT_COUNT=str(self.agent_count), AGENT_STATUS="1 0") + AGENT_COUNT=str(self.agent_count), AGENT_STATUS="1", + FOCUS="769", FOCUS_COUNT=str(self.focus_count)) result = subprocess.run(["bash", "-c", script, "restart"], env=env, text=True, capture_output=True, timeout=10, check=True) - self.assertEqual(game_id, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) - self.assertEqual(["--wait-steam-shortcut-overview", game_id, "45"], self.agent_args.read_text().splitlines()) + self.assertEqual(SHORTCUT, (self.launch_dir / "steam-game").read_text().strip(), result.stdout) + self.assertEqual(2, len(self.hand_offs(result)), result.stdout) def test_non_game_steam_urls_remain_on_the_original_startup_arguments(self): url = "steam://open/library" - result = self.run_launch(url, 0) + result = self.run_launch(url) self.assertIn("ARG:" + url, result.stdout) self.assertFalse((self.launch_dir / "steam-game").exists()) self.assertFalse(self.agent_args.exists()) From e4503129ea48857692e5efd4a3c7d2dba2bbdf11 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:31:14 -0400 Subject: [PATCH 005/156] fix: install Decky Loader from upstream repository (#1090) --- .../main/java/com/droiddeck/launcher/runtime/DeckyManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 4bf70d9e4..ba454af7a 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -18,7 +18,7 @@ import org.json.JSONObject object DeckyManager { private const val TAG = "DeckyManager" - const val REPOSITORY = "Droid-Deck/decky-loader" + const val REPOSITORY = "SteamDeckHomebrew/decky-loader" private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30" private const val PATH = "root/homebrew/services/PluginLoader" private const val VERSION = "root/homebrew/services/.droiddeck-decky-version" From df146d011f8c788c078deb9a0fe0f63555cc8af1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:11:30 -0400 Subject: [PATCH 006/156] fix: keep hidden and private Steam games off the Steam home art wall (#1097) --- .../droiddeck/launcher/frontend/Library.kt | 27 ++++++++++- .../frontend/LibraryConcealedAppsTest.kt | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 8fbb19b7c..743fea5d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -72,6 +72,7 @@ object Library { private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE) + private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"") private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE) internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List> { @@ -137,13 +138,35 @@ object Library { } fun steamLibraryArt(context: Context): List> { - val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache") + val root = SessionPrefs.activeSteamRoot(context) + val cache = File(root, "appcache/librarycache") + val concealed = concealedSteamApps(root) return cache.listFiles().orEmpty().mapNotNull { dir -> - val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null + val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it } } } + /** Apps the user hid (the "hidden" collection) or marked private in Steam. */ + internal fun concealedSteamApps(root: File): Set { + val config = AddedGames.accountConfig(root) ?: return emptySet() + val hidden = runCatching { + val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText()) + val entry = (0 until entries.length()).map { entries.getJSONArray(it) } + .firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1) + if (entry == null || entry.optBoolean("is_deleted")) emptySet() else { + val collection = org.json.JSONObject(entry.optString("value")) + fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty() + ids("added").toSet() - ids("removed").toSet() + } + }.getOrDefault(emptySet()) + val private = runCatching { + PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1) + ?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet() + }.getOrNull().orEmpty() + return hidden + private + } + fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM fun launchableGames( diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt new file mode 100644 index 000000000..8d00784cc --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryConcealedAppsTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.frontend + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class LibraryConcealedAppsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun steamRoot(): File = tmp.newFolder("Steam").also { root -> + File(root, "config").mkdirs() + File(root, "config/loginusers.vdf").writeText( + "\"users\"\n{\n\t\"76561198352563669\"\n\t{\n\t\t\"AccountName\"\t\t\"a\"\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + } + + private fun config(root: File) = File(root, "userdata/392297941/config").apply { mkdirs() } + + @Test fun hiddenCollectionAndPrivateAppsAreBothConcealed() { + val root = steamRoot() + val config = config(root) + File(config, "cloudstorage").mkdirs() + File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText( + """[["user-collections.favorite",{"key":"user-collections.favorite","value":"{\"id\":\"favorite\",\"added\":[620],\"removed\":[]}"}],""" + + """["user-collections.hidden",{"key":"user-collections.hidden","value":"{\"id\":\"hidden\",\"name\":\"Hidden\",\"added\":[223470,2931908220,400],\"removed\":[400]}"}]]""") + File(config, "localconfig.vdf").writeText( + "\"UserLocalConfigStore\"\n{\n\t\"WebStorage\"\n\t{\n\t\t\"PrivateApps_392297941\"\t\t\"[339800,567090]\"\n\t}\n}\n") + assertEquals(setOf(223470L, 2931908220L, 339800L, 567090L), Library.concealedSteamApps(root)) + } + + @Test fun deletedHiddenCollectionAndMissingFilesConcealNothing() { + val root = steamRoot() + assertEquals(emptySet(), Library.concealedSteamApps(root)) + val config = config(root) + File(config, "cloudstorage").mkdirs() + File(config, "cloudstorage/cloud-storage-namespace-1.json").writeText( + """[["user-collections.hidden",{"key":"user-collections.hidden","is_deleted":true}]]""") + assertEquals(emptySet(), Library.concealedSteamApps(root)) + } +} From 123fbc723c09488cc23c8e108356afa8b28075b6 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 19:24:09 -0400 Subject: [PATCH 007/156] fix(ci): give unit tests a 2 GB heap (#1091) Robolectric keeps one sandbox per SDK level the tests ask for, and the suite now asks for seven (26, 28, 29, 31, 33, 34 and the default). They no longer fit in Gradle's default 512 MB test heap, so testReleaseUnitTest runs out of memory part way through (SteamHomeHeadsetTest, GlassesMenuCaptureTest) on main and on every open PR. --- app/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 4799d4fe4..b38e1b35a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -192,6 +192,8 @@ android { unitTests.returnDefaultValues = true unitTests.includeAndroidResources = true unitTests.all { + // Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB. + maxHeapSize = '2g' def logTarget = providers.systemProperty('robolectric.logging').orNull if (logTarget != null) systemProperty 'robolectric.logging', logTarget } From 7800d3d81048b479d7b92b8629ee0271730cab13 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 19:26:26 -0400 Subject: [PATCH 008/156] fix: Steam client responds sooner and its sounds play on time (#1094) - Pace the session at the rate the panel actually runs. When the system keeps a lower rate than the mode DroidDeck asked for (a vendor game assistant holding 120 Hz while 185 Hz was requested), gamescope, Xwayland and the compositor's presentation feedback were told 185 Hz, and Steam's interface drew every 10.8 ms out of step with the panel. - Build the DirectAudio PulseAudio sink from source (LGPL-2.1-or-later, The412Banner/directaudio v1.1.0) with its max request kept to its ring target. Advertising 100 ms made PulseAudio grow every client's buffer to about 112 ms, so Steam's interface sounds played that late. --- README.md | 2 +- .../com/droiddeck/launcher/SessionActivity.kt | 5 +- .../glasses/GlassesMirrorPacingTest.kt | 7 + tools/aaudio-sink/build.sh | 8 +- tools/directaudio/sink/COPYING | 502 +++++++++++ tools/directaudio/sink/da_relay_proto.h | 209 +++++ .../sink/module-directaudio-native-sink.c | 782 ++++++++++++++++++ 7 files changed, 1509 insertions(+), 6 deletions(-) create mode 100644 tools/directaudio/sink/COPYING create mode 100644 tools/directaudio/sink/da_relay_proto.h create mode 100644 tools/directaudio/sink/module-directaudio-native-sink.c diff --git a/README.md b/README.md index b1b3bb7f8..d699d4419 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 008223ca5..9f145ab57 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1609,8 +1609,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { glassesScreen?.hz?.takeIf { it > 1f }?.let { return it } val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay val wanted = window.attributes.preferredDisplayModeId - val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate - ?: display?.refreshRate ?: 60f + val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate + val running = display?.refreshRate?.takeIf { it > 1f } + val hz = listOfNotNull(requested, running).minOrNull() ?: 60f val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap) return if (capped > 1f) capped else 60f diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt index 359fcfe30..b8306b936 100644 --- a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt @@ -62,6 +62,12 @@ class GlassesMirrorPacingTest { assertEquals(30, SessionState.fpsLimit) } + @Test fun pacingFollowsThePanelWhenTheSystemKeepsALowerRateThanRequested() { + Shadow.extract(activity.display).current = mode(2, 90f) + apply(GlassesState(), settings) + expect(3, 90f, 0) + } + private fun apply(state: GlassesState, config: GlassesSettings) { ReflectionHelpers.getField>(activity, "glasses\$delegate").value = state ReflectionHelpers.getField>(activity, "glassesSettings\$delegate").value = config @@ -83,5 +89,6 @@ class GlassesMirrorPacingTest { var supported = emptyArray() @Implementation protected fun getMode(): Display.Mode = current @Implementation protected fun getSupportedModes(): Array = supported + @Implementation override fun getRefreshRate(): Float = current.refreshRate } } diff --git a/tools/aaudio-sink/build.sh b/tools/aaudio-sink/build.sh index eed2a2abc..049675f49 100755 --- a/tools/aaudio-sink/build.sh +++ b/tools/aaudio-sink/build.sh @@ -23,7 +23,8 @@ if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == linux-aarch64 && -d "$NDK/toolchains/l TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" fi CC="$TOOLCHAIN/aarch64-linux-android${API}-clang" -if [[ ! -x "$CC" ]]; then +CC28="$TOOLCHAIN/aarch64-linux-android28-clang" +if [[ ! -x "$CC" || ! -x "$CC28" ]]; then echo "Android NDK toolchain not found at $TOOLCHAIN" >&2 exit 1 fi @@ -34,8 +35,8 @@ sed -e 's/@PA_MAJOR@/13/g' -e 's/@PA_MINOR@/0/g' -e 's/@PA_API_VERSION@/12/g' -e "$PA_SRC/src/pulse/version.h.in" > "$INC/pulse/version.h" cp "$HERE/config.h" "$HERE/ltdl.h" "$INC/" build_one() { - local src=$1 OUT=$2 extra=$3 - "$CC" -O2 -shared -fPIC -Wall -Wno-unused-parameter -Wno-error=int-conversion -Wno-visibility -DHAVE_CONFIG_H \ + local src=$1 OUT=$2 extra=$3 cc=${4:-$CC} + "$cc" -O2 -shared -fPIC -Wall -Wno-unused-parameter -Wno-error=int-conversion -Wno-visibility -DHAVE_CONFIG_H \ -I"$INC" -I"$PA_SRC/src" -I"$HERE" \ -o "$OUT" "$HERE/$src" \ -L"$LIBS" -l:libpulsecore-13.0.so -l:libpulsecommon-13.0.so -l:libpulse.so $extra @@ -54,3 +55,4 @@ build_one() { ls -l "$OUT" } build_one module-aaudio-sink.c "$OUTDIR/module-aaudio-sink.so" -laaudio +build_one ../directaudio/sink/module-directaudio-native-sink.c "$OUTDIR/module-directaudio-native-sink.so" -laaudio "$CC28" diff --git a/tools/directaudio/sink/COPYING b/tools/directaudio/sink/COPYING new file mode 100644 index 000000000..732811e4f --- /dev/null +++ b/tools/directaudio/sink/COPYING @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/tools/directaudio/sink/da_relay_proto.h b/tools/directaudio/sink/da_relay_proto.h new file mode 100644 index 000000000..351cdfb60 --- /dev/null +++ b/tools/directaudio/sink/da_relay_proto.h @@ -0,0 +1,209 @@ +/* DirectAudio relay - the contract between the driver and the relay helper. + * + * Copyright 2026 The412Banner + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * WHY THIS EXISTS. The driver's whole premise is an in-process AAudio client: + * the guest's WASAPI buffers are mixed and handed to Android's audio server + * from inside the game, no daemon in the path. That is only possible when the + * game process is a bionic process. Under a glibc runtime (the Linux Steam + * client, where the game runs inside a proot'd Arch rootfs on Valve's own + * ARM64 Proton) libaaudio cannot be loaded at all: it is a bionic library and + * the rootfs's dynamic linker does not know how to bring it into a glibc + * process. The game side therefore has to stop at a boundary it CAN cross, and + * the only thing both worlds agree on is the kernel: a unix socket, a memfd and + * a futex word. + * + * SHAPE. One helper process (directaudio-relay, bionic, links libaaudio) runs + * on the Android side and listens on a unix socket. Each game process that + * needs audio connects once, sends its launch config (the same knobs the + * in-process build reads from BANNER_AUDIO_DIRECT_*), and gets back one or two + * shared rings (memfds passed with SCM_RIGHTS): + * + * render ring game (producer) -> helper (consumer) -> AAudio OUTPUT + * capture ring helper (producer, AAudio INPUT) -> game (consumer) + * + * The driver keeps its mixer and its per-voice WASAPI rings exactly as in the + * in-process build; what used to be the AAudio data callback is now a pump + * thread that keeps the render ring topped up, and the capture callback is a + * pump that drains the capture ring into the capture voices. Everything that + * needs an AAudioStream (open, adaptive buffer growth and decay, route-change + * reopen, the stalled-callback watchdog, the live-config mailbox) moves to the + * helper unchanged in spirit. + * + * COST. One extra hand-off: the ring. The helper's callback pulls a burst from + * the ring, so the ring must hold at least one burst at every callback; the + * driver keeps DA_RING_TARGET_BURSTS bursts queued and the helper raises that + * target when a callback finds the ring short. Everything else - the AAudio + * buffer, AudioFlinger's own latency - is what the in-process build pays too. + * + * WIRE FORMAT. Fixed-width, native-endian, same-machine only. The two sides are + * different libcs but the same CPU, so no marshalling beyond fixed-size structs. + * Bump DA_RELAY_VERSION on any layout change; the helper refuses a mismatch. + */ +#ifndef DA_RELAY_PROTO_H +#define DA_RELAY_PROTO_H + +#include +#include + +#define DA_RELAY_VERSION 1 +#define DA_RING_MAGIC 0x44415247u /* 'DARG' */ +#define DA_HELLO_MAGIC 0x44414831u /* 'DAH1' */ +#define DA_ACK_MAGIC 0x44414131u /* 'DAA1' */ + +/* Socket path. The driver reads BANNER_AUDIO_DIRECT_RELAY; when unset it falls + * back to $XDG_RUNTIME_DIR/DA_RELAY_DEFAULT_NAME, which a host that already + * hands its runtime dir into the session gets for free. */ +#define DA_RELAY_ENV "BANNER_AUDIO_DIRECT_RELAY" +#define DA_RELAY_DEFAULT_NAME "directaudio-relay" + +/* Both rings carry float32 interleaved stereo. The render ring is always + * 48 kHz (the mixer's output rate); the capture ring runs at whatever rate the + * input stream was granted, published in da_ring.rate, and the driver resamples + * per voice as the in-process build already does. */ +#define DA_RING_RATE 48000 +#define DA_RING_CHANNELS 2 + +/* Render ring sizing. Capacity is the ceiling adaptive growth can reach; the + * initial target is what the driver keeps queued. Two bursts: the helper's + * callback consumes one burst and wakes the pump, which then has a full burst + * period to refill before the next callback. */ +#define DA_RING_CAP_MS 250 +#define DA_RING_TARGET_BURSTS 2 +#define DA_RING_MAX_TARGET_MS 100 + +/* Capture ring: 200 ms is far more than a game ever leaves queued; on overrun + * the helper drops the newest block, matching the in-process "guest is not + * draining" behaviour of dropping data rather than blocking the mic thread. */ +#define DA_CAPTURE_RING_MS 200 + +enum da_ring_state +{ + DA_RING_OPENING = 0, + DA_RING_PLAYING = 1, + DA_RING_ERROR = 2, +}; + +struct da_ring +{ + uint32_t magic; + uint32_t version; + uint32_t cap_frames; /* ring capacity in frames */ + uint32_t rate; /* frames per second of the data in this ring */ + uint32_t channels; /* DA_RING_CHANNELS */ + uint32_t _pad0; + + /* Single producer, single consumer; monotonic frame totals, wrap-safe with + * unsigned arithmetic. Producer writes widx with RELEASE after the data; + * consumer writes ridx with RELEASE after it has copied the data out. */ + volatile uint32_t widx; + volatile uint32_t ridx; + + /* Futex word the CONSUMER of a render ring / PRODUCER of a capture ring + * bumps after each block, so the other side can sleep on it instead of + * polling. Waiters always use a timeout, so a missed wake only costs one + * period. */ + volatile uint32_t wake; + + volatile uint32_t quit; /* either side: tear down */ + volatile uint32_t state; /* enum da_ring_state, helper-owned */ + + /* Render ring only, helper-owned, read by the driver: */ + volatile int32_t target_frames; /* keep at least this many queued */ + volatile int32_t hw_buf_frames; /* AAudio buffer size right now (get_latency) */ + volatile int32_t hw_burst; /* frames per burst of the output stream */ + volatile uint32_t underruns; /* callbacks that found fewer than they needed */ + volatile uint32_t cb_count; /* data callbacks so far (liveness) */ + uint32_t _pad1[3]; + + float data[]; /* cap_frames * channels */ +}; + +static inline size_t da_ring_bytes(uint32_t cap_frames) +{ + return sizeof(struct da_ring) + (size_t)cap_frames * DA_RING_CHANNELS * sizeof(float); +} + +static inline uint32_t da_ring_avail(const struct da_ring *r) +{ + uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE); + uint32_t rd = __atomic_load_n(&r->ridx, __ATOMIC_ACQUIRE); + return w - rd; +} + +static inline uint32_t da_ring_space(const struct da_ring *r) +{ + return r->cap_frames - da_ring_avail(r); +} + +/* ---- wire messages ------------------------------------------------------- */ + +#define DA_HELLO_MIC 0x01u /* also open an INPUT stream + capture ring */ +#define DA_HELLO_EXCLUSIVE 0x02u /* AAUDIO_SHARING_MODE_EXCLUSIVE requested */ +#define DA_HELLO_ADAPTIVE 0x04u +#define DA_HELLO_DECAY 0x08u +#define DA_HELLO_WATCHDOG 0x10u + +/* First message on a fresh connection, driver -> helper. Mirrors the launch + * config the in-process build derives from BANNER_AUDIO_DIRECT_* so a preset + * means the same thing under both runtimes. */ +struct da_hello +{ + uint32_t magic; /* DA_HELLO_MAGIC */ + uint32_t version; /* DA_RELAY_VERSION */ + uint32_t flags; /* DA_HELLO_* */ + int32_t perf; /* 0 NONE, 1 LOW_LATENCY, 2 POWER_SAVING (host convention) */ + int32_t target_ms; /* AAudio buffer, ms; 0 = use target_frames or the default */ + int32_t max_ms; /* adaptive ceiling, ms; 0 = max_frames or the default */ + int32_t target_frames; /* frame forms, used only when the ms forms are 0 */ + int32_t max_frames; + uint32_t stall_ms; /* watchdog: callback silent this long => rebuild */ + uint32_t quiet_ms; /* decay pacing, see the driver */ + uint32_t punish_ms; + uint32_t max_backoff; + int32_t pid; /* the game process, for the log */ + uint32_t log; /* BANNER_AUDIO_DIRECT_LOG */ + char runtime_path[256]; /* BANNER_AUDIO_DIRECT_RUNTIME mailbox, "" = none */ + char name[64]; /* process name, for the log */ +}; + +#define DA_ACK_MIC_FAILED 0x01u /* mic asked for, input stream would not open */ + +/* Reply, helper -> driver, followed by SCM_RIGHTS carrying the ring fds: + * fd[0] = render ring, fd[1] = capture ring (present iff in_cap_frames > 0). */ +struct da_ack +{ + uint32_t magic; /* DA_ACK_MAGIC */ + int32_t status; /* 0 = ok, else an AAudio error code (negative) */ + int32_t rate; /* output stream rate (48000) */ + int32_t burst; /* output frames per burst */ + int32_t buf_frames; /* output buffer size as opened */ + int32_t out_cap_frames; /* render ring capacity */ + int32_t in_cap_frames; /* capture ring capacity, 0 = no capture ring */ + int32_t in_rate; /* rate of the capture ring's data */ + uint32_t flags; /* DA_ACK_* */ + uint32_t _pad; +}; + +/* Subsequent messages, driver -> helper, on the same socket. The helper never + * writes after the ack; everything it has to say is in the ring header. EOF on + * the socket is how the helper learns the game is gone. */ +enum da_msg_type +{ + DA_MSG_MIC_START = 1, /* first capture voice started: make the mic hot */ + DA_MSG_MIC_STOP = 2, /* last capture voice gone: stop (keep open) */ + DA_MSG_PING = 3, +}; + +struct da_msg +{ + uint32_t type; + int32_t arg; +}; + +#endif /* DA_RELAY_PROTO_H */ diff --git a/tools/directaudio/sink/module-directaudio-native-sink.c b/tools/directaudio/sink/module-directaudio-native-sink.c new file mode 100644 index 000000000..208dac9ac --- /dev/null +++ b/tools/directaudio/sink/module-directaudio-native-sink.c @@ -0,0 +1,782 @@ +/* + * module-directaudio-native-sink: a PulseAudio 13 sink that is DirectAudio's AAudio engine, + * in-process. For the native Linux Steam client on Android (DroidDeck, Bannerlator's Linux + * session), whose sound can only ever reach a PulseAudio daemon: this module is where that + * daemon's mixed output meets Android, one step away from AudioFlinger, with the same engine the + * game driver has - adaptive device buffer that grows a burst on every xrun, optional decay with a + * remembered floor, a primed ring with fades instead of clicks, route-change / error reopen on a + * fresh stream, and a stall watchdog. + * + * It is the relay helper's per-client OUTPUT engine (directaudio-relay.c) with the socket and the + * shared-memory ring replaced by a ring in this process, fed by the sink's IO thread exactly as + * module-directaudio-sink feeds the relay. Compared with that module there is no second process + * and no 40 ms proot safety margin: the ring here only has to cover the IO thread's own + * scheduling jitter, so it starts at two bursts and grows only if a callback finds it short. + * + * The daemon must run on the Android side (the host app's process), not inside proot, for AAudio + * to be reachable at all - which is where both host apps already run it. + * + * Field fixes carried from the relay (MaxsTechReview, Droid-Deck/DroidDeck#338): prime before + * playing, fade on an underrun, one consumer per ring across a reopen, a device buffer of at + * least two bursts. + * + * SPDX-License-Identifier: LGPL-2.1-or-later + */ +#ifdef HAVE_CONFIG_H +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "da_relay_proto.h" /* struct da_ring + the ring helpers; nothing of the socket protocol */ + +PA_MODULE_AUTHOR("The412Banner"); +PA_MODULE_DESCRIPTION("Android audio output: DirectAudio's AAudio engine, in-process"); +PA_MODULE_VERSION(PACKAGE_VERSION); +PA_MODULE_LOAD_ONCE(false); +PA_MODULE_USAGE( + "sink_name= " + "sink_properties= " + "volume= " + "performance_mode=<0 none, 1 low latency, 2 power saving> " + "buffer_ms= " + "max_ms= " + "ring_ms= " + "adaptive= " + "decay= " + "limiter= " + "watchdog="); + +#define DEFAULT_SINK_NAME "DirectAudio" +#define STATS_EVERY_USEC (30 * PA_USEC_PER_SEC) +#define WATCHDOG_TICK_USEC (1 * PA_USEC_PER_SEC) +#define AUDIO_NICE (-16) + +/* ---- tunables: the in-process driver's, same defaults ----------------------- */ +#define DA_KNEE 0.75f +#define DA_DEFAULT_MS 12 +#define DA_DEFAULT_MAX_MS 100 +#define DA_DEFAULT_RING_MS 12 +#define DA_RING_CAP_MS_LOCAL 250 +#define DA_FREEZE_GAP_NS 500000000ull +#define DA_CB_STALL_NS 1000000000ull +#define DA_DECAY_QUIET_NS 10000000000ull +#define DA_DECAY_PUNISH_NS 5000000000ull +#define DA_DECAY_MAX_BACKOFF 32 +#define DA_MIN_BUFFER_BURSTS 2 +#define DA_FADE_FRAMES 64 + +static const char* const valid_modargs[] = { + "sink_name", "sink_properties", "volume", "performance_mode", "buffer_ms", "max_ms", "ring_ms", + "adaptive", "decay", "limiter", "watchdog", NULL +}; + +struct userdata { + pa_core *core; + pa_module *module; + pa_sink *sink; + + pa_thread *thread; + pa_thread_mq thread_mq; + pa_rtpoll *rtpoll; + + pa_sample_spec ss; + size_t frame_size; + + /* configuration */ + aaudio_performance_mode_t perf; + int32_t target_ms, max_ms, ring_ms; + bool adaptive, decay, limiter, watchdog_on; + + /* the ring: this process's, same layout as the relay's so the engine is shared */ + struct da_ring *ring; + int32_t burst; + int32_t ring_max_target; + + /* the one OUTPUT stream and its adaptive state (the relay's struct client, minus the socket) */ + AAudioStream *aq; + pthread_mutex_t lock; + int32_t last_xrun; + uint64_t last_cb_ns, last_xrun_ns, last_decay_ns, wd_last_tick_ns; + int32_t base_buf_frames, decay_floor; + unsigned int decay_backoff, cb_count, wd_last_cb; + int reopen_running, reopen_redo; + int consuming, primed, starved; + volatile int dead; + + /* what the IO thread last logged, so engine changes made on the AAudio thread get a line */ + int32_t seen_target, seen_hw_buf; + uint32_t seen_underruns; + pa_usec_t next_watchdog, settled_at, stats_at; + unsigned int opens; +}; + +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t) ts.tv_sec * 1000000000ull + (uint64_t) ts.tv_nsec; +} + +static void request_reopen(struct userdata *u, const char *why); + +/* ---- the engine: the relay's out_cb / open_output / reopen / watchdog, on a local ring ---- */ + +static void fade(float *p, uint32_t frames, int in) { + uint32_t i, ch; + + for (i = 0; i < frames; i++) { + float g = (float) (in ? i + 1 : frames - i) / (float) (frames + 1); + + for (ch = 0; ch < DA_RING_CHANNELS; ch++) + p[(size_t) i * DA_RING_CHANNELS + ch] *= g; + } +} + +/* The driver's soft-knee limiter: below DA_KNEE the signal is untouched, so ordinary material is + * bit-identical; above it a smooth knee folds the overshoot back under full scale. */ +static void limit(float *p, uint32_t samples) { + uint32_t i; + + for (i = 0; i < samples; i++) { + float x = p[i], a = x < 0.0f ? -x : x; + + if (a > DA_KNEE) { + float t = (a - DA_KNEE) / (1.0f - DA_KNEE); + float y = DA_KNEE + (1.0f - DA_KNEE) * (t / (1.0f + t)); + + p[i] = x < 0.0f ? -y : y; + } + } +} + +static aaudio_data_callback_result_t out_cb(AAudioStream *aq, void *user, void *audioData, int32_t numFrames) { + struct userdata *u = user; + struct da_ring *r = u->ring; + float *out = audioData; + AAudioStream *live = __atomic_load_n(&u->aq, __ATOMIC_SEQ_CST); + uint32_t cap = r->cap_frames; + uint32_t frames = (uint32_t) numFrames; + uint32_t ridx, avail, n = 0, i; + int fade_in = 0, short_read = 0, starving = 0; + uint64_t now; + + /* Only the promoted stream reads the ring; an outgoing one during a reopen plays silence. */ + if ((live && aq != live) || __atomic_exchange_n(&u->consuming, 1, __ATOMIC_ACQUIRE)) { + memset(out, 0, (size_t) frames * DA_RING_CHANNELS * sizeof(float)); + return AAUDIO_CALLBACK_RESULT_CONTINUE; + } + + ridx = __atomic_load_n(&r->ridx, __ATOMIC_ACQUIRE); + avail = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE) - ridx; + if (!u->primed) { + uint32_t start = (uint32_t) __atomic_load_n(&r->target_frames, __ATOMIC_ACQUIRE); + + if (start < frames) start = frames; + if (start > cap) start = cap; + u->primed = fade_in = avail >= start; + if (u->primed) u->starved = 0; + else starving = u->starved; + } + if (u->primed) { + uint32_t slot = ridx % cap, first; + + n = frames < avail ? frames : avail; + first = cap - slot < n ? cap - slot : n; + memcpy(out, &r->data[(size_t) slot * DA_RING_CHANNELS], (size_t) first * DA_RING_CHANNELS * sizeof(float)); + if (n > first) + memcpy(out + (size_t) first * DA_RING_CHANNELS, r->data, (size_t) (n - first) * DA_RING_CHANNELS * sizeof(float)); + if (u->limiter) + limit(out, n * DA_RING_CHANNELS); + if (fade_in) + fade(out, n < DA_FADE_FRAMES ? n : DA_FADE_FRAMES, 1); + if (n < frames) { + uint32_t tail = n < DA_FADE_FRAMES ? n : DA_FADE_FRAMES; + + fade(out + (size_t) (n - tail) * DA_RING_CHANNELS, tail, 0); + u->primed = 0; + u->starved = 1; + short_read = 1; + } + __atomic_store_n(&r->ridx, ridx + n, __ATOMIC_RELEASE); + } + for (i = n * DA_RING_CHANNELS; i < frames * DA_RING_CHANNELS; i++) + out[i] = 0.0f; + __atomic_store_n(&u->consuming, 0, __ATOMIC_RELEASE); + + now = now_ns(); + { + uint64_t prev = __atomic_exchange_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST); + + if (prev && now - prev > DA_FREEZE_GAP_NS) { + u->last_xrun = AAudioStream_getXRunCount(aq); + u->last_xrun_ns = now; + } + } + __atomic_add_fetch(&u->cb_count, 1, __ATOMIC_SEQ_CST); + __atomic_store_n(&r->cb_count, u->cb_count, __ATOMIC_RELAXED); + + /* The ring's own adaptive step: a short ring means the IO thread did not get scheduled in + * time - an xrun one stage earlier - so keep one more burst queued. Only grows. */ + if (short_read) + __atomic_add_fetch(&r->underruns, 1, __ATOMIC_RELAXED); + if ((short_read || starving) && u->adaptive) { + int32_t burst = AAudioStream_getFramesPerBurst(aq); + int32_t cur = __atomic_load_n(&r->target_frames, __ATOMIC_RELAXED); + int32_t want = cur + (burst > 0 ? burst : numFrames); + + if (want > u->ring_max_target) want = u->ring_max_target; + if (want > cur) + __atomic_store_n(&r->target_frames, want, __ATOMIC_RELEASE); + } + + /* The device buffer: grow a burst per xrun; decay a burst after a quiet spell, never below + * the floor a punished decay taught us. */ + if (u->adaptive) { + int32_t xruns = AAudioStream_getXRunCount(aq); + int32_t burst = AAudioStream_getFramesPerBurst(aq); + int32_t cur = AAudioStream_getBufferSizeInFrames(aq); + + if (xruns > u->last_xrun) { + int32_t capf = AAudioStream_getBufferCapacityInFrames(aq); + int32_t want = cur + (burst > 0 ? burst : 1); + int32_t max_frames = u->max_ms > 0 ? u->max_ms * DA_RING_RATE / 1000 : 0; + + if (max_frames > 0 && want > max_frames) want = max_frames; + if (want > capf) want = capf; + if (want > cur) AAudioStream_setBufferSizeInFrames(aq, want); + + if (u->decay && u->last_decay_ns && now - u->last_decay_ns < DA_DECAY_PUNISH_NS) { + if (want > u->decay_floor) u->decay_floor = want; + if (u->decay_backoff < DA_DECAY_MAX_BACKOFF) u->decay_backoff *= 2; + } + u->last_xrun = xruns; + u->last_xrun_ns = now; + __atomic_store_n(&r->hw_buf_frames, AAudioStream_getBufferSizeInFrames(aq), __ATOMIC_RELAXED); + } else if (u->decay && burst > 0) { + uint64_t quiet = DA_DECAY_QUIET_NS * u->decay_backoff; + int32_t floor = u->base_buf_frames; + + if (u->decay_floor > floor) floor = u->decay_floor; + if (cur > floor && u->last_xrun_ns && now - u->last_xrun_ns > quiet && + (!u->last_decay_ns || now - u->last_decay_ns > quiet)) { + int32_t want = cur - burst; + + if (want < floor) want = floor; + if (want < cur) { + AAudioStream_setBufferSizeInFrames(aq, want); + u->last_decay_ns = now; + __atomic_store_n(&r->hw_buf_frames, AAudioStream_getBufferSizeInFrames(aq), __ATOMIC_RELAXED); + } + } + } + } + + return AAUDIO_CALLBACK_RESULT_CONTINUE; +} + +static void out_error_cb(AAudioStream *aq, void *user, aaudio_result_t error) { + struct userdata *u = user; + + if (aq != __atomic_load_n(&u->aq, __ATOMIC_SEQ_CST)) return; + /* A disconnected stream (headphones plugged or unplugged, a Bluetooth route coming or going) + * can never be restarted: it has to be closed and a new one opened on the current route. */ + if (error != AAUDIO_ERROR_DISCONNECTED && + error != AAUDIO_ERROR_INVALID_STATE && + error != AAUDIO_ERROR_INVALID_HANDLE && + error != AAUDIO_ERROR_TIMEOUT) + return; + request_reopen(u, "stream error"); +} + +/* 48 kHz / float / stereo, buffer from the ms form rounded up to a burst and never below two, + * capacity from the ceiling. The ring target floor is two bursts or ring_ms, whichever is more. */ +static aaudio_result_t open_output(struct userdata *u, AAudioStream **out) { + AAudioStreamBuilder *builder = NULL; + AAudioStream *aq = NULL; + aaudio_result_t res; + int32_t capf, burst, want; + + res = AAudio_createStreamBuilder(&builder); + if (res != AAUDIO_OK || !builder) return res != AAUDIO_OK ? res : AAUDIO_ERROR_NO_MEMORY; + + AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT); + AAudioStreamBuilder_setSharingMode(builder, AAUDIO_SHARING_MODE_SHARED); + AAudioStreamBuilder_setPerformanceMode(builder, u->perf); + AAudioStreamBuilder_setUsage(builder, AAUDIO_USAGE_GAME); + AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_FLOAT); + AAudioStreamBuilder_setChannelCount(builder, DA_RING_CHANNELS); + AAudioStreamBuilder_setSampleRate(builder, DA_RING_RATE); + AAudioStreamBuilder_setDataCallback(builder, out_cb, u); + AAudioStreamBuilder_setErrorCallback(builder, out_error_cb, u); + AAudioStreamBuilder_setBufferCapacityInFrames(builder, (u->max_ms > 0 ? u->max_ms : DA_DEFAULT_MAX_MS) * DA_RING_RATE / 1000); + + res = AAudioStreamBuilder_openStream(builder, &aq); + AAudioStreamBuilder_delete(builder); + if (res != AAUDIO_OK || !aq) return res != AAUDIO_OK ? res : AAUDIO_ERROR_INTERNAL; + + capf = AAudioStream_getBufferCapacityInFrames(aq); + burst = AAudioStream_getFramesPerBurst(aq); + want = (u->target_ms > 0 ? u->target_ms : DA_DEFAULT_MS) * DA_RING_RATE / 1000; + if (burst > 0) { + int32_t nb = (want + burst - 1) / burst; + want = (nb < 1 ? 1 : nb) * burst; + if (want < DA_MIN_BUFFER_BURSTS * burst) want = DA_MIN_BUFFER_BURSTS * burst; + } + if (want > capf) want = capf; + if (want > 0) AAudioStream_setBufferSizeInFrames(aq, want); + + if (burst <= 0) burst = 192; + u->burst = burst; + u->ring_max_target = DA_RING_MAX_TARGET_MS * DA_RING_RATE / 1000; + if (u->ring_max_target > (int32_t) u->ring->cap_frames) u->ring_max_target = (int32_t) u->ring->cap_frames; + { + int32_t floor = DA_RING_TARGET_BURSTS * burst; + int32_t asked = (u->ring_ms > 0 ? u->ring_ms : DA_DEFAULT_RING_MS) * DA_RING_RATE / 1000; + + if (floor < asked) floor = asked; + if (floor > u->ring_max_target) floor = u->ring_max_target; + if (__atomic_load_n(&u->ring->target_frames, __ATOMIC_ACQUIRE) < floor) + __atomic_store_n(&u->ring->target_frames, floor, __ATOMIC_RELEASE); + } + __atomic_store_n(&u->ring->hw_burst, burst, __ATOMIC_RELAXED); + + u->last_xrun = AAudioStream_getXRunCount(aq); + u->base_buf_frames = AAudioStream_getBufferSizeInFrames(aq); + u->decay_floor = 0; + u->decay_backoff = 1; + u->last_xrun_ns = now_ns(); + u->last_decay_ns = 0; + __atomic_store_n(&u->ring->hw_buf_frames, u->base_buf_frames, __ATOMIC_RELAXED); + + pa_log("directaudio-native-sink: open #%u: device buffer %d frames (%d ms), burst %d, capacity %d, performance mode %d (asked %d), ring target %d frames - Android adds its own output latency on top", + ++u->opens, u->base_buf_frames, u->base_buf_frames * 1000 / DA_RING_RATE, AAudioStream_getFramesPerBurst(aq), + capf, AAudioStream_getPerformanceMode(aq), u->perf, (int) u->ring->target_frames); + + res = AAudioStream_requestStart(aq); + if (res != AAUDIO_OK) { AAudioStream_close(aq); return res; } + + __atomic_store_n(&u->last_cb_ns, now_ns(), __ATOMIC_SEQ_CST); + u->wd_last_tick_ns = 0; + __atomic_store_n(&u->ring->state, DA_RING_PLAYING, __ATOMIC_RELEASE); + *out = aq; + return AAUDIO_OK; +} + +static void *reopen_thread(void *user) { + struct userdata *u = user; + + for (;;) { + AAudioStream *old = NULL, *neu = NULL; + int expected = 0; + + __atomic_store_n(&u->reopen_redo, 0, __ATOMIC_SEQ_CST); + if (u->dead) break; + + if (open_output(u, &neu) == AAUDIO_OK) { + pthread_mutex_lock(&u->lock); + old = u->aq; + __atomic_store_n(&u->aq, neu, __ATOMIC_SEQ_CST); + pthread_mutex_unlock(&u->lock); + } else + pa_log("directaudio-native-sink: reopen failed; keeping the old stream"); + + if (old) { + AAudioStream_requestStop(old); + AAudioStream_close(old); + pa_log("directaudio-native-sink: reopened the output on the current route"); + } + + if (__atomic_load_n(&u->reopen_redo, __ATOMIC_SEQ_CST)) { usleep(20000); continue; } + __atomic_store_n(&u->reopen_running, 0, __ATOMIC_SEQ_CST); + if (!__atomic_load_n(&u->reopen_redo, __ATOMIC_SEQ_CST)) break; + if (!__atomic_compare_exchange_n(&u->reopen_running, &expected, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) + break; + } + return NULL; +} + +static void request_reopen(struct userdata *u, const char *why) { + int expected = 0; + + __atomic_store_n(&u->reopen_redo, 1, __ATOMIC_SEQ_CST); + if (__atomic_compare_exchange_n(&u->reopen_running, &expected, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) { + pthread_t th; + + pa_log("directaudio-native-sink: reopen: %s", why); + if (pthread_create(&th, NULL, reopen_thread, u)) + __atomic_store_n(&u->reopen_running, 0, __ATOMIC_SEQ_CST); + else + pthread_detach(th); + } +} + +/* Ticked once a second from the IO thread: a callback silent for a second is a dead stream. */ +static void watchdog(struct userdata *u) { + uint64_t now = now_ns(), last_cb, prev_tick; + unsigned int cb; + + if (!u->watchdog_on) return; + if (!__atomic_load_n(&u->aq, __ATOMIC_SEQ_CST)) return; + + prev_tick = u->wd_last_tick_ns; + u->wd_last_tick_ns = now; + cb = __atomic_load_n(&u->cb_count, __ATOMIC_SEQ_CST); + + if (!prev_tick || now - prev_tick > 2 * DA_FREEZE_GAP_NS + 1000000000ull) { + u->wd_last_cb = cb; + __atomic_store_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST); + return; + } + if (cb != u->wd_last_cb) { u->wd_last_cb = cb; return; } + + last_cb = __atomic_load_n(&u->last_cb_ns, __ATOMIC_SEQ_CST); + if (!last_cb || now - last_cb < DA_CB_STALL_NS) return; + + __atomic_store_n(&u->last_cb_ns, now, __ATOMIC_SEQ_CST); + request_reopen(u, "data callback stalled"); +} + +static void close_output(struct userdata *u) { + AAudioStream *aq; + + u->dead = 1; + pthread_mutex_lock(&u->lock); + aq = u->aq; + __atomic_store_n(&u->aq, NULL, __ATOMIC_SEQ_CST); + pthread_mutex_unlock(&u->lock); + if (aq) { + AAudioStream_requestStop(aq); + AAudioStream_close(aq); + } + /* A reopen still in flight sees u->dead and stops; give it a moment to let go of u. */ + while (__atomic_load_n(&u->reopen_running, __ATOMIC_SEQ_CST)) + usleep(10000); +} + +/* ---- the PulseAudio side: module-directaudio-sink's producer, on the local ring ---- */ + +static pa_usec_t frames_to_usec(const struct userdata *u, int64_t frames) { + return (pa_usec_t) (frames * (int64_t) PA_USEC_PER_SEC / (int64_t) u->ss.rate); +} + +static pa_usec_t sink_latency(struct userdata *u) { + return frames_to_usec(u, (int64_t) da_ring_avail(u->ring) + (u->ring->hw_buf_frames > 0 ? u->ring->hw_buf_frames : 0)); +} + +static void produce(struct userdata *u, uint32_t frames) { + struct da_ring *r = u->ring; + uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE); + uint32_t pos = w % r->cap_frames; + uint32_t first = PA_MIN(frames, r->cap_frames - pos); + float *dst = r->data + (size_t) pos * DA_RING_CHANNELS; + size_t bytes = (size_t) frames * u->frame_size; + + if (PA_SINK_IS_OPENED(u->sink->thread_info.state)) { + pa_memchunk chunk; + const uint8_t *src; + + if (u->sink->thread_info.rewind_requested) + pa_sink_process_rewind(u->sink, 0); + pa_sink_render_full(u->sink, bytes, &chunk); + src = pa_memblock_acquire_chunk(&chunk); + memcpy(dst, src, (size_t) first * u->frame_size); + if (frames > first) + memcpy(r->data, src + (size_t) first * u->frame_size, (size_t) (frames - first) * u->frame_size); + pa_memblock_release(chunk.memblock); + pa_memblock_unref(chunk.memblock); + } else { + memset(dst, 0, (size_t) first * u->frame_size); + if (frames > first) + memset(r->data, 0, (size_t) (frames - first) * u->frame_size); + } + __atomic_store_n(&r->widx, w + frames, __ATOMIC_RELEASE); +} + +/* Tops the ring up to the engine's target and narrates what the engine changed. */ +static void service_ring(struct userdata *u) { + struct da_ring *r = u->ring; + uint32_t avail, space; + int32_t target = __atomic_load_n(&r->target_frames, __ATOMIC_ACQUIRE); + int32_t hw = __atomic_load_n(&r->hw_buf_frames, __ATOMIC_RELAXED); + uint32_t under = __atomic_load_n(&r->underruns, __ATOMIC_RELAXED); + pa_usec_t now = pa_rtclock_now(); + + if (target <= 0) target = 2 * u->burst; + avail = da_ring_avail(r); + space = da_ring_space(r); + if (avail < (uint32_t) target) { + uint32_t want = PA_MIN((uint32_t) target - avail, space); + + if (want > 0) + produce(u, want); + } + if (target != u->seen_target || hw != u->seen_hw_buf) { + pa_sink_set_max_request_within_thread(u->sink, (size_t) target * u->frame_size); + pa_sink_set_fixed_latency_within_thread(u->sink, frames_to_usec(u, (int64_t) target + (hw > 0 ? hw : 0))); + } + if (target != u->seen_target) { + if (u->seen_target) + pa_log("directaudio-native-sink: ring grow: target %d -> %d frames (%d ms)", u->seen_target, target, target * 1000 / DA_RING_RATE); + u->seen_target = target; + } + if (hw != u->seen_hw_buf) { + if (u->seen_hw_buf) + pa_log("directaudio-native-sink: device buffer %d -> %d frames (%d ms)", u->seen_hw_buf, hw, hw * 1000 / DA_RING_RATE); + u->seen_hw_buf = hw; + } + if (u->settled_at && now >= u->settled_at) { + pa_log("directaudio-native-sink: after 10 s: device buffer %d frames, ring target %d, ring underruns %u, stream xruns %d", + hw, target, under, u->aq ? AAudioStream_getXRunCount(u->aq) : -1); + u->settled_at = 0; + } + if (under != u->seen_underruns && now - u->stats_at >= STATS_EVERY_USEC) { + pa_log("directaudio-native-sink: %u ring underrun(s) so far, target %d frames, device buffer %d", under, target, hw); + u->seen_underruns = under; + u->stats_at = now; + } + if (now >= u->next_watchdog) { + watchdog(u); + u->next_watchdog = now + WATCHDOG_TICK_USEC; + } +} + +static int sink_process_msg(pa_msgobject *o, int code, void *data, int64_t offset, pa_memchunk *chunk) { + struct userdata *u = PA_SINK(o)->userdata; + + switch (code) { + case PA_SINK_MESSAGE_GET_LATENCY: + *((pa_usec_t*) data) = sink_latency(u); + return 0; + } + return pa_sink_process_msg(o, code, data, offset, chunk); +} + +static void thread_func(void *userdata) { + struct userdata *u = userdata; + + pa_log_debug("IO thread starting"); + pa_thread_mq_install(&u->thread_mq); + if (setpriority(PRIO_PROCESS, (id_t) syscall(SYS_gettid), AUDIO_NICE) < 0) + pa_log_debug("directaudio-native-sink: could not raise the IO thread's priority (%s)", strerror(errno)); + if (u->core->realtime_scheduling) + pa_thread_make_realtime(u->core->realtime_priority); + pa_rtpoll_set_timer_relative(u->rtpoll, 0); + + for (;;) { + int ret; + + /* Half a burst between visits, never blocking on the stream: clients' audio reaches this + * thread as messages pa_rtpoll_run handles, and a thread stuck in a write or a wait lets + * them run dry (the crackle #338 fixed). */ + service_ring(u); + pa_rtpoll_set_timer_relative(u->rtpoll, PA_MAX(frames_to_usec(u, u->burst) / 2, PA_USEC_PER_MSEC)); + + if ((ret = pa_rtpoll_run(u->rtpoll)) < 0) + goto fail; + if (ret == 0) + goto finish; + } + +fail: + pa_asyncmsgq_post(u->thread_mq.outq, PA_MSGOBJECT(u->core), PA_CORE_MESSAGE_UNLOAD_MODULE, u->module, 0, NULL, NULL); + pa_asyncmsgq_wait_for(u->thread_mq.inq, PA_MESSAGE_SHUTDOWN); + +finish: + pa_log_debug("IO thread shutting down"); +} + +void pa__done(pa_module *m); + +int pa__init(pa_module *m) { + struct userdata *u = NULL; + pa_modargs *ma = NULL; + pa_sink_new_data data; + pa_channel_map map; + pa_cvolume volume; + uint32_t perf = 1, buffer_ms = DA_DEFAULT_MS, max_ms = DA_DEFAULT_MAX_MS, ring_ms = DA_DEFAULT_RING_MS; + double linear_volume = 1.0; + bool adaptive = true, decay = false, limiter = true, watchdog_on = true; + AAudioStream *aq = NULL; + aaudio_result_t res; + uint32_t cap_frames; + + pa_assert(m); + + if (!(ma = pa_modargs_new(m->argument, valid_modargs))) { + pa_log("Failed to parse module arguments."); + goto fail; + } + if (pa_modargs_get_value_u32(ma, "performance_mode", &perf) < 0 || perf > 2) { + pa_log("performance_mode must be 0, 1 or 2"); + goto fail; + } + if (pa_modargs_get_value_u32(ma, "buffer_ms", &buffer_ms) < 0 || buffer_ms == 0 || buffer_ms > 200) { + pa_log("buffer_ms must be between 1 and 200"); + goto fail; + } + if (pa_modargs_get_value_u32(ma, "max_ms", &max_ms) < 0 || max_ms < buffer_ms || max_ms > 250) { + pa_log("max_ms must be between buffer_ms and 250"); + goto fail; + } + if (pa_modargs_get_value_u32(ma, "ring_ms", &ring_ms) < 0 || ring_ms == 0 || ring_ms > 100) { + pa_log("ring_ms must be between 1 and 100"); + goto fail; + } + if (pa_modargs_get_value_boolean(ma, "adaptive", &adaptive) < 0 || + pa_modargs_get_value_boolean(ma, "decay", &decay) < 0 || + pa_modargs_get_value_boolean(ma, "limiter", &limiter) < 0 || + pa_modargs_get_value_boolean(ma, "watchdog", &watchdog_on) < 0) { + pa_log("adaptive, decay, limiter and watchdog must be 0 or 1"); + goto fail; + } + if (pa_modargs_get_value_double(ma, "volume", &linear_volume) < 0 || linear_volume < 0.0 || linear_volume > 10.0) { + pa_log("volume must be a linear factor between 0 and 10"); + goto fail; + } + + m->userdata = u = pa_xnew0(struct userdata, 1); + u->core = m->core; + u->module = m; + u->perf = perf == 0 ? AAUDIO_PERFORMANCE_MODE_NONE : perf == 2 ? AAUDIO_PERFORMANCE_MODE_POWER_SAVING : AAUDIO_PERFORMANCE_MODE_LOW_LATENCY; + u->target_ms = (int32_t) buffer_ms; + u->max_ms = (int32_t) max_ms; + u->ring_ms = (int32_t) ring_ms; + u->adaptive = adaptive; + u->decay = decay; + u->limiter = limiter; + u->watchdog_on = watchdog_on; + u->ss.format = PA_SAMPLE_FLOAT32NE; + u->ss.rate = DA_RING_RATE; + u->ss.channels = DA_RING_CHANNELS; + u->frame_size = pa_frame_size(&u->ss); + u->burst = 192; + pthread_mutex_init(&u->lock, NULL); + pa_channel_map_init_stereo(&map); + + /* The ring: the relay's layout in this process's memory. */ + cap_frames = DA_RING_CAP_MS_LOCAL * DA_RING_RATE / 1000; + u->ring = pa_xmalloc0(da_ring_bytes(cap_frames)); + u->ring->magic = DA_RING_MAGIC; + u->ring->version = DA_RELAY_VERSION; + u->ring->cap_frames = cap_frames; + u->ring->rate = DA_RING_RATE; + u->ring->channels = DA_RING_CHANNELS; + u->ring->target_frames = 0; + + if ((res = open_output(u, &aq)) != AAUDIO_OK) { + pa_log("directaudio-native-sink: could not open the AAudio output (%s); is the daemon running on the Android side?", AAudio_convertResultToText(res)); + goto fail; + } + __atomic_store_n(&u->aq, aq, __ATOMIC_SEQ_CST); + u->seen_hw_buf = u->base_buf_frames; + u->seen_target = u->ring->target_frames; + u->settled_at = pa_rtclock_now() + 10 * PA_USEC_PER_SEC; + u->stats_at = pa_rtclock_now(); + + u->rtpoll = pa_rtpoll_new(); + if (pa_thread_mq_init(&u->thread_mq, m->core->mainloop, u->rtpoll) < 0) { + pa_log("pa_thread_mq_init() failed."); + goto fail; + } + + pa_sink_new_data_init(&data); + data.driver = __FILE__; + data.module = m; + pa_sink_new_data_set_name(&data, pa_modargs_get_value(ma, "sink_name", DEFAULT_SINK_NAME)); + pa_sink_new_data_set_sample_spec(&data, &u->ss); + pa_sink_new_data_set_channel_map(&data, &map); + pa_cvolume_set(&volume, u->ss.channels, pa_sw_volume_from_linear(linear_volume)); + pa_sink_new_data_set_volume(&data, &volume); + pa_proplist_sets(data.proplist, PA_PROP_DEVICE_STRING, "directaudio"); + pa_proplist_sets(data.proplist, PA_PROP_DEVICE_DESCRIPTION, "Android audio output (DirectAudio)"); + pa_proplist_sets(data.proplist, PA_PROP_DEVICE_CLASS, "sound"); + pa_proplist_sets(data.proplist, PA_PROP_DEVICE_API, "directaudio"); + if (pa_modargs_get_proplist(ma, "sink_properties", data.proplist, PA_UPDATE_REPLACE) < 0) { + pa_log("Invalid properties"); + pa_sink_new_data_done(&data); + goto fail; + } + u->sink = pa_sink_new(m->core, &data, PA_SINK_HARDWARE | PA_SINK_LATENCY); + pa_sink_new_data_done(&data); + if (!u->sink) { + pa_log("Failed to create sink."); + goto fail; + } + u->sink->parent.process_msg = sink_process_msg; + u->sink->userdata = u; + pa_sink_set_asyncmsgq(u->sink, u->thread_mq.inq); + pa_sink_set_rtpoll(u->sink, u->rtpoll); + pa_sink_set_max_request(u->sink, (size_t) u->ring->target_frames * u->frame_size); + /* What clients should keep queued on their side: the ring's starting target plus the device + * buffer. Grows are reported live through GET_LATENCY. */ + pa_sink_set_fixed_latency(u->sink, frames_to_usec(u, (int64_t) u->ring->target_frames + u->base_buf_frames)); + + if (!(u->thread = pa_thread_new("directaudio-native-sink", thread_func, u))) { + pa_log("Failed to create thread."); + goto fail; + } + pa_sink_put(u->sink); + pa_modargs_free(ma); + return 0; + +fail: + if (ma) + pa_modargs_free(ma); + pa__done(m); + return -1; +} + +int pa__get_n_used(pa_module *m) { + struct userdata *u; + + pa_assert(m); + pa_assert_se(u = m->userdata); + return pa_sink_linked_by(u->sink); +} + +void pa__done(pa_module *m) { + struct userdata *u; + + pa_assert(m); + if (!(u = m->userdata)) + return; + if (u->sink) + pa_sink_unlink(u->sink); + if (u->thread) { + pa_asyncmsgq_send(u->thread_mq.inq, NULL, PA_MESSAGE_SHUTDOWN, NULL, 0, NULL); + pa_thread_free(u->thread); + } + pa_thread_mq_done(&u->thread_mq); + if (u->sink) + pa_sink_unref(u->sink); + close_output(u); + if (u->rtpoll) + pa_rtpoll_free(u->rtpoll); + pthread_mutex_destroy(&u->lock); + pa_xfree(u->ring); + pa_xfree(u); +} From 48a70c8229c6988df6bfe9af7bc442c6e4218104 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 20:38:08 -0400 Subject: [PATCH 009/156] Boot Linux games under the FEX compatibility tool again (#1100) Valve's FEX opens rootfs paths with openat2(RESOLVE_IN_ROOT), which the preload emulates under proot. droiddeck-fex's rootfs links to the Steam runtimes with absolute paths outside the rootfs, so every lookup missed and FEX fell back to the arm64 host files: x86 bash, and with it Stardew Valley's launcher, failed on libdl.so.2. An absolute link whose target is missing in the root but present on the host now finishes there. --- tools/linuxfs/preload/robust.c | 13 +++++++++ tools/tests/test_openat2_preload.py | 44 ++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/tools/linuxfs/preload/robust.c b/tools/linuxfs/preload/robust.c index 679904b29..5de1dc740 100644 --- a/tools/linuxfs/preload/robust.c +++ b/tools/linuxfs/preload/robust.c @@ -208,6 +208,19 @@ static long openat_in_root(int dirfd, const char *path, int flags, long mode) { } memmove(pending + target_len, tail, rest_len + 1); if (pending[0] == '/') { + char saved = pending[target_len]; + pending[target_len] = 0; + const char *inside = pending; + while (*inside == '/') { + inside++; + } + bl_stat probe; + int host = *inside && real_syscall(BL_SYS_FSTATAT, (long)dirfd, (long)inside, (long)&probe, (long)AT_SYMLINK_NOFOLLOW) != 0 && + errno == ENOENT && real_syscall(BL_SYS_FSTATAT, (long)AT_FDCWD, (long)pending, (long)&probe, 0L) == 0; + pending[target_len] = saved; + if (host) { + return real_syscall(SYS_openat, (long)AT_FDCWD, (long)pending, (long)flags, mode); + } resolved_len = 0; } rest = pending; diff --git a/tools/tests/test_openat2_preload.py b/tools/tests/test_openat2_preload.py index 2187272ca..3791fdd14 100644 --- a/tools/tests/test_openat2_preload.py +++ b/tools/tests/test_openat2_preload.py @@ -117,8 +117,17 @@ CASES = [ ("o", os.O_RDONLY, "proc/self/status", "proc"), ] +# Only the preload: an absolute link to a path missing in the tree but present outside it, as droiddeck-fex's rootfs has. +HOST_CASES = [ + ("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libbar.so.2", "host:libbar.so.2"), + ("r", os.O_RDONLY, "hostdir/sub/data", "host:sub/data"), + ("r", os.O_RDONLY | os.O_DIRECTORY, "hostdir/sub", "host:sub"), + ("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"), + ("r", os.O_RDONLY, "hostdir/missing", "ENOENT"), +] -def make_tree(root): + +def make_tree(root, outside): for folder in ("usr/lib/x86_64-linux-gnu", "etc/alternatives", "loop", "chain"): (root / folder).mkdir(parents=True) (root / "usr/lib/x86_64-linux-gnu/libfoo.so.1.2").write_text("x86") @@ -129,16 +138,24 @@ def make_tree(root): "etc/alternatives/hn": "/etc/hostname", "etc/escape": "../../../../../../etc/hostname", "loop/a": "b", "loop/b": "a", "dirlink": "/usr", "rootlink": "/", "dangling": "/usr/newfile"} links.update({"chain/l%d" % i: ("l%d" % (i + 1) if i < 41 else "end") for i in range(1, 42)}) + links.update({"usr/lib/x86_64-linux-gnu/libbar.so.2": str(outside / "libbar.so.2"), "hostdir": str(outside)}) for link, target in links.items(): os.symlink(target, root / link) -def labels(lines, root): +def make_outside(outside): + (outside / "sub").mkdir(parents=True) + (outside / "libbar.so.2").write_text("host") + (outside / "sub/data").write_text("host") + + +def labels(lines, root, outside): inodes = {(os.lstat(root).st_dev, os.lstat(root).st_ino): "."} - for folder, dirs, files in os.walk(root): - for name in dirs + files: - st = os.lstat(os.path.join(folder, name)) - inodes[(st.st_dev, st.st_ino)] = os.path.relpath(os.path.join(folder, name), root) + for base, prefix in ((root, ""), (outside, "host:")): + for folder, dirs, files in os.walk(base): + for name in dirs + files: + st = os.lstat(os.path.join(folder, name)) + inodes[(st.st_dev, st.st_ino)] = prefix + os.path.relpath(os.path.join(folder, name), base) out = [] for line in lines: kind, *values = line.split() @@ -161,16 +178,19 @@ class Openat2PreloadTest(unittest.TestCase): source.write_text(PROGRAM) subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True) cls.trees = (cls.tmp / "kernel", cls.tmp / "preload") + cls.outside = cls.tmp / "outside" + make_outside(cls.outside) for tree in cls.trees: - make_tree(tree) - cases = "".join("%s\t%d\t%s\n" % case[:3] for case in CASES) + make_tree(tree, cls.outside) + every = CASES + HOST_CASES + cases = "".join("%s\t%d\t%s\n" % case[:3] for case in every) result = subprocess.run([str(cls.tmp / "openat2"), *map(str, cls.trees)], input=cases, capture_output=True, text=True, env=dict(os.environ, LD_PRELOAD=str(cls.lib)), timeout=60) if result.returncode != 0: raise AssertionError(result.stdout + result.stderr) lines = result.stdout.splitlines() - cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0]) if lines[0] == "kernel" else None - cls.preload = labels(lines[-len(CASES):], cls.trees[1]) + cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0], cls.outside) if lines[0] == "kernel" else None + cls.preload = labels(lines[-len(every):], cls.trees[1], cls.outside) @classmethod def tearDownClass(cls): @@ -181,6 +201,10 @@ class Openat2PreloadTest(unittest.TestCase): self.assertEqual(got, case[3], case) self.assertTrue((self.trees[1] / "usr/newfile").exists()) + def test_preload_finishes_a_link_to_a_path_only_outside_the_root_there(self): + for case, got in zip(HOST_CASES, self.preload[len(CASES):]): + self.assertEqual(got, case[3], case) + def test_the_kernel_agrees(self): if self.kernel is None: self.skipTest("this kernel or sandbox has no openat2") From 5c07c122feb8e1f9c64881a0bfb4b36227df3f51 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 20:38:48 -0400 Subject: [PATCH 010/156] Steam x86-64 client: menus come up, sign-in stays put, games listed once (#1096) * Steam x86-64 client: menus come up, sign-in stays put, games listed once Switching the Steam client from arm64 to x86-64 left the client on a blank screen behind "Downloading the Steam client update 100%", and each switch signed one of the clients out. - The x86-64 client is Valve's steamrt64 platform, but it was installed and synced from the ubuntu12 manifest, which lags it (1792108788 against 1792192922). The client was therefore never the arm64 client's build, the arm64 web helper was never used, and the client then updated itself in place after the helper had been chosen. Install, sync and the build check now read the steamrt64 manifest. - The x86 web helper's GPU process loads libpci, which exits when /sys/bus/pci/devices lists a device whose files the app may not read (a RedMagic's PCIe root ports). Every GPU process exited with status 1, even in software, until Chromium gave up and the menus stayed blank. An empty folder is bound over /sys/bus/pci/devices on such devices. - Steam refuses one client's login token from the other ("Invalid Password" for a token the arm64 client signed in with minutes before), so copying the newer token at every start signed the other client out. Each client now keeps its own token; a client with no accounts only gets the other's account list. - Games could be listed twice: an added game whose folder a Steam import already covers is now shown as that Steam game only, a client switch drops the old client's imported entries, an import stays one when its ownership cannot be confirmed instead of gaining a shortcut beside it, and only the client's own library folders count as an install. * Steam x86-64 client: share the arm64 sign-in again The x86-64 client refused the arm64 client's login token because it did not know the account's SteamID: it takes it from Accounts in config/config.vdf, which this script creates without one. It logged on as [U:1:0] with a valid token, Steam answered "Invalid Password", and the client dropped the token and AutoLoginUser, so every switch asked for the password. The newer sign-in is copied between the clients again, each start adds the accounts either client knows to the other's config.vdf, and an AutoLoginUser or RememberPassword one client lacks is filled in. Device: the x86 client signs in with the arm64 token on a fresh install and on an install left signed out, and the arm64 client stays signed in. --- README.md | 2 +- .../com/droiddeck/launcher/MainActivity.kt | 5 +- .../droiddeck/launcher/frontend/Library.kt | 16 ++- .../launcher/runtime/LinuxRuntime.java | 12 ++ .../launcher/frontend/LibraryArchTest.kt | 13 ++ docs/development/x86-64-steam-client.md | 37 +++-- .../usr/local/bin/droiddeck-steam-games | 15 ++- .../overlay/usr/local/bin/droiddeck-steam-x64 | 127 +++++++++++++----- tools/tests/test_steam_games.py | 18 +++ tools/tests/test_steam_x64_signin.py | 78 +++++++++++ 10 files changed, 278 insertions(+), 45 deletions(-) create mode 100644 tools/tests/test_steam_x64_signin.py diff --git a/README.md b/README.md index d699d4419..1660171eb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 2330dfb23..279692714 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1882,7 +1882,7 @@ class MainActivity : ComponentActivity() { val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64" if (listX64 != steamGamesX64) { steamGamesX64 = listX64 - steamGames = steamGames.filterNot(Library::fromSteamClient) + steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) } } if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) } if (showComponents) drivers.refreshDrivers() @@ -1916,12 +1916,13 @@ class MainActivity : ComponentActivity() { ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus } if (ready) { val quick = runCatching { Library.steamGames(this) }.getOrNull() + val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty() if (quick != null) ui.post { if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) { val before = steamGames.map { it.gameId }.toSet() val fresh = quick.filter { it.gameId !in before } if (fresh.isNotEmpty()) { - steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId } + steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId } fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 743fea5d9..1242d9add 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -178,9 +178,23 @@ object Library { var steam = steamGames(context, strictRead, arch) val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) } if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch) - return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId } + val claimed = claimedFolders(steam) + val own = list.filter { g -> + g.steamAppId != null || claimed.isEmpty() || + (runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed && + AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed }) + } + return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId } } + fun claimedFolders(steam: List): Set = steam.filter(::fromSteamClient).flatMap { g -> + val dir = g.gameFiles ?: return@flatMap emptyList() + listOfNotNull( + runCatching { dir.canonicalPath }.getOrNull(), + runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(), + ) + }.toSet() + fun addedGame(context: Context, g: AddedGames.Game, roots: List = steamLibraries(context).map { it.first }): SteamGame = AddedGameArt.resolve(context, g).let { art -> SteamGame( diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index f7f68d6da..c729850fc 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -214,6 +214,9 @@ public final class LinuxRuntime { bind(cmd, fake.getPath() + ":" + entry[1]); } } + if (unreadablePciDevice()) { + bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices"); + } bindGpuNode(context, cmd); bindAdrenoStats(cmd, extraBinds); bindCpuTemps(cmd, root); @@ -225,6 +228,15 @@ public final class LinuxRuntime { return specs; } + private static boolean unreadablePciDevice() { + File[] devices = new File("/sys/bus/pci/devices").listFiles(); + if (devices == null) return false; + for (File device : devices) { + if (!new File(device, "vendor").canRead()) return true; + } + return false; + } + private static void bindGpuNode(Context context, List cmd) { StructStat st; try { diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt index d3a230a81..8ecd411b0 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt @@ -22,4 +22,17 @@ class LibraryArchTest { assertEquals(listOf("Team Fortress 2"), Library.launchableGames(app, added = emptyList(), arch = "x86_64").map { it.name }) assertEquals(emptyList(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name }) } + + @Test fun anAddedGameSteamAlreadyListsFromItsFolderIsShownOnce() { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val folder = File(app.filesDir, "games/Example").apply { mkdirs() } + val exe = File(folder, "Example.exe").apply { writeText("game") } + val apps = File(SessionPrefs.steamRoot(app, "arm64"), "steamapps").apply { mkdirs() } + java.nio.file.Files.createSymbolicLink(File(apps, "common").apply { mkdirs() }.toPath().resolve("DroidDeck-42"), folder.toPath()) + File(apps, "appmanifest_42.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"42\"\n\t\"name\"\t\t\"Example\"\n\t\"StateFlags\"\t\t\"4\"\n\t\"installdir\"\t\t\"DroidDeck-42\"\n}\n") + val added = AddedGames.Game(folder, "Example", exe, "/g/Example.exe", "/g", 0x87654321L, (0x87654321L shl 32) or 0x02000000L, emptyList()) + val other = File(app.filesDir, "games/Other").apply { mkdirs() } + val second = AddedGames.Game(other, "Other", File(other, "Other.exe"), "/g/Other.exe", "/g", 0x81234567L, (0x81234567L shl 32) or 0x02000000L, emptyList()) + assertEquals(listOf("Example" to "internal", "Other" to Library.ADDED), Library.launchableGames(app, added = listOf(added, second), arch = "arm64").map { it.name to it.library }) + } } diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md index 25e25a700..f11923fe7 100644 --- a/docs/development/x86-64-steam-client.md +++ b/docs/development/x86-64-steam-client.md @@ -39,6 +39,11 @@ arm64 client down first. standalone launcher honours up to five times, as `steam.sh` does). - Before each start `x64_sync_client` brings the client to Valve's current build, downloading only the changed parts (9 of 28 on a device). If that fails, the client updates itself. +- The client is the `steamrt64` platform (`steamrt64/steam`, the manifest it checks for updates), so + the install, the sync and the web-helper build check read `steam_client__steamrt64`. The + `ubuntu12` manifest lags it (device: 1792108788 against 1792192922): an install from it was always + one build behind the arm64 client, so the arm64 web helper was never used, and the client updated + itself in place at its first start (506 MB) after the helper had already been chosen. - `.steam-enable-steamrt64-client` in the client folder opts into the SteamRT3 build (`steamrt64/steam`). Without it the client re-executes itself in the scout environment through `steam.sh`, whose start-up checks need what proot cannot give; the session stalled before the @@ -119,6 +124,11 @@ The client's web helper draws its whole UI. There are two: backend through FEX's GL thunk to zink and Turnip. On ANGLE's Vulkan backend the x86 compositor thread was the bottleneck (16 fps against 60). Never `--in-process-gpu`, the preload's default: a GPU crash there took the whole helper down (102 restarts). `BL_X64_CEF_GPU=0` keeps it in software. + Its GPU process loads libpci, which exits when `/sys/bus/pci/devices` lists a device whose files + the app may not read (a RedMagic's PCIe root ports): every GPU process exited with status 1 in about + 50 ms, software ones too, until Chromium gave up ("GPU process isn't usable") and the menus stayed + blank. `LinuxRuntime` binds an empty folder over `/sys/bus/pci/devices` on such devices; device: + 0 GPU process exits, ANGLE on zink and Turnip. The session shim (`preload/net.c`) turns `steamrt64/steamwebhelper.sh`'s exec of `./steamwebhelper` into `BL_X64_ARM_WEBHELPER`. Nothing in the client's folder is edited: the client checksums its files @@ -217,7 +227,7 @@ alone would then read as an installed client. - **What a session log shows.** `FEX ready (, )` names the build and where it came from (`catalog`, `built-in`, or `kept` when a newer one did not install, with the reason a line above); - `x86-64 rootfs:` names the image; `x86-64 client:` the client's ubuntu12 and steamrt64 builds at start + `x86-64 rootfs:` names the image; `x86-64 client:` the client's steamrt64 build at start (it may update itself after). The rootfs download reports every tenth of its size as a step. When the web helper's GPU process keeps exiting until Chromium gives up on the GPU (the menus then never appear), one line says so with the exit codes, followed by x86 `glxinfo -B` and `vulkaninfo --summary` through @@ -301,15 +311,24 @@ alone would then read as an installed client. in 51 s. - **Sign-in.** Before either client starts, the newer sign-in (`config/loginusers.vdf`, the login token in `local.vdf`, `AutoLoginUser` and `RememberPassword` in `registry.vdf`) is copied to the other, so - signing in or switching accounts in one counts for both. Steam renews its token every few days and - refuses an old copy (a 3-day-old one asked for the password), so the newest is copied at every start, - never while either client runs, since each rewrites these files. A client that refused its token - rewrites `local.vdf` with an empty `ConnectCache`: newest, but copied over the other it would sign - that one out too (seen after the x86 client's "Invalid Password"), so it is never the source. The copy keeps - the time stamp, so both match until one client signs in again. The rest of `registry.vdf` - (language, skin) stays each client's. + signing in or switching accounts in one counts for both; never while either client runs, since each + rewrites these files. A client that refused its token rewrites `local.vdf` with an empty + `ConnectCache`, so it is never the source. The copy keeps the time stamp, so both match until one + client signs in again. The rest of `registry.vdf` (language, skin) stays each client's. +- **Account IDs.** A client logs on with the token only once it knows the account's SteamID, which it + takes from `Accounts` in `config/config.vdf`. The x86 client's `config.vdf` is made by this script + without one, so it logged on as `[U:1:0]` with the arm64 token and Steam answered "Invalid Password" + (and the client then dropped the token and `AutoLoginUser`): every switch asked for the password. + Each start adds the accounts either client knows to the other's `Accounts`, and an `AutoLoginUser` or + `RememberPassword` one client has and the other lacks. Device: the x86 client signed in with the + arm64 token (`RecvMsgClientLogOnResponse ... 'OK'`). - **App side.** The library, art picker, grid sync and boot movie follow the running client, else the - selected one; a changed client applies from the next session. Offline Mode is written to both + selected one; a changed client applies from the next session. A game is listed once per client: an added game + whose folder a Steam install already covers (its `DroidDeck-` import) is shown as that Steam + game only, and a client switch drops the old client's imported entries with its Steam games. In + Steam, an import stays one when its ownership cannot be confirmed that session instead of gaining a + shortcut beside it, and only the client's own library folders count as an install (the arm64 card + library made the x86 client skip a game's import and its shortcut). Offline Mode is written to both clients' `loginusers.vdf`; until the x86 client's first start its sign-in is the arm64 one. The log redactor learns account names from both clients. The startup movie is staged into the x86 client's config only once its first start has created it; `--prepare` links the arm64 `config/uioverrides` diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 4bfbb87ed..c710ff0ac 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -291,7 +291,7 @@ def libraries(steam_root): except OSError: text = "" paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)] - return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths])) + return list(dict.fromkeys([Path(steam_root)] + [Path(path) for path in paths])) def ours(manifest, appid): @@ -311,6 +311,16 @@ def unpark(steamapps): os.replace(manifest, live) +def seeded(steamapps, game, appid): + target = steamapps / "common" / f"DroidDeck-{appid}" + manifest = steamapps / f"appmanifest_{appid}.acf" + try: + return (target.is_symlink() and target.is_dir() and manifest.is_file() and ours(manifest, appid) + and os.readlink(target) in (game.get("folder", ""), legacy_path(game.get("folder", "")))) + except OSError: + return False + + def park(steamapps): for target in sorted((steamapps / "common").glob("DroidDeck-*")): appid = target.name.removeprefix("DroidDeck-") @@ -428,6 +438,9 @@ def route(steam_root, acct, games): continue except OSError as error: print(f"droiddeck-steam-games: import unavailable ({error})") + elif isinstance(appid, int) and 0 < appid < 0x80000000 and seeded(steamapps, game, appid): + routes[str(game["appid"])] = appid + continue shortcuts.append(game) try: park(steamapps) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 index 2c27c9db4..e52a4e985 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -8,7 +8,7 @@ ROOTFS_SHA256=2854b06d3ff1b8f6e526135bfb6dd5b7b30ab3ab73e79ae933a3d9fed959a178 CDN=https://client-update.fastly.steamstatic.com # Must match the -clientbeta droiddeck-session starts this client with (steam_channel). X64_CHANNEL=steamdeck_publicbeta -MANIFEST=steam_client_${X64_CHANNEL}_ubuntu12 +MANIFEST=steam_client_${X64_CHANNEL}_steamrt64 REAL_HOME=${HOME:-/root} X64_HOME=${BL_X64_HOME:-$REAL_HOME/.droiddeck-x64} @@ -37,17 +37,17 @@ x64_client_parts() { /^\t"/ && NF >= 3 && $4 == "" { block = $2 } $2 == "file" { file[block] = $4 } $2 == "sha2" { sha[block] = $4 } - END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] } + END { for (b in file) if (b ~ /_all$/ || b ~ /_steamrt64$/) print b, file[b], sha[b] } ' "$1" | sort } x64_sync_client() { local chan cur version work total i block file sum old chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL") - old=$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest + old=$STEAM_X64/package/steam_client_${chan}_steamrt64.manifest cur=$(awk -F'"' '$2 == "version" { print $4; exit }' "$old" 2>/dev/null || true) work=$(mktemp -d) - if ! curl -fsSL "$CDN/steam_client_${chan}_ubuntu12" -o "$work/manifest"; then + if ! curl -fsSL "$CDN/steam_client_${chan}_steamrt64" -o "$work/manifest"; then rm -rf "$work"; return 0 fi version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") @@ -471,7 +471,7 @@ if [ ! -x "$STEAM_X64/steamrt64/steam" ] || [ ! -f "$STEAM_X64/package/droiddeck /^\t"/ && NF >= 3 && $4 == "" { block = $2 } $2 == "file" { file[block] = $4 } $2 == "sha2" { sha[block] = $4 } - END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] } + END { for (b in file) if (b ~ /_all$/ || b ~ /_steamrt64$/) print b, file[b], sha[b] } ' "$work/manifest" | sort > "$work/parts" [ -s "$work/parts" ] || { step "no client components in $MANIFEST"; exit 1; } mkdir -p "$STEAM_X64/package" @@ -910,7 +910,7 @@ x64_exec() { [ -x "$ARM_STEAM/steamrtarm64/steamwebhelper" ]; then local chan x86_build arm_build chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL") - x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest" 2>/dev/null || true) + x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_steamrt64.manifest" 2>/dev/null || true) arm_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$ARM_STEAM/package/steam_client_${chan}_linuxarm64.manifest" 2>/dev/null || true) if [ -n "$x86_build" ] && [ "$x86_build" = "$arm_build" ]; then export BL_X64_ARM_WEBHELPER=/usr/local/bin/droiddeck-x64-arm-webhelper @@ -937,7 +937,7 @@ x64_exec() { touch "$STEAM_X64/logs/bootstrap_log.txt" ( tail -n0 -F --pid=$$ "$STEAM_X64/logs/bootstrap_log.txt" 2>/dev/null | grep --line-buffered -oE 'Downloading update \([0-9,]+ of [0-9,]+ KB\)' | sed -u 's/,//g' >&2 ) & - echo "== x86-64 client: ubuntu12 $(x64_manifest_version ubuntu12), steamrt64 $(x64_manifest_version steamrt64) (the client may update itself)" >&2 + echo "== x86-64 client: steamrt64 $(x64_manifest_version steamrt64) (the client may update itself)" >&2 x64_webhelper_gpu && { x64_gpu_watch >&2 & } exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@" } @@ -952,6 +952,76 @@ arm, x64, arm_reg, x64_reg = sys.argv[1:5] FILES = ("config/loginusers.vdf", "local.vdf") +def read(path): + try: + return open(path, encoding="utf-8", errors="surrogateescape").read() + except OSError: + return None + + +def write(path, text): + open(path, "w", encoding="utf-8", errors="surrogateescape").write(text) + + +def block(text, name): + m = re.search(r'"%s"\s*\{' % name, text) + if not m: + return None + depth, k = 1, m.end() + while depth and k < len(text): + depth += text[k] == "{" + depth -= text[k] == "}" + k += 1 + return m, k + + +def accounts(root): + text = read(os.path.join(root, "config/config.vdf")) or "" + found = block(text, "Accounts") + if not found: + return {} + return dict(re.findall(r'"([^"]+)"\s*\{\s*"SteamID"\s*"(\d+)"', text[found[0].end():found[1]])) + + +def add_accounts(root, wanted): + path = os.path.join(root, "config/config.vdf") + text = read(path) + if text is None: + return + have = accounts(root) + missing = {name: sid for name, sid in wanted.items() if name not in have} + if not missing: + return + entries = "".join('\n\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"SteamID"\t\t"%s"\n\t\t\t\t\t}' % item for item in missing.items()) + found = block(text, "Accounts") + if found: + text = text[:found[0].end()] + entries + text[found[0].end():] + else: + steam = re.search(r'"Valve"\s*\{\s*"Steam"\s*\{', text) + if not steam: + return + text = text[:steam.end()] + '\n\t\t\t\t"Accounts"\n\t\t\t\t{' + entries + '\n\t\t\t\t}' + text[steam.end():] + write(path, text) + + +def set_keys(src_reg, dst_reg, force): + st, dt = read(src_reg), read(dst_reg) + if st is None or dt is None: + return + for key in ("AutoLoginUser", "RememberPassword"): + v = re.search(r'"%s"\s*"([^"]*)"' % key, st) + if not v: + continue + if re.search(r'"%s"\s*"' % key, dt): + if force: + dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1) + continue + hkcu = re.search(r'"HKCU"\s*\{\s*"Software"\s*\{\s*"Valve"\s*\{\s*"Steam"\s*\{\n', dt) + if hkcu: + dt = dt[:hkcu.end()] + '\t\t\t\t\t"%s"\t\t"%s"\n' % (key, v.group(1)) + dt[hkcu.end():] + write(dst_reg, dt) + + def stamp(root): if not all(os.path.isfile(os.path.join(root, f)) for f in FILES): return None @@ -963,31 +1033,26 @@ def stamp(root): a, x = stamp(arm), stamp(x64) -if a is None and x is None: - sys.exit(0) -if a is not None and x is not None and abs(a - x) < 1: - sys.exit(0) -if x is None or (a is not None and a > x): - src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64" -else: - src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64" -for f in FILES: - os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True) - # copy2 keeps the time stamp, so both match until one client signs in again. - shutil.copy2(os.path.join(src, f), os.path.join(dst, f)) -if os.path.isfile(sreg): - st = open(sreg, encoding="utf-8", errors="surrogateescape").read() - if not os.path.isfile(dreg): - os.makedirs(os.path.dirname(dreg), exist_ok=True) - shutil.copy2(sreg, dreg) +if a is not None or x is not None: + if x is None or (a is not None and a > x): + src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64" else: - dt = open(dreg, encoding="utf-8", errors="surrogateescape").read() - for key in ("AutoLoginUser", "RememberPassword"): - v = re.search(r'"%s"\s*"([^"]*)"' % key, st) - if v and re.search(r'"%s"\s*"' % key, dt): - dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1) - open(dreg, "w", encoding="utf-8", errors="surrogateescape").write(dt) -print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who) + src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64" + copy = a is None or x is None or abs(a - x) >= 1 + if copy: + for f in FILES: + os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True) + # copy2 keeps the time stamp, so both match until one client signs in again. + shutil.copy2(os.path.join(src, f), os.path.join(dst, f)) + if os.path.isfile(sreg) and not os.path.isfile(dreg): + os.makedirs(os.path.dirname(dreg), exist_ok=True) + shutil.copy2(sreg, dreg) + set_keys(sreg, dreg, copy) + set_keys(dreg, sreg, False) + add_accounts(dst, accounts(src)) + add_accounts(src, accounts(dst)) + if copy: + print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who) PY } diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index b45126164..7ad6e5300 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -311,6 +311,24 @@ class ImportsTest(unittest.TestCase): self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists()) self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42')) + def test_imported_game_stays_imported_when_ownership_is_unconfirmed(self): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + for owned, account in [(False, '123'), (True, '456')]: + self.snapshot(owned, account) + self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game])) + self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file()) + + def test_unlisted_card_library_is_not_another_clients_install(self): + card = self.root / 'card' + (card / 'steamapps').mkdir(parents=True) + (card / 'steamapps/appmanifest_42.acf').write_text('the other client installed it here') + self.snapshot() + with patch.object(imports, 'LIBRARY', str(card)): + self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game])) + self.assertTrue((self.steam / 'steamapps/appmanifest_42.acf').is_file()) + self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve()) + def test_replaced_game_does_not_inherit_cached_ownership(self): self.snapshot() self.exe.write_bytes(b'a different game') diff --git a/tools/tests/test_steam_x64_signin.py b/tools/tests/test_steam_x64_signin.py new file mode 100644 index 000000000..c294108e8 --- /dev/null +++ b/tools/tests/test_steam_x64_signin.py @@ -0,0 +1,78 @@ +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-x64' + +REGISTRY = ('"Registry"\n{\n\t"HKCU"\n\t{\n\t\t"Software"\n\t\t{\n\t\t\t"Valve"\n\t\t\t{\n\t\t\t\t"Steam"\n\t\t\t\t{\n' + '\t\t\t\t\t"language"\t\t"english"\n%s\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n') +CONFIG = ('"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n%s' + '\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n') +ACCOUNTS = ('\t\t\t\t"Accounts"\n\t\t\t\t{\n\t\t\t\t\t"someone"\n\t\t\t\t\t{\n' + '\t\t\t\t\t\t"SteamID"\t\t"76561198000000001"\n\t\t\t\t\t}\n\t\t\t\t}\n') + + +def local(token): + entry = '\t\t"abc1"\t\t"%s"\n' % token if token else '' + return '"MachineUserConfigStore"\n{\n\t"ConnectCache"\n\t{\n%s\t}\n}\n' % entry + + +class X64SignInTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.arm = self.home / '.local/share/Steam' + self.x64 = self.home / '.droiddeck-x64/.local/share/Steam' + for root in (self.arm, self.x64): + (root / 'config').mkdir(parents=True) + (self.home / '.steam').mkdir() + (self.home / '.droiddeck-x64/.steam').mkdir(parents=True) + self.write(self.arm / 'local.vdf', local('00ff')) + self.write(self.arm / 'config/loginusers.vdf', '"users"\n{\n}\n') + self.write(self.arm / 'config/config.vdf', CONFIG % ACCOUNTS) + self.write(self.home / '.steam/registry.vdf', REGISTRY % '\t\t\t\t\t"AutoLoginUser"\t\t"someone"\n') + self.write(self.x64 / 'local.vdf', local(None)) + self.write(self.x64 / 'config/loginusers.vdf', '"users"\n{\n}\n') + self.write(self.x64 / 'config/config.vdf', CONFIG % '') + self.write(self.home / '.droiddeck-x64/.steam/registry.vdf', REGISTRY % '') + + def write(self, path, text): + path.write_text(text) + + def sync(self): + env = dict(os.environ, HOME=str(self.home)) + env.pop('XDG_DATA_HOME', None) + env.pop('BL_X64_HOME', None) + return subprocess.run(['bash', str(SCRIPT), '--sync-login'], env=env, capture_output=True, text=True, check=True) + + def test_a_refused_x64_sign_in_gets_the_token_account_and_auto_login(self): + self.assertIn('sign-in shared from the arm64 client', self.sync().stdout) + self.assertEqual(local('00ff'), (self.x64 / 'local.vdf').read_text()) + self.assertIn('"SteamID"\t\t"76561198000000001"', (self.x64 / 'config/config.vdf').read_text()) + self.assertIn('"AutoLoginUser"\t\t"someone"', (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text()) + + def test_an_earlier_copy_still_gains_the_account_and_nothing_repeats(self): + self.sync() + self.write(self.x64 / 'config/config.vdf', CONFIG % '') + self.write(self.home / '.droiddeck-x64/.steam/registry.vdf', REGISTRY % '') + self.assertNotIn('sign-in shared', self.sync().stdout) + self.assertIn('"76561198000000001"', (self.x64 / 'config/config.vdf').read_text()) + self.assertIn('"AutoLoginUser"', (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text()) + self.sync() + for root in (self.arm, self.x64): + self.assertEqual(1, (root / 'config/config.vdf').read_text().count('"SteamID"')) + self.assertEqual(1, (self.home / '.droiddeck-x64/.steam/registry.vdf').read_text().count('AutoLoginUser')) + + def test_a_newer_x64_sign_in_moves_to_arm64(self): + self.write(self.x64 / 'local.vdf', local('11ee')) + os.utime(self.arm / 'local.vdf', (1, 1)) + self.assertIn('from the x86-64 client', self.sync().stdout) + self.assertEqual(local('11ee'), (self.arm / 'local.vdf').read_text()) + + +if __name__ == '__main__': + unittest.main() From 1e06e908e8839bdaf953b5d3a1f96f0c43c4c25d Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 21:28:36 -0400 Subject: [PATCH 011/156] Fill missing plural forms and translate English left in the app strings (#1102) Nine storage plurals lacked the forms Arabic, French, Spanish, Italian and Portuguese use (zero/two/few/many), and 71 strings across 16 languages were still the English text. --- app/src/main/res/values-ar/strings.xml | 42 +++++++++++++++-- app/src/main/res/values-b+es+419/strings.xml | 9 ++++ app/src/main/res/values-bg/strings.xml | 8 ++-- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-da/strings.xml | 6 +-- app/src/main/res/values-el/strings.xml | 22 ++++----- app/src/main/res/values-es/strings.xml | 9 ++++ app/src/main/res/values-fi/strings.xml | 4 +- app/src/main/res/values-fr/strings.xml | 9 ++++ app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 10 ++-- app/src/main/res/values-it/strings.xml | 9 ++++ app/src/main/res/values-ms/strings.xml | 10 ++-- app/src/main/res/values-nb/strings.xml | 2 +- app/src/main/res/values-pt-rPT/strings.xml | 15 ++++-- app/src/main/res/values-pt/strings.xml | 11 ++++- app/src/main/res/values-ro/strings.xml | 8 ++-- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-th/strings.xml | 6 +-- app/src/main/res/values-vi/strings.xml | 48 ++++++++++---------- 21 files changed, 164 insertions(+), 74 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8b5c45a08..3381ca393 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2687,33 +2687,61 @@ تحرير مساحة اللعب على أي حال + %d مثبَّتة %d مثبَّتة + %d مثبَّتة + %d مثبَّتة + %d مثبَّتة %d مثبَّتة + %d على بطاقة SD %d على بطاقة SD + %d على بطاقة SD + %d على بطاقة SD + %d على بطاقة SD %d على بطاقة SD + %d أداة %d أداة - %d أدوات + %d أداة + %d أدوات + %d أداة + %d أداة + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + مجلدات بادئة فارغة وذاكرة شيدرات من %d ألعاب + مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة مجلدات بادئة فارغة وذاكرة شيدرات من %d لعبة + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. بادئة %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d ألعاب تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. بادئات %d لعبة تحتوي على ملفات، منها حفظات محلية. انسخها أولًا إن كنت تحتاجها. + %d لعبة %d لعبة + %d لعبة + %d ألعاب + %d لعبة %d لعبة Proton الافتراضي في DroidDeck خيار Proton 11 في DroidDeck + تستخدمه %d لعبة تستخدمه %d لعبة + تستخدمه %d لعبة + تستخدمه %d ألعاب + تستخدمه %d لعبة تستخدمه %d لعبة المكوّنات @@ -2756,12 +2784,20 @@ يحذف بيئات التشغيل التي لا يحتاجها أي تطبيق مثبَّت وينظّف مخزن كائنات Flatpak. تبقى التطبيقات المثبَّتة تعمل. يحذف التطبيق (%1$s) وبيئات التشغيل التي لا يستخدمها غيره. تبقى بياناته في مجلدك الرئيسي. + %d حزمة %d حزمة - %d حزم + %d حزمة + %d حزم + %d حزمة + %d حزمة + %d تطبيق %d تطبيق - %d تطبيقات + %d تطبيق + %d تطبيقات + %d تطبيقًا + %d تطبيق فيديو الإقلاع الفيديو diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 729f2aa74..3ff6b8521 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2565,32 +2565,39 @@ Jugar igualmente %d instalado + %d instalados %d instalados %d en la tarjeta SD + %d en la tarjeta SD %d en la tarjeta SD %d herramienta + %d herramientas %d herramientas Carpetas de prefijo vacías y caché de shaders de %d juego + Carpetas de prefijo vacías y cachés de shaders de %d juegos Carpetas de prefijo vacías y cachés de shaders de %d juegos El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. + Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. %d juego + %d juegos %d juegos Proton predeterminado de DroidDeck Opción Proton 11 de DroidDeck Usado por %d juego + Usado por %d juegos Usado por %d juegos Componentes @@ -2634,10 +2641,12 @@ Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan. %d paquete + %d paquetes %d paquetes %d app + %d apps %d apps Video de arranque diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 303feb9f4..f562b8bb8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -32,7 +32,7 @@ Изключено Win-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Адаптивно %1$d Изисква Lossless Scaling в Steam Инсталирайте Lossless Scaling в Steam или импортирайте Lossless.dll Импортиране на Lossless.dll… @@ -115,7 +115,7 @@ Изключва проверките за самопроменящ се код. Игри с JIT и DRM може да спрат да работят. Екстремно + TSO Екстремни настройки с включен основен TSO. - Extreme-gn + Екстремно-gn Производителност с TSC и подсказки за метаданни. Пробвайте преди Екстремно. Denuvo Производителност с пълни проверки на кода и скрит hypervisor бит. @@ -299,7 +299,7 @@ Инсталирано · %1$s Налична актуализация · %1$s Актуално · %1$s - Loader + Зареждач Инсталиране на последната версия Стартира заедно със Steam. Докато е включено, други приложения на това устройство може да могат да контролират Steam. Изключено. Портът за отдалечено дебъгване на Steam остава затворен. @@ -1097,7 +1097,7 @@ Сесията на Linux все още не е готова за терминал. Steam Игри - Desktop + Работен плот Магазин Компоненти Приложения diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index feb7fdf84..df75b0192 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -32,7 +32,7 @@ Vypnuto Win-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Adaptivní %1$d Vyžaduje Lossless Scaling na Steamu Nainstalujte Lossless Scaling ve Steamu nebo naimportujte Lossless.dll Importovat Lossless.dll… diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 6186aa6a6..931534a37 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -527,7 +527,7 @@ Klientgrænseflade Threaded GL Kan gøre Steam-menuer mere lydhøre. - Zink: lazy descriptors + Zink: dovne deskriptorer Anbefalet til drivere uden descriptor buffers. Spring GL-fejlkontrol over Slår validering af GL fra for hvert kald. @@ -549,7 +549,7 @@ Deaktiverer Protons gamepad-navigationshjælper, som koster CPU i hvert spil. Kør proot uden seccomp Kan afhjælpe fejl om manglende systemkald på nogle kerner. Kan reducere ydelsen. - proot fast path + proot-hurtigvej Kræver prootets seccomp-filter. Hurtigere filopslag og mindre hak. Slå fra, hvis et program ikke kan finde sine filer. Sessionsidentitet @@ -942,7 +942,7 @@ 100% er standardstørrelsen Stick-klik Dobbelttryk og hold en stick nede for L3 eller R3 - Adaptive sticks + Adaptive styrepinde Spilvibration Steam-knap QAM-knap diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index bb3ed7830..7efafb770 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -115,7 +115,7 @@ Απενεργοποιεί τους ελέγχους αυτο-τροποποιούμενου κώδικα. Παιχνίδια με JIT και DRM μπορεί να πάθουν βλάβη. Ακραίο + TSO Ρυθμίσεις Ακραίο με το κύριο TSO ενεργοποιημένο. - Extreme-gn + Ακραίο-gn Απόδοση με υποδείξεις TSC και μεταδεδομένων. Δοκιμάστε το πριν το Ακραίο. Denuvo Απόδοση με πλήρεις ελέγχους κώδικα και το bit υπερεπόπτη κρυμμένο. @@ -299,7 +299,7 @@ Εγκατεστημένο · %1$s Διαθέσιμη ενημέρωση · %1$s Ενημερωμένο · %1$s - Loader + Φορτωτής Εγκατάσταση τελευταίας έκδοσης Ξεκινά με το Steam. Όσο είναι ενεργό, άλλες εφαρμογές σε αυτή τη συσκευή μπορεί να είναι σε θέση να ελέγξουν το Steam. Ανενεργό. Η θύρα απομακρυσμένου εντοπισμού σφαλμάτων του Steam παραμένει κλειστή. @@ -527,7 +527,7 @@ Διεπαφή πελάτη Νηματοποιημένο GL Μπορεί να κάνει τα μενού του Steam πιο αποκρίσιμα. - Zink: lazy descriptors + Zink: καθυστερημένοι περιγραφείς Προτείνεται για προγράμματα οδήγησης χωρίς descriptor buffers. Παράβλεψη ελέγχων σφαλμάτων GL Απενεργοποιεί την επικύρωση GL ανά κλήση. @@ -575,7 +575,7 @@ Steam Επιφάνεια εργασίας Μενού Steam - Quick access + Γρήγορη πρόσβαση Άνοιγμα Quick Access Menu Προηγούμενη σελίδα Επόμενη σελίδα @@ -1059,7 +1059,7 @@ Προετοιμασία του runtime Linux Συνεδρία Εκκίνηση οθόνης και ήχου - Steam client + Πρόγραμμα-πελάτης Steam Έλεγχος του Steam και των εργαλείων συμβατότητάς του Άνοιγμα Steam Αναμονή για το πρώτο καρέ του Steam @@ -1428,8 +1428,8 @@ Καρφίτσωμα τρέχοντος φακέλου Δεν υπάρχουν ακόμα αγαπημένα - καρφιτσώστε έναν φάκελο με το μενού ⋮ του για να έρχεστε εδώ γρήγορα. Κατάργηση από τα αγαπημένα - 0 bytes - bytes + 0 μπάιτ + μπάιτ Τοποθεσία Τύπος Μέγεθος @@ -1785,7 +1785,7 @@ Μόνο όξυνση, το χρώμα παραμένει ανεπηρέαστο. Φιλμικό Καμπύλη σε στιλ φιλμ, ελαφρώς απαλή. - Arcade + Αρκέιντ Έντονο και δυναμικό. Ρετρό CRT Ο σωλήνας CRT και τα αναλογικά περάσματα σήματος μαζί. @@ -2066,7 +2066,7 @@ Επανάληψη Εκκαθάριση Εκκαθάριση ολοκληρωμένων - Manifest + Μανιφέστο Λήψη Επαλήθευση Εγκατάσταση @@ -2247,7 +2247,7 @@ Καθρέφτης Δικό σου prefix Ιστότοπος - Prefix + Πρόθεμα ΕΦΑΡΜΟΓΕΣ LINUX Flathub Εφαρμογές επιφάνειας εργασίας για την επιφάνεια εργασίας Linux, ανοίγουν από την Επιφάνεια εργασίας. @@ -2625,7 +2625,7 @@ Απέτυχε: %1$s %1$s κοινά Εγκατεστημένα παιχνίδια - Prefix %1$s · shaders %2$s · workshop %3$s + Πρόθεμα %1$s · σκιαστές %2$s · εργαστήριο %3$s Εκκαθάριση shaders Διαγραμμένα παιχνίδια Εφαρμογή Steam %1$d diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f576e9582..ccc1e9e37 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2613,32 +2613,39 @@ Jugar igualmente %d instalado + %d instalados %d instalados %d en la tarjeta SD + %d en la tarjeta SD %d en la tarjeta SD %d herramienta + %d herramientas %d herramientas Carpetas de prefijo vacías y caché de shaders de %d juego + Carpetas de prefijo vacías y cachés de shaders de %d juegos Carpetas de prefijo vacías y cachés de shaders de %d juegos El prefijo de %d juego aún contiene archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. + Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. Los prefijos de %d juegos aún contienen archivos, que pueden incluir partidas guardadas locales. Haz una copia antes si las quieres. %d juego + %d juegos %d juegos Proton predeterminado de DroidDeck Opción Proton 11 de DroidDeck Usado por %d juego + Usado por %d juegos Usado por %d juegos Componentes @@ -2682,10 +2689,12 @@ Quita la app (%1$s) y cualquier runtime que no use nada más. Sus datos en tu carpeta personal se quedan. %d paquete + %d paquetes %d paquetes %d app + %d apps %d apps Vídeo de arranque diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 7c12aaa94..9b156663c 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -115,7 +115,7 @@ Poistaa itseään muokkaavan koodin tarkistukset käytöstä. JIT-pelit ja DRM voivat hajota. Äärimmäinen + TSO Äärimmäiset asetukset, pääasiallinen TSO käytössä. - Extreme-gn + Äärimmäinen-gn Suorituskyky TSC- ja metadatavihjeillä. Kokeile ennen Äärimmäistä. Denuvo Suorituskyky täysin koodintarkistuksin ja hypervisor-bitti piilotettuna. @@ -299,7 +299,7 @@ Asennettu · %1$s Päivitys saatavilla · %1$s Ajan tasalla · %1$s - Loader + Lataaja Asenna uusin Käynnistyy Steamin kanssa. Päällä ollessaan muut tämän laitteen sovellukset voivat pystyä ohjaamaan Steamia. Pois. Steamin etävianmääritysportti pysyy suljettuna. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5d2323f91..fc79c4e3c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2613,32 +2613,39 @@ Jouer quand même %d installé + %d installés %d installés %d sur la carte SD + %d sur la carte SD %d sur la carte SD %d outil + %d outils %d outils Dossiers de préfixe vides et cache de shaders de %d jeu + Dossiers de préfixe vides et caches de shaders de %d jeux Dossiers de préfixe vides et caches de shaders de %d jeux Le préfixe de %d jeu contient encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. + Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. Les préfixes de %d jeux contiennent encore des fichiers, dont d\'éventuelles sauvegardes locales. Sauvegardez-les avant si vous y tenez. %d jeu + %d jeux %d jeux Proton par défaut de DroidDeck Option Proton 11 de DroidDeck Utilisé par %d jeu + Utilisé par %d jeux Utilisé par %d jeux Composants @@ -2682,10 +2689,12 @@ Supprime l\'application (%1$s) et tout environnement d\'exécution que rien d\'autre n\'utilise. Ses données dans votre dossier personnel restent. %d paquet + %d paquets %d paquets %d application + %d applications %d applications Vidéo de démarrage diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 556e2b538..a9cbd38f3 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1894,7 +1894,7 @@ फ़िल्टर शार्पनेस फ़्रेम रेट %1$s स्केलिंग को %2$s पर सेट करता है। - Match screen (%1$d×%2$d) + स्क्रीन से मिलाएँ (%1$d×%2$d) Follow screen (खुलने या बंद होने पर आकार बदलता है) Steam डिवाइस जानकारी दिखाएं diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index d60f27fa9..747aac900 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1012,7 +1012,7 @@ Indítási beállítások Indítás Játékfájlok - Proton prefix + Proton-prefix Mentések keresése… Nem található mentésmappa név szerint. Az exportálás a teljes felhasználói mappát veszi. %1$d mentésmappa diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 8aee58b53..2c695930b 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1520,9 +1520,9 @@ Biru Steam Langit Putih - Violet + Ungu Hijau - Amber + Ambar Merah Merah muda Tombol A @@ -1745,8 +1745,8 @@ Daftar paket ini dibuat sebelum adanya checksum - segarkan dan coba lagi Checksum tidak cocok - Linear - Nearest + Linier + Terdekat Snapdragon GSR (kualitas) Hanya penajaman Otomatis (sesuaikan skala) @@ -1754,7 +1754,7 @@ Tidak ada yang diterapkan. Kejernihan Gim Penajaman dengan sedikit kontras - cocok untuk sehari-hari. - Vivid + Cerah Warna didorong lebih kuat. Deband aktif agar langit tidak bergaris. Sinematik Kontras lebih dalam, warna lebih dingin, tepi dihaluskan. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 84fc8340c..4d6cb901c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2613,32 +2613,39 @@ Gioca comunque %d installato + %d installati %d installati %d su scheda SD + %d su scheda SD %d su scheda SD %d strumento + %d strumenti %d strumenti Cartelle di prefisso vuote e cache shader di %d gioco + Cartelle di prefisso vuote e cache shader di %d giochi Cartelle di prefisso vuote e cache shader di %d giochi Il prefisso di %d gioco contiene ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. + I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. I prefissi di %d giochi contengono ancora file, che possono includere salvataggi locali. Se ti servono, fanne una copia prima. %d gioco + %d giochi %d giochi Proton predefinito di DroidDeck Opzione Proton 11 di DroidDeck Usato da %d gioco + Usato da %d giochi Usato da %d giochi Componenti @@ -2682,10 +2689,12 @@ Rimuove l\'app (%1$s) e i runtime che nessun altro usa. I suoi dati nella cartella home restano. %d pacchetto + %d pacchetti %d pacchetti %d app + %d app %d app Video di avvio diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 668a2419b..6729d1e58 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -53,7 +53,7 @@ Menambah pustaka Steam dan mengimbasnya untuk permainan Windows. Permainan yang dimiliki menggunakan entri Steam masing-masing; selebihnya menjadi pintasan bukan Steam. Persekitaran permainan Pembolehubah persekitaran untuk permainan Proton. - Edit + Sunting Semua permainan Proton Profil ID apl Steam @@ -153,7 +153,7 @@ Mengemas kini apl gagal Ikon itu tidak dapat dibaca %1$s disimpan - Edit + Sunting Semak kemas kini Menyemak kemas kini… Kemas kini kepada %1$s @@ -205,7 +205,7 @@ Daripada repositori Mencari ikon… Lalai - Edit %1$s + Sunting %1$s Simpan Menyemak folder… Foldernya dipautkan ke dalam sesi: %1$s @@ -315,7 +315,7 @@ Karya seni Kunci API SteamGridDB Kunci anda - Edit… + Sunting… Melengkapkan karya seni yang hilang daripada Steam, kemudian SteamGridDB. cover.jpg, hero.jpg, logo.png atau icon.png anda sendiri dalam folder permainan diutamakan. Alih keluar %1$s Alih keluar? @@ -1520,7 +1520,7 @@ Biru Steam Langit Putih - Violet + Ungu Hijau Ambar Merah diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 5c1ebd5db..42962f25a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -527,7 +527,7 @@ Klientgrensesnitt Trådet GL Kan gjøre Steam-menyer mer responsive. - Zink: lazy descriptors + Zink: late deskriptorer Anbefales for drivere uten descriptor buffers. Hopp over GL-feilkontroller Slår av GL-validering per kall. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b33f52523..13412341a 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1092,10 +1092,10 @@ A sessão Linux ainda não está pronta para um terminal. Steam Jogos - Desktop + Ambiente de trabalho Loja Componentes - Apps + Aplicações Configuração Atualizações %1$d%% @@ -2242,7 +2242,7 @@ Diagnóstico Pronto para jogar Ainda não está pronto para jogar - runtime %1$s + ambiente de execução %1$s limite de processos desativado sessão iniciada como %1$s Quando algo precisa de si @@ -2613,32 +2613,39 @@ Jogar mesmo assim %d instalado + %d instalados %d instalados %d no cartão SD + %d no cartão SD %d no cartão SD %d ferramenta + %d ferramentas %d ferramentas Pastas de prefixo vazias e cache de shaders de %d jogo + Pastas de prefixo vazias e cache de shaders de %d jogos Pastas de prefixo vazias e cache de shaders de %d jogos O prefixo de %d jogo ainda tem ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. + Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. Os prefixos de %d jogos ainda têm ficheiros, que podem incluir jogos guardados locais. Se precisar deles, faça uma cópia antes. %d jogo + %d jogos %d jogos Proton predefinido do DroidDeck Opção Proton 11 do DroidDeck Usado por %d jogo + Usado por %d jogos Usado por %d jogos Componentes @@ -2682,10 +2689,12 @@ Remove a aplicação (%1$s) e os ambientes de execução que mais ninguém usa. Os dados dela na pasta pessoal mantêm-se. %d pacote + %d pacotes %d pacotes %d aplicação + %d aplicações %d aplicações Vídeo de arranque diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0dc9d0e57..b3d775666 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1092,7 +1092,7 @@ A sessão Linux ainda não está pronta para um terminal. Steam Jogos - Desktop + Área de trabalho Loja Componentes Apps @@ -2613,32 +2613,39 @@ Jogar assim mesmo %d instalado + %d instalados %d instalados %d no cartão SD + %d no cartão SD %d no cartão SD %d ferramenta + %d ferramentas %d ferramentas Pastas de prefixo vazias e cache de shaders de %d jogo + Pastas de prefixo vazias e cache de shaders de %d jogos Pastas de prefixo vazias e cache de shaders de %d jogos O prefixo de %d jogo ainda tem arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. + Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. Os prefixos de %d jogos ainda têm arquivos, que podem incluir saves locais. Se precisar deles, faça uma cópia antes. %d jogo + %d jogos %d jogos Proton padrão do DroidDeck Opção Proton 11 do DroidDeck Usado por %d jogo + Usado por %d jogos Usado por %d jogos Componentes @@ -2682,10 +2689,12 @@ Remove o app (%1$s) e os runtimes que mais ninguém usa. Os dados dele na pasta pessoal ficam. %d pacote + %d pacotes %d pacotes %d app + %d apps %d apps Vídeo de inicialização diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index d1ea59179..0453831e3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1442,8 +1442,8 @@ Fixează folderul curent Niciun favorit încă - fixează un folder din meniul său ⋮ pentru a reveni rapid aici. Elimină din favorite - 0 bytes - bytes + 0 octeți + octeți Locație Tip Dimensiune @@ -1562,7 +1562,7 @@ Alb Violet Verde - Amber + Chihlimbariu Roșu Roz Butonul A @@ -1798,7 +1798,7 @@ Claritate cu puțin contrast - cea de zi cu zi. Viu Culoare intensificată puternic. Eliminarea benzilor activată, ca cerurile să nu aibă dungi. - Cinematic + Cinematografic Contrast mai profund, culoare mai rece, margini netezite. Competitiv Umbre ridicate și claritate puternică - observă persoanele mai întâi. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 65db27eaf..78d237c6c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -32,7 +32,7 @@ Выкл. Win-FG %1$d× LSFG %1$d× - LSFG Adaptive %1$d + LSFG Адаптивный %1$d Требуется Lossless Scaling из Steam Установите Lossless Scaling в Steam или импортируйте Lossless.dll Импорт Lossless.dll… @@ -1592,7 +1592,7 @@ Правый триггер Кнопка View Кнопка «Меню» - View + Вид Меню Крестовина вверх Крестовина вниз diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c9cac80c4..4bda3c786 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1362,7 +1362,7 @@ ไม่สามารถตรวจสอบการอัปเดตจาก Flathub ได้ ติดตั้งแล้ว โฟลเดอร์ - Graphite + แกรไฟต์ พื้นผิวสีเทาแกรไฟต์ พร้อมสีฟ้าของโลโก้สำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด กระดาษบนพื้นดำ สีขาวเป็นหลัก สีฟ้าของไอคอนเป็นสัญญาณสำหรับเส้น วงแหวน และจุด @@ -1745,8 +1745,8 @@ รายการแพ็กเกจนี้เก่ากว่าระบบค่าตรวจสอบ - รีเฟรชแล้วลองใหม่ ค่าตรวจสอบไม่ตรงกัน - Linear - Nearest + เชิงเส้น + ใกล้ที่สุด Snapdragon GSR (คุณภาพ) เพิ่มความคมชัดเท่านั้น อัตโนมัติ (ตามอัตราขยาย) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 05ff91a85..64c63421b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -372,7 +372,7 @@ Âm thanh DirectAudio cho game Bỏ qua PulseAudio để giảm độ trễ. - Microphone + Micro Dùng cho chat bằng giọng nói. Mạng Dò tìm Wi-Fi @@ -756,7 +756,7 @@ Tìm kiếm Đã cài %1$s · %2$d - Store + Cửa hàng Flathub · ARM64 ● Flatpak đã sẵn sàng Cài runtime Linux từ Thiết lập trước. @@ -1069,7 +1069,7 @@ Pad trái Pad phải Cả hai - Gamepad + Tay cầm Cần Trackpad Chuyển giữa cần và trackpad @@ -1092,8 +1092,8 @@ Steam Games Desktop - Store - Components + Cửa hàng + Thành phần Ứng dụng Thiết lập Cập nhật @@ -1506,11 +1506,11 @@ LƯU TRỮ NHANH YÊU THÍCH - Downloads - Downlds + TẢI XUỐNG + TẢI ROMs Log phiên - Pictures + HÌNH ẢNH Đã thêm \"%1$s\" vào Yêu thích Đã xóa \"%1$s\" khỏi Yêu thích Thư mục trống @@ -1752,27 +1752,27 @@ Tự động (khớp tỷ lệ) Không áp dụng gì. - Game Clarity + Rõ nét trò chơi Làm nét với một chút tương phản - lựa chọn hàng ngày. - Vivid + Sống động Màu sắc được đẩy mạnh. Khử dải màu được bật để bầu trời không bị sọc. - Cinematic + Điện ảnh Tương phản sâu hơn, màu lạnh hơn, cạnh được làm mượt. - Competitive + Thi đấu Vùng tối được nâng lên và làm nét mạnh - phát hiện người trước tiên. - Adaptive Sharpen + Làm nét thích ứng Chỉ làm nét, không đổi màu sắc. Filmic Đường cong giống phim, hơi trầm. Arcade Mạnh mẽ và rực rỡ. - Retro CRT + CRT cổ điển Ống CRT và tín hiệu analog cùng áp dụng. - Upscale Sharp + Nâng cấp sắc nét Dùng khi chạy dưới độ phân giải màn hình. - Pixel Clean + Điểm ảnh sạch Pixel sắc nét cho game 2D và các game cũ. - Anime Edge + Viền anime Viền hoạt hình cộng làm nét. Không phải GPU Adreno @@ -1885,10 +1885,10 @@ Buộc SSBS Giữ một thiết lập tốc độ CPU mà Wine liên tục tắt. Có thể giúp game Windows nhanh hơn trên Snapdragon 8 Elite; thay đổi ít thấy trên chip cũ hơn. Áp dụng vào lần game khởi động kế tiếp. - Stores + Cửa hàng game Steam Tùy chỉnh - Stores + Cửa hàng Mở một store trên Mức tốc độ tải Engine stores @@ -1897,15 +1897,15 @@ Cân bằng Nhanh Tối đa - Stores + Cửa hàng Thiết lập Stores Thiết lập (chưa đăng nhập) - Store + Cửa hàng Đã cài Thư viện Tất cả - Downloads + Tải xuống %1$s (%2$d) Đăng nhập %1$s · đăng xuất @@ -1987,7 +1987,7 @@ %1$d tệp đã cài trong thư viện của bạn - store + cửa hàng Thành tích Đã mở khóa %1$d/%2$d %1$d / %2$d @@ -2014,7 +2014,7 @@ Lựa chọn của Steam cho mỗi game Âm thanh DirectAudio - Components + Thành phần FEX, DXVK, VKD3D Engine %1$s · gốc From 2cf009d025ff15667ab34e7581c27cff0fe4a6bd Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:54:50 -0400 Subject: [PATCH 012/156] feat: add light launcher icon option (#1104) --- app/src/main/AndroidManifest.xml | 30 ++++++++++--- .../com/droiddeck/launcher/LauncherIcon.kt | 42 ++++++++++++++++++ .../com/droiddeck/launcher/MainActivity.kt | 20 ++++++++- .../launcher/frontend/GameShortcuts.kt | 3 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 3 ++ .../droiddeck/launcher/ui/FrontEndSetup.kt | 13 +++++- .../mipmap-anydpi-v26/ic_launcher_light.xml | 5 +++ .../ic_launcher_background_light.png | Bin 0 -> 370 bytes .../ic_launcher_foreground_light.png | Bin 0 -> 2059 bytes .../ic_launcher_background_light.png | Bin 0 -> 235 bytes .../ic_launcher_foreground_light.png | Bin 0 -> 1331 bytes .../ic_launcher_background_light.png | Bin 0 -> 511 bytes .../ic_launcher_foreground_light.png | Bin 0 -> 2822 bytes .../ic_launcher_background_light.png | Bin 0 -> 898 bytes .../ic_launcher_foreground_light.png | Bin 0 -> 4528 bytes .../ic_launcher_background_light.png | Bin 0 -> 1352 bytes .../ic_launcher_foreground_light.png | Bin 0 -> 6162 bytes app/src/main/res/values-ar/strings.xml | 3 ++ app/src/main/res/values-b+es+419/strings.xml | 3 ++ app/src/main/res/values-bg/strings.xml | 3 ++ app/src/main/res/values-cs/strings.xml | 3 ++ app/src/main/res/values-da/strings.xml | 3 ++ app/src/main/res/values-de/strings.xml | 3 ++ app/src/main/res/values-el/strings.xml | 3 ++ app/src/main/res/values-es/strings.xml | 3 ++ app/src/main/res/values-fi/strings.xml | 3 ++ app/src/main/res/values-fr/strings.xml | 3 ++ app/src/main/res/values-hi/strings.xml | 3 ++ app/src/main/res/values-hu/strings.xml | 3 ++ app/src/main/res/values-in/strings.xml | 3 ++ app/src/main/res/values-it/strings.xml | 3 ++ app/src/main/res/values-ja/strings.xml | 3 ++ app/src/main/res/values-ko/strings.xml | 3 ++ app/src/main/res/values-ms/strings.xml | 3 ++ app/src/main/res/values-nb/strings.xml | 3 ++ app/src/main/res/values-nl/strings.xml | 3 ++ app/src/main/res/values-pl/strings.xml | 3 ++ app/src/main/res/values-pt-rPT/strings.xml | 3 ++ app/src/main/res/values-pt/strings.xml | 3 ++ app/src/main/res/values-ro/strings.xml | 3 ++ app/src/main/res/values-ru/strings.xml | 3 ++ app/src/main/res/values-sv/strings.xml | 3 ++ app/src/main/res/values-th/strings.xml | 3 ++ app/src/main/res/values-tr/strings.xml | 3 ++ app/src/main/res/values-uk/strings.xml | 3 ++ app/src/main/res/values-vi/strings.xml | 3 ++ app/src/main/res/values-zh-rCN/strings.xml | 3 ++ app/src/main/res/values-zh-rHK/strings.xml | 3 ++ app/src/main/res/values-zh-rTW/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ .../launcher/frontend/GameLaunchLinkTest.kt | 15 ++++++- artwork/README.md | 2 +- 52 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/LauncherIcon.kt create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_background_light.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_background_light.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_background_light.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_light.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground_light.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81b3adcf4..095abb5bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,11 +65,6 @@ android:exported="true" android:screenOrientation="sensorLandscape" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation"> - - - - - @@ -88,6 +83,31 @@ + + + + + + + + + + + + + + + + > = listOf( + DARK to R.string.setup_launcher_icon_dark, + LIGHT to R.string.setup_launcher_icon_light, + ) + + private fun component(context: Context, style: String) = + ComponentName(context, aliases.getValue(style)) + + private fun enabled(context: Context, style: String): Boolean = + when (context.packageManager.getComponentEnabledSetting(component(context, style))) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK + else -> false + } + + fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK + + fun activity(context: Context): ComponentName = component(context, current(context)) + + fun set(context: Context, style: String) { + if (style !in aliases || style == current(context)) return + val pm = context.packageManager + pm.setComponentEnabledSetting(component(context, style), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + aliases.keys.filter { it != style }.forEach { + pm.setComponentEnabledSetting(component(context, it), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 279692714..ec5ff76c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -164,6 +164,7 @@ class MainActivity : ComponentActivity() { private var downloadMirror by mutableStateOf("") private var downloadPrefix by mutableStateOf("") private var animationsEnabled by mutableStateOf(true) + private var launcherIcon by mutableStateOf(LauncherIcon.DARK) private var gameStoresEnabled by mutableStateOf(true) private var gameStoresSpeedTier by mutableStateOf("fast") private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY) @@ -484,6 +485,20 @@ class MainActivity : ComponentActivity() { pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder)) } + private fun switchLauncherIcon(style: String): Boolean { + val before = LauncherIcon.current(this) + LauncherIcon.set(this, style) + launcherIcon = LauncherIcon.current(this) + if (launcherIcon == before) return false + // Android finishes activities started through the alias that was just disabled. + startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + .setComponent(LauncherIcon.activity(this)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + .putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher")) + finishAndRemoveTask() + return true + } + private fun readGameIntent(request: Intent) { request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) } shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT @@ -739,6 +754,7 @@ class MainActivity : ComponentActivity() { downloadPrefix = DownloadSource.custom(this) DownloadSource.probe() animationsEnabled = SessionPrefs.animationsEnabled(this) + launcherIcon = LauncherIcon.current(this) gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) storesOpenTab = SessionPrefs.storesOpenTab(this) @@ -799,6 +815,7 @@ class MainActivity : ComponentActivity() { downloadMirror = downloadMirror, downloadPrefix = downloadPrefix, animationsEnabled = animationsEnabled, + launcherIcon = launcherIcon, gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, @@ -952,6 +969,7 @@ class MainActivity : ComponentActivity() { onPageBack = { showPerformance = false; showComponents = false; showMapping = false }, onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, + onLauncherIcon = { style -> switchLauncherIcon(style) }, onLanguage = { tag -> if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) { com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag) @@ -2045,7 +2063,7 @@ class MainActivity : ComponentActivity() { com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM) if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show() com.droiddeck.launcher.ui.Motion.refresh(this) - recreate() + if (!switchLauncherIcon(LauncherIcon.DARK)) recreate() } }, "reset-settings").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt index d5ca08be2..80cdd93c1 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameShortcuts.kt @@ -10,6 +10,7 @@ import android.graphics.BitmapFactory import android.graphics.drawable.Icon import android.net.Uri import android.widget.Toast +import com.droiddeck.launcher.LauncherIcon import com.droiddeck.launcher.MainActivity import com.droiddeck.launcher.R import java.io.File @@ -23,7 +24,7 @@ object GameShortcuts { val name = game.name.ifBlank { context.getString(R.string.app_name) } val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher) return ShortcutInfo.Builder(context, "game:${game.gameIdString}") - .setActivity(android.content.ComponentName(context, MainActivity::class.java)) + .setActivity(LauncherIcon.activity(context)) .setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon) .setIntent(launchIntent(context, game.gameIdString)).build() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 543778b34..0b9e784e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -138,6 +138,7 @@ class FrontEndState( val downloadMirror: String = "", val downloadPrefix: String = "", val animationsEnabled: Boolean = true, + val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK, val gameStoresEnabled: Boolean = true, val gameStoresSpeedTier: String = "fast", val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY, @@ -205,6 +206,7 @@ class FrontEndActions( val onTheme: (String) -> Unit = {}, val onLanguage: (String) -> Unit = {}, val onAppScale: (Int) -> Unit = {}, + val onLauncherIcon: (String) -> Unit = {}, val onOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, @@ -386,6 +388,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com when (target) { "stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" } "components" -> a.onComponents(true) + "setup:launcher" -> { SetupTabs.openLauncher = true; selected = "setup" } else -> selected = target } a.onNavigated() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 359450dae..42eb27d69 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -86,6 +86,11 @@ import com.droiddeck.launcher.R private enum class CheckState { OK, WARN, BUSY } private const val STORAGE_TAB = 3 +private const val LAUNCHER_TAB = 2 + +internal object SetupTabs { + var openLauncher = false +} private const val GLASSES_TAB = 5 @Composable @@ -177,7 +182,8 @@ internal fun SetupPanel( onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false }, ) val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses)) - var tab by rememberSaveable { mutableStateOf(0) } + var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) } + SetupTabs.openLauncher = false val chipFocus = remember { List(tabs.size) { FocusRequester() } } var tabTurned by remember { mutableStateOf(false) } val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } @@ -362,6 +368,11 @@ internal fun SetupPanel( ) }, s.appScale, onPick = a.onAppScale, ) + ChoiceRow( + host, "launcher-icon", stringResource(R.string.setup_launcher_icon), null, + com.droiddeck.launcher.LauncherIcon.options.map { (style, label) -> style to stringResource(label) }, + s.launcherIcon, onPick = a.onLauncherIcon, + ) ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, s.animationsEnabled, onChange = a.onAnimationsEnabled) LanguageRow(host, s.language, a.onLanguage) diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml new file mode 100644 index 000000000..275b9dfdd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background_light.png new file mode 100644 index 0000000000000000000000000000000000000000..0ed586fceea38f753381df03ab525ef2c006b13d GIT binary patch literal 370 zcmeAS@N?(olHy`uVBq!ia0vp^i$Iuz4M-mPBqq(kz-Z~|;uunK>+K~+K?Vkn!v+WT zKUZvK?`(;)_T=j8Wswl<=@2~FK{A!#(!=3)q=O8~K*^0oImv}Ys!7mLNkUMW42lfT e0Yl?NIk)3ER)@8JjFW+3$>8bg=d#Wzp$Pz18E!WK literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground_light.png new file mode 100644 index 0000000000000000000000000000000000000000..e89aa93ddee4e301c0568c4de84e66122de01c1d GIT binary patch literal 2059 zcma)-`#TegAIIl5Vq@K0!igNoHRk>`w9M@!l-r~eBRId`TY8R-mgq|S4Xh)DQN%z0CsjlTs_pM z|Ev`7@OEv=D+2(MW6lV;X9D{xC;EXGMx<|Jy=fH~u5&E)HeTH++}n(+c-(N}gu_B; zW})=i@LOQh35yO^g?rQLlN^xg2J=*#^0|3e$|UI6`2cF12>P)uOtDN;~BAx*{?EWDk^8M$RTx46Xz2(AJtJadLoYW1xmc zh9t4gvS5$9+;L0)AaS*V!%rmS0Y+%yz0SJx5_9KdLzWgXpz@$%LrY4wYItI-qnccbi6uF zgQ%3oP7IQ1_^<7I&PSz0w?7t}x31n8(A@6bT9<54ZLC<8cu}j^m4qJPT`ipfcFOSI z!byg^k4Mz_duD>jByE2;a$$$S%84x@tocgY5{-Btb&fO++1I!=$6JVr>f436jbULY ztZy1b5!Z5$`Dw-V%6TG})|aE1`=_?Q6*}y-P1M8RC<)M$cXO}4fp+ynT4Ud(N=nf4 zbjCSXvI^I-VBq9NrID;5#hwWUSL+R1)iF*lZw4@V-@R|Rh8V+O=Pqp6v~z37rgxC& zXWVOP$Ye*NeMv}H+8~(N8=$Cz6}c!iHE}1y%@esW5awGKbJB2BP5yJ<*dhf@#rzf% zOdyf=W*ytl7R+OmvTxHd&mENc&S}W&LSoV%;eGn~6AUHGf@QL|KzoN-KQdPrRzjWM zoN|Hx^!EPd)OSMuzI&0dzdEUe#x_b%SIXY5!n|O*oQg<}Z4x)4#F|+!1*G{u5gq=7 z&6LQWc1Cq~o1ywvUa{q6k&x^V8Gir9`PZ)-EX2<~IQaTJ^SQp>S>ZPKk&gK-h|HHv z>FE7OUCEGqs-(yEIA1>P698W7nPn{VJv5KOYj)}LbNnus2+fe#bVKf8b?R_mwjaz#qf?tIs%55JwcG zL=_mgtzoyh4K=@432gMeTkPqbsO|r}Eg)O~jLEpGC`Ofu50izNzhiH)R7^5@q3L8l zd?Nu(&@HG{E%xkPe68FwpTp@`6euKFbw|JZlK&8GkBPrV=ML53e(v4`2W||$!~%c# z5<3jsK=jqpX+lyO(r|sC9dw4xuhoOm2O)WX^)v7mAgKO$E&j91_(=BSNTD${f z3t(nixQtapk51{FA8;hxRPG6Ee-d=2485g-3L~!$Z$z|KF99wY%a@5IxAYH1xps#_ zv=<{>wQ|EctW}OQs1;f1=kG(>GCz0cOQWeHwYUMdK&!wC4&1@RX(TcLZ5fnXRab{Y z1^i=}PcskGHR>jdZU)cKlGT-3rzxYQ<6f=?Gu_88wsy7{$9f4|9(klJkzRe+_B2Yf zaIuk|DtlGlVyq_`m6dN7H+3+FL;1JA)QcDM`Pi^mn2`N4VCo@oQ{1{NVB*3or%^Tq zfDZb2LB=@A_GG(mJuYr>{G8$kPJtFU5dC)jq-U{Z`AcKi&X3gAdQd!5{fkQeWWPe8 zA6`iF>wcnJf7^!dl=FmT9`;^~X(p`9exhDdT#d_VJJA=5hyoLp3A(^PO`2%$9{Y&e z(v3?aew{91oDHXrW32ezX`hPW;t7R3Gd9!XxyA1P6N2$3O?EtMgn>9cozc8i-y-%2 zj!FW-ILf;V+Fd)gJ^&7b8V-{+A7jY<-nXp3Z|lQa!krU_jHP_Mwx7d91r5Poi4@y_ zFf`hluIy;hmt$zdP5K30vAUX(d)Xe&r>FymMXBk4;<-t;k%KPwUAy65?X2E1TlL_w z`Q9CXxN<0UJBImg0!Stw zH9T4cubLxS*LTlV^|RmQ^-lKj8oX literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb44b431e803b2c962c2031c7205c1855ebe512 GIT binary patch literal 1331 zcmV-31cvKeP(d1!5|jl6fkBjEAz{v~^u>||rf8IND<}pg^up#7*->FC-+9Cak8txLXqa5ms((_vytwcRTmbw&(8NyL(Ujec+<=}%QAlVzQ#TW zdVqht^*4Y4;AgMdzt zBG%Gx;F}T4@!x7pBu%-m0*4&iViZ;hlm3-=9<@tCqcj1v#!P+1Yb{X> zhodFz{dJSMmh|>ZKnHF^Q-{1ueg!s#_krExOWk%}xSx7sc-v&yPqJ_wOT$1FAMu14 z^}Th_F^Io`JG?*D4@=`D@YeWJr!5U%VSmWbY-Z4@HL%ba_Ak9cGo7~7_ttjctV0pE z02|bCp8UL32Snp5!b3EM;Po7QwbX})X!Nc67pMV79X8*rz|w2=I=!)TR-1h>1iNzh zpWhYkuNQz!G%< zgT52THlREY^(-A5Vr$Y-cFJKp1(u{I>GCdb=M7_1t`55#He0E{CJ8LuZYwb?&tvCi zdAQwHPP((CSE;}f_4+>Kidkrg73u1p!XgDW;ZJXLBi`A~``(;X$FO(NgYG!H87#op zPi2~&Mu9oHsYtpN3d~VBsnat^{s&bDr}_*EZ2BW?f$#j1_phgDVqlAkV zV_^RH1;|wzv2^F4?5`tN6YeL*C*uWS2v}^Cfo#Ss&-I+3~?dLITdTYGBT<2 zlA_A~=SIhXXK{O2%3@%>x6Mp`Zgjt*%I$MHE&xZ99+q;K_Z50E%d+bfQ*k_4#pTt4 zZm$H!sY3ozMS*3e1-DWASs(h5L?gh~LJmJEFn2!!n}GA^NjOFAIKmtH425~L6Qe+=)PrOU_9pO;uunK>+NMnUIqq^BL)Zd zKX+!8zvLCM$!`*qUk^)yfq?`FOYnd&4;u)xF@tb3BM>(pWPsuW2@MAiBqSJsFs2r) z+K{zi*M?*h!Zu`W$Ts0|AVdp32SRK@^B1N~gEA;STxLqmW?eI5<>D{Eh-L6}^>bP0 Hl+XkKrtF4? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2e96d09347fa6f2ed086a8b80b91b57dfa09c555 GIT binary patch literal 2822 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png new file mode 100644 index 0000000000000000000000000000000000000000..134c0842ad7ff7bb14134a67c4ffb12d18943699 GIT binary patch literal 4528 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3381ca393..fcc1b2258 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -741,6 +741,9 @@ %1$d%% (افتراضي) المظهر مظهر المشغّل + أيقونة التطبيق + داكنة + فاتحة استخدام كشاشة رئيسية يمكن أن يكون DroidDeck تطبيق الشاشة الرئيسية لا يُعرَض DroidDeck أبدًا كتطبيق للشاشة الرئيسية diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 3ff6b8521..877992f59 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -752,6 +752,9 @@ %1$d%% (predeterminada) Tema Apariencia del launcher + Ícono de la app + Oscuro + Claro Usar como pantalla de inicio DroidDeck puede ser la app de inicio DroidDeck nunca se ofrece como app de inicio diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index f562b8bb8..4e14e9940 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -729,6 +729,9 @@ %1$d%% (по подразбиране) Тема Облик на лаунчъра + Икона на приложението + Тъмна + Светла Използване като начален екран DroidDeck може да бъде приложението за начален екран DroidDeck никога не се предлага като приложение за начален екран diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index df75b0192..366be9edc 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -735,6 +735,9 @@ %1$d %% (výchozí) Téma Vzhled spouštěče + Ikona aplikace + Tmavá + Světlá Použít jako domovskou obrazovku DroidDeck může být domovská aplikace DroidDeck se nikdy nenabízí jako domovská aplikace diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 931534a37..46d435398 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -729,6 +729,9 @@ %1$d%% (standard) Tema Launcherens udseende + Appikon + Mørk + Lys Brug som startskærm DroidDeck kan være Home-appen DroidDeck tilbydes aldrig som Home-app diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dfaa30fed..66b18fde8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -718,6 +718,9 @@ %1$d %% (Standard) Design Aussehen des Launchers + App-Symbol + Dunkel + Hell Als Startbildschirm verwenden DroidDeck kann die Startbildschirm-App sein DroidDeck wird nie als Startbildschirm-App angeboten diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 7efafb770..786b4ddb5 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -729,6 +729,9 @@ %1$d%% (Προεπιλογή) Θέμα Εμφάνιση εκκινητή + Εικονίδιο εφαρμογής + Σκούρο + Ανοιχτό Χρήση ως αρχική οθόνη Το DroidDeck μπορεί να είναι η εφαρμογή αρχικής οθόνης Το DroidDeck δεν προτείνεται ποτέ ως εφαρμογή αρχικής οθόνης diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ccc1e9e37..7db317747 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -729,6 +729,9 @@ %1$d%% (predeterminada) Tema Apariencia del launcher + Icono de la app + Oscuro + Claro Usar como pantalla de inicio DroidDeck puede ser la app de inicio DroidDeck nunca se ofrece como app de inicio diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9b156663c..de265dcab 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -729,6 +729,9 @@ %1$d %% (Oletus) Teema Käynnistimen ulkoasu + Sovelluskuvake + Tumma + Vaalea Käytä Aloitusnäyttönä DroidDeck voi olla kotisovellus DroidDeckiä ei koskaan tarjota kotisovellukseksi diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fc79c4e3c..1d09298d5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -729,6 +729,9 @@ %1$d%% (par défaut) Thème Apparence du lanceur + Icône de l\'app + Sombre + Clair Utiliser comme écran d\'accueil DroidDeck peut être utilisé comme application d\'écran d\'accueil DroidDeck ne sera pas proposé comme application d\'écran d\'accueil diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index a9cbd38f3..90c47ff45 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -729,6 +729,9 @@ %1$d%% (डिफ़ॉल्ट) थीम लॉन्चर की दिखावट + ऐप आइकन + गहरा + हल्का होम स्क्रीन के तौर पर इस्तेमाल करें DroidDeck होम ऐप हो सकता है DroidDeck को होम ऐप के तौर पर कभी नहीं दिखाया जाता diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 747aac900..96cb5ed36 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -729,6 +729,9 @@ %1$d%% (Alapértelmezett) Téma Az indítóalkalmazás megjelenése + Alkalmazásikon + Sötét + Világos Használat kezdőképernyőként A DroidDeck lehet a kezdőképernyő alkalmazás A DroidDeck soha nem jelenik meg kezdőképernyő alkalmazásként diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2c695930b..9ab984dce 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -726,6 +726,9 @@ %1$d%% (Default) Tema Tampilan peluncur + Ikon aplikasi + Gelap + Terang Gunakan sebagai Layar Utama DroidDeck dapat menjadi aplikasi Utama DroidDeck tidak pernah ditawarkan sebagai aplikasi Utama diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4d6cb901c..cd769f615 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -721,6 +721,9 @@ %1$d%% (predefinita) Tema Aspetto del launcher + Icona dell\'app + Scura + Chiara Usa come schermata Home DroidDeck può essere l\'app Home DroidDeck non viene mai proposta come app Home diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2df5383a9..b350ede00 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -726,6 +726,9 @@ %1$d%%(デフォルト) テーマ ランチャーの外観 + アプリアイコン + ダーク + ライト ホーム画面として使用 DroidDeckをホームアプリとして使用できます DroidDeckはホームアプリとして提供されません diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0705efab9..f076dc4fb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -726,6 +726,9 @@ %1$d%%(기본값) 테마 런처 외관 + 앱 아이콘 + 다크 + 라이트 홈 화면으로 사용 DroidDeck을 홈 앱으로 사용할 수 있습니다 DroidDeck은 홈 앱으로 제공되지 않습니다 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 6729d1e58..344e02520 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -726,6 +726,9 @@ %1$d%% (Lalai) Tema Rupa pelancar + Ikon apl + Gelap + Cerah Gunakan sebagai skrin Utama DroidDeck boleh menjadi apl Utama DroidDeck tidak sekali-kali ditawarkan sebagai apl Utama diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 42962f25a..3b316f00e 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -729,6 +729,9 @@ %1$d%% (Standard) Tema Launcherens utseende + Appikon + Mørk + Lys Bruk som Hjem-skjerm DroidDeck kan være Hjem-appen DroidDeck tilbys aldri som en Hjem-app diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 46389acca..ec1b209ab 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -729,6 +729,9 @@ %1$d%% (Standaard) Thema Uiterlijk van de launcher + App-pictogram + Donker + Licht Als startscherm gebruiken DroidDeck kan de startschermapp zijn DroidDeck wordt nooit aangeboden als startschermapp diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 32634d99b..35a3afaef 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -735,6 +735,9 @@ %1$d%% (domyślnie) Motyw Wygląd aplikacji + Ikona aplikacji + Ciemna + Jasna Użyj jako ekranu głównego DroidDeck może być aplikacją ekranu głównego DroidDeck nigdy nie jest proponowany jako aplikacja ekranu głównego diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 13412341a..614ece612 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -721,6 +721,9 @@ %1$d%% (padrão) Tema Aspeto do iniciador + Ícone da app + Escuro + Claro Utilizar como página inicial O DroidDeck pode ser a app de página inicial O DroidDeck nunca é proposto como app de página inicial diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b3d775666..b5c4889e3 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -721,6 +721,9 @@ %1$d%% (padrão) Tema Aparência do inicializador + Ícone do app + Escuro + Claro Usar como tela inicial O DroidDeck pode ser o app de início O DroidDeck nunca é oferecido como app de início diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 0453831e3..840d1dfb3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -732,6 +732,9 @@ %1$d%% (Implicit) Temă Aspectul launcherului + Pictograma aplicației + Întunecată + Luminoasă Folosește ca ecran Acasă DroidDeck poate fi aplicația Acasă DroidDeck nu este niciodată oferit ca aplicație Acasă diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 78d237c6c..f939fd97f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -732,6 +732,9 @@ %1$d%% (по умолчанию) Тема Внешний вид лаунчера + Значок приложения + Тёмный + Светлый Использовать как главный экран DroidDeck может использоваться как приложение главного экрана DroidDeck не будет доступен как приложение главного экрана diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4ddc7eacc..58279d36b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -729,6 +729,9 @@ %1$d %% (Standard) Tema Startprogrammets utseende + Appikon + Mörk + Ljus Använd som startskärm DroidDeck kan vara startskärmsappen DroidDeck erbjuds aldrig som startskärmsapp diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 4bda3c786..f13ce19aa 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -726,6 +726,9 @@ %1$d%% (ค่าเริ่มต้น) ธีม รูปลักษณ์ของตัวเปิดแอป + ไอคอนแอป + มืด + สว่าง ใช้เป็นหน้าจอหลัก DroidDeck สามารถเป็นแอปหน้าจอหลักได้ DroidDeck จะไม่ถูกเสนอให้เป็นแอปหน้าจอหลัก diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3322f1c92..7a7e40f23 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -718,6 +718,9 @@ %%%1$d (Varsayılan) Tema Başlatıcı görünümü + Uygulama simgesi + Koyu + Açık Ana ekran olarak kullan DroidDeck ana ekran uygulaması olabilir DroidDeck hiçbir zaman ana ekran uygulaması olarak sunulmaz diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fb30a8e4e..6a19b33c9 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -735,6 +735,9 @@ %1$d%% (стандартно) Тема Вигляд лаунчера + Значок застосунку + Темний + Світлий Використовувати як головний екран DroidDeck може бути додатком головного екрана DroidDeck ніколи не пропонується як додаток головного екрана diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 64c63421b..e1f58f9e8 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -726,6 +726,9 @@ %1$d%% (Mặc định) Giao diện Giao diện launcher + Biểu tượng ứng dụng + Tối + Sáng Dùng làm màn hình chính DroidDeck có thể là ứng dụng Màn hình chính DroidDeck không bao giờ được đề xuất làm ứng dụng Màn hình chính diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c0acbc778..caf528050 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -726,6 +726,9 @@ %1$d%%(默认) 主题 启动器外观 + 应用图标 + 深色 + 浅色 用作主屏幕 DroidDeck 可以设为主屏幕应用 系统不会把 DroidDeck 作为主屏幕应用提供 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index a8978f454..252446f85 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -726,6 +726,9 @@ %1$d%%(預設) 主題 啟動器外觀 + 應用程式圖示 + 深色 + 淺色 用作主畫面 DroidDeck 可用作主畫面應用程式 DroidDeck 不會作為主畫面應用程式的選項 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 84195dcd7..10865ff6d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -726,6 +726,9 @@ %1$d%%(預設) 佈景主題 啟動器外觀 + 應用程式圖示 + 深色 + 淺色 用作主螢幕 DroidDeck 可以作為主螢幕應用程式 DroidDeck 不會作為主螢幕應用程式提供 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1f8c0203..424d8e35b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -760,6 +760,9 @@ %1$d%% (Default) Theme Launcher appearance + App icon + Dark + Light Use as Home screen DroidDeck can be the Home app DroidDeck is never offered as a Home app diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt index e23faaf69..9493a630b 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend import android.content.Intent import android.net.Uri import android.app.Activity +import com.droiddeck.launcher.LauncherIcon import com.droiddeck.launcher.MainActivity import com.droiddeck.launcher.frontend.Library import org.junit.Assert.assertEquals @@ -68,12 +69,24 @@ class GameShortcutIntentTest { val launch = requireNotNull(shortcut.intent) assertEquals("game:8400", shortcut.id) assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString()) - assertEquals(MainActivity::class.java.name, shortcut.activity?.className) + assertEquals("com.droiddeck.launcher.LauncherDark", shortcut.activity?.className) + assertEquals(MainActivity::class.java.name, launch.component?.className) assertEquals(Intent.ACTION_VIEW, launch.action) assertEquals(Uri.parse("droiddeck://game/8400"), launch.data) assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags) } + @Test fun pinnedShortcutFollowsTheLightLauncherIcon() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + LauncherIcon.set(activity, LauncherIcon.LIGHT) + + GameShortcuts.pin(activity, Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)) + + val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java).pinnedShortcuts.single() + assertEquals(LauncherIcon.LIGHT, LauncherIcon.current(activity)) + assertEquals("com.droiddeck.launcher.LauncherLight", shortcut.activity?.className) + } + @Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L) diff --git a/artwork/README.md b/artwork/README.md index 6b2f7de2e..7f4f24ec9 100644 --- a/artwork/README.md +++ b/artwork/README.md @@ -2,4 +2,4 @@ `droiddeck-mark.svg` is the editable source for the white split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. Place it on black when a background is needed. -Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. `drawable-nodpi/logo.png` composes the same mark on a black circular badge. +Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. The `_light` variants recolor the mark black on a white background and back the light launcher icon users can pick in Settings. `drawable-nodpi/logo.png` composes the same mark on a black circular badge. From 7370145f89d77b03badefb6f59c0e74710bf8d10 Mon Sep 17 00:00:00 2001 From: kingokksa <47888451+kingokksa@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:54:13 +0000 Subject: [PATCH 013/156] feat: itch.io store, library and installs for DRM-free Windows builds (#1064) Co-authored-by: xXJSONDeruloXx <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../launcher/runtime/GuestCommand.kt | 15 +- .../droiddeck/launcher/stores/CloudSaves.kt | 6 +- .../droiddeck/launcher/stores/LoginFlows.kt | 1 + .../com/droiddeck/launcher/stores/Store.kt | 3 +- .../droiddeck/launcher/stores/StoreSizes.kt | 2 + .../droiddeck/launcher/stores/StoresState.kt | 26 ++ .../launcher/stores/download/DownloadQueue.kt | 2 +- .../droiddeck/launcher/stores/itch/ItchApi.kt | 34 ++ .../launcher/stores/itch/ItchAuth.kt | 36 ++ .../launcher/stores/itch/ItchBackend.kt | 71 ++++ .../launcher/stores/itch/ItchCatalog.kt | 90 +++++ .../launcher/stores/itch/ItchInstaller.kt | 372 ++++++++++++++++++ .../launcher/stores/itch/ItchLibrary.kt | 162 ++++++++ .../launcher/stores/itch/ItchPrefs.kt | 11 + .../launcher/stores/itch/ItchShelves.kt | 199 ++++++++++ .../launcher/stores/itch/ItchUploads.kt | 36 ++ .../droiddeck/launcher/ui/ItchKeyDialog.kt | 115 ++++++ .../com/droiddeck/launcher/ui/StoreChips.kt | 2 + .../droiddeck/launcher/ui/StoresDownloads.kt | 19 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 28 +- app/src/main/res/values-ar/strings.xml | 10 + app/src/main/res/values-b+es+419/strings.xml | 10 + app/src/main/res/values-bg/strings.xml | 10 + app/src/main/res/values-cs/strings.xml | 10 + app/src/main/res/values-da/strings.xml | 10 + app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values-el/strings.xml | 10 + app/src/main/res/values-es/strings.xml | 10 + app/src/main/res/values-fi/strings.xml | 10 + app/src/main/res/values-fr/strings.xml | 10 + app/src/main/res/values-hi/strings.xml | 10 + app/src/main/res/values-hu/strings.xml | 10 + app/src/main/res/values-in/strings.xml | 10 + app/src/main/res/values-it/strings.xml | 10 + app/src/main/res/values-ja/strings.xml | 10 + app/src/main/res/values-ko/strings.xml | 10 + app/src/main/res/values-ms/strings.xml | 10 + app/src/main/res/values-nb/strings.xml | 10 + app/src/main/res/values-nl/strings.xml | 10 + app/src/main/res/values-pl/strings.xml | 10 + app/src/main/res/values-pt-rPT/strings.xml | 10 + app/src/main/res/values-pt/strings.xml | 10 + app/src/main/res/values-ro/strings.xml | 10 + app/src/main/res/values-ru/strings.xml | 10 + app/src/main/res/values-sv/strings.xml | 10 + app/src/main/res/values-th/strings.xml | 10 + app/src/main/res/values-tr/strings.xml | 10 + app/src/main/res/values-uk/strings.xml | 10 + app/src/main/res/values-vi/strings.xml | 10 + app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values-zh-rHK/strings.xml | 10 + app/src/main/res/values-zh-rTW/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + 54 files changed, 1534 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index e0abe387d..9c7f928d7 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -35,5 +35,6 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend) + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend) } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 9c65c426a..8b4f14e5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -11,7 +11,7 @@ import java.io.File object GuestCommand { fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null, - linkDir: File? = null, onLine: (String) -> Unit): Int { + linkDir: File? = null, extraBinds: List? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int { val log = logName?.let { try { File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() @@ -19,13 +19,14 @@ object GuestCommand { } log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") try { - return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) } + return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) } } finally { log?.close() } } - private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int { + private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, extraBinds: List?, + fastPath: Boolean, onLine: (String) -> Unit): Int { val root = LinuxRuntime.rootDir(context) LinuxRuntime.writeAccounts(context) SessionFiles.stage(context, root) @@ -33,8 +34,10 @@ object GuestCommand { val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() } val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot) if (linkDir != null) cmd.add(1, "--link2symlink") - LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null) - .forEach { cmd.add("-b"); cmd.add(it) } + val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds) + binds.forEach { cmd.add("-b"); cmd.add(it) } + // Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path. + val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null cmd += listOf( "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}", @@ -42,6 +45,7 @@ object GuestCommand { "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) + fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) } cmd += argv val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true) builder.environment().apply { @@ -49,6 +53,7 @@ object GuestCommand { put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path) if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path) if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1") + fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } } LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } } val process = builder.start() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 3c3d38d26..ccfd221ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -57,7 +57,7 @@ object CloudSaves { val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON -> "" + Store.AMAZON, Store.ITCH -> "" } ?: return null p.edit().putString("cloud_template_$id", fetched).apply() Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}") @@ -95,7 +95,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON -> null + Store.AMAZON, Store.ITCH -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -120,7 +120,7 @@ object CloudSaves { val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON -> null + Store.AMAZON, Store.ITCH -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index 7a4f579ae..43517ebfe 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -12,6 +12,7 @@ internal object LoginFlows { Store.GOG -> GogFlow(state) Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() + Store.ITCH -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index 3288e1c6e..d7cc814cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores enum class Store(val id: String, val label: String, val shortLabel: String) { GOG("gog", "GOG", "GOG"), EPIC("epic", "Epic Games", "Epic"), - AMAZON("amazon", "Amazon Games", "Amazon"); + AMAZON("amazon", "Amazon Games", "Amazon"), + ITCH("itch", "itch.io", "itch"); companion object { fun byId(id: String?): Store? = entries.firstOrNull { it.id == id } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 9e37226f8..01014dbd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -54,6 +54,7 @@ object StoreSizes { when (item.store) { Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() + Store.ITCH -> {} Store.GOG -> {} } } @@ -108,5 +109,6 @@ object StoreSizes { AmazonManifest.parse(bytes).totalInstallSize } Store.GOG -> 0L + Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 6b99210e9..63cc4a4cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -16,6 +16,7 @@ import java.util.Locale object StoresState { private const val TAG = "Stores" + private const val SEARCH_DELAY_MS = 400L private val main = Handler(Looper.getMainLooper()) val accounts = mutableStateMapOf() @@ -24,6 +25,8 @@ object StoresState { val shelves = mutableStateMapOf() + val found = mutableStateMapOf>() + val status = mutableStateMapOf() val problems = mutableStateMapOf() @@ -64,6 +67,8 @@ object StoresState { private val backends = HashMap() + private val searchSeq = java.util.concurrent.ConcurrentHashMap() + fun backend(store: Store): StoreBackend? = backends[store] fun register(backend: StoreBackend) { backends[backend.store] = backend } @@ -129,6 +134,26 @@ object StoresState { if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false) } + fun search(context: Context, store: Store, query: String) { + val backend = backends[store] ?: return + val text = query.trim() + if (text.isEmpty()) { + searchSeq.remove(store) + found.remove(store) + return + } + val seq = (searchSeq[store] ?: 0) + 1 + searchSeq[store] = seq + val app = context.applicationContext + Thread({ + Thread.sleep(SEARCH_DELAY_MS) + if (searchSeq[store] != seq) return@Thread + val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList()) + if (searchSeq[store] != seq) return@Thread + main.post { found[store] = items } + }, "stores-search").start() + } + fun signIn(context: Context, store: Store) { expired.remove(store) backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet") @@ -265,6 +290,7 @@ interface StoreBackend { fun signOut(context: Context) fun syncLibrary(context: Context, force: Boolean) fun loadShelves(context: Context, force: Boolean) + fun search(context: Context, query: String): List = emptyList() fun install(context: Context, item: CatalogItem, root: java.io.File) fun uninstall(context: Context, game: InstalledStoreGame) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt index e2549a3be..cfb1324f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -335,7 +335,7 @@ object DownloadQueue { StoresState.logLine("installed \"${item.entry.name}\" → ${result}") StoresState.notifyLibraryChanged(context) val games = runCatching { - com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" } + com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" } }.getOrNull() StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt new file mode 100644 index 000000000..92635630d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt @@ -0,0 +1,34 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchApi { + const val KEYS_PAGE = "https://itch.io/user/settings/api-keys" + + private const val BASE = "https://api.itch.io" + private const val WEB = "https://itch.io" + + fun get(path: String, key: String): JSONObject? { + val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null + return runCatching { JSONObject(body) }.getOrNull() + } + + fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user") + + fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key) + + fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? = + get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads") + + fun search(key: String, query: String, page: Int): JSONArray? { + val q = java.net.URLEncoder.encode(query, "UTF-8") + return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games") + } + + fun downloadUrl(uploadId: String, downloadKeyId: String): String = + "$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId" + + fun pageUrl(slug: String): String = "$WEB/$slug" +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt new file mode 100644 index 000000000..622aa5383 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONObject + +internal object ItchAuth { + private val TOKEN = Regex("[A-Za-z0-9]{40,64}") + + fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty() + + fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty() + + fun verify(key: String): JSONObject? = ItchApi.profile(key) + + fun save(context: Context, key: String, user: JSONObject) { + StoreAccounts.write( + context, + Store.ITCH, + JSONObject() + .put("access_token", key) + .put("username", user.optString("username")) + .put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") }) + .put("user_id", user.optLong("id")), + ) + } + + fun candidates(text: String): List { + if (text.isBlank()) return emptyList() + val out = LinkedHashSet() + text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) } + TOKEN.findAll(text).forEach { out.add(it.value) } + return out.take(8) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt new file mode 100644 index 000000000..b1bf834c1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreBackend +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreSizes +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import java.io.File + +object ItchBackend : StoreBackend { + override val store: Store = Store.ITCH + + override fun signIn(context: Context) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + override fun signOut(context: Context) { + ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply() + StoresState.post { + StoresState.library.remove(store) + StoresState.shelves.remove(store) + StoresState.found.remove(store) + StoresState.problems.remove(store) + StoresState.status.remove(store) + } + } + + override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start() + } + + override fun loadShelves(context: Context, force: Boolean) { + val app = context.applicationContext + ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } } + Thread({ + val shelves = ItchShelves.featured(app, force) + if (shelves != null) { + StoresState.post { StoresState.shelves[store] = shelves } + } else { + StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) } + } + }, "itch-shelves").start() + } + + override fun search(context: Context, query: String): List = ItchShelves.search(context, query) + + override fun install(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + if (item.extra["downloadKeyId"].isNullOrEmpty()) { + StoresState.logLine("itch.io: \"${item.title}\" is not in the library") + return + } + val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root) + val entry = DownloadEntry( + Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item), + location = StoreInstallRoot.labelFor(app, folder), + ) + DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt new file mode 100644 index 000000000..fc4f4a168 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.stores.itch + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreShelves +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchCatalog { + fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/') + + fun shelvesJson(shelves: StoreShelves): String = JSONObject() + .put("new", array(shelves.whatsNew)) + .put("deals", array(shelves.deals)) + .put("free", array(shelves.free)) + .put("top", array(shelves.trending)) + .toString() + + fun shelves(raw: String?): StoreShelves? { + if (raw.isNullOrBlank()) return null + val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val shelves = StoreShelves( + whatsNew = list(o.optJSONArray("new")), + deals = list(o.optJSONArray("deals")), + free = list(o.optJSONArray("free")), + trending = list(o.optJSONArray("top")), + ) + return shelves.takeUnless { it.isEmpty } + } + + fun array(items: List): JSONArray { + val out = JSONArray() + items.forEach { out.put(json(it)) } + return out + } + + fun list(raw: JSONArray?): List { + if (raw == null) return emptyList() + return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) } + } + + private fun json(i: CatalogItem): JSONObject = JSONObject() + .put("id", i.id) + .put("title", i.title) + .put("image", i.imageUrl ?: "") + .put("tall", i.tallImageUrl ?: "") + .put("tags", i.tags) + .put("free", i.isFree) + .put("hasPrice", i.hasPrice) + .put("finalPrice", i.finalPrice) + .put("originalPrice", i.originalPrice) + .put("discount", i.discountPercent) + .put("url", i.storeUrl) + .put("developer", i.developer) + .put("publisher", i.publisher) + .put("releaseDate", i.releaseDate) + .put("description", i.description) + .put("owned", i.owned) + .put("size", i.sizeBytes) + .put("extra", JSONObject(i.extra)) + .put("mature", i.mature) + + private fun item(o: JSONObject): CatalogItem { + val extra = o.optJSONObject("extra") + val map = HashMap() + extra?.keys()?.forEach { k -> map[k] = extra.optString(k) } + return CatalogItem( + store = Store.ITCH, + id = o.optString("id"), + title = o.optString("title"), + imageUrl = o.optString("image").ifBlank { null }, + tallImageUrl = o.optString("tall").ifBlank { null }, + tags = o.optString("tags"), + isFree = o.optBoolean("free"), + hasPrice = o.optBoolean("hasPrice"), + finalPrice = o.optString("finalPrice"), + originalPrice = o.optString("originalPrice"), + discountPercent = o.optInt("discount"), + storeUrl = o.optString("url"), + developer = o.optString("developer"), + publisher = o.optString("publisher"), + releaseDate = o.optString("releaseDate"), + description = o.optString("description"), + owned = o.optBoolean("owned"), + sizeBytes = o.optLong("size"), + extra = map, + mature = o.optBoolean("mature"), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt new file mode 100644 index 000000000..96ec0ae35 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt @@ -0,0 +1,372 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.GuestCommand +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreExe +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.apache.commons.compress.archivers.sevenz.SevenZFile +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +internal class ItchInstaller( + private val app: Context, + private val item: CatalogItem, + private val folder: File, +) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + + override fun run(handle: DownloadQueue.JobHandle): String? { + val key = ItchAuth.key(app) + if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in)) + val downloadKeyId = item.extra["downloadKeyId"].orEmpty() + if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned)) + val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') } + StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl) + handle.stage(DownloadStage.MANIFEST) + val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) + ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload)) + val uploadId = upload.optString("id") + val filename = ItchUploads.filename(upload) + val size = ItchUploads.size(upload) + val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id) + scratch.mkdirs() + val archive = File(scratch, filename.ifBlank { "download.bin" }) + val exe = try { + handle.stage(DownloadStage.DOWNLOAD) + download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle) + if (cancelled.get()) return null + handle.stage(DownloadStage.INSTALL) + install(archive, filename, handle) + } catch (t: Throwable) { + if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch)) + throw t + } + if (exe == null) { + if (cancelled.get()) return null + StoreInstalls.discard(app, folder, listOf(scratch)) + throw IllegalStateException(app.getString(R.string.stores_itch_no_exe)) + } + archive.delete() + StoreInstalls.deleteTree(scratch) + val sidecar = StoreGameSidecar( + store = Store.ITCH, + id = item.id, + title = item.title, + exe = exe, + installVersion = upload.optString("updated_at", upload.optString("updatedAt")), + installedAt = System.currentTimeMillis(), + cover = item.imageUrl, + hero = item.imageUrl, + extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId), + cloud = false, + ) + StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) } + return folder.path + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id))) + } + + private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long, + handle: DownloadQueue.JobHandle) { + // A partial file is only resumed when it came from this exact upload build. + val origin = File(target.path + ".from") + val stamp = "$uploadId $version $expected" + if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) { + target.delete() + origin.writeText(stamp) + } + if (expected > 0 && target.length() == expected) { + handle.progress(expected, expected) + return + } + for (attempt in 0..1) { + val have = target.length() + var conn: HttpURLConnection? = null + try { + conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 60_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", StoreNet.BROWSER_UA) + setRequestProperty("Authorization", "Bearer $key") + setRequestProperty("Accept", "*/*") + if (have > 0) setRequestProperty("Range", "bytes=$have-") + } + val code = conn.responseCode + if (code == 416 && attempt == 0) { + target.delete() + continue + } + if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code)) + val append = code == 206 && have > 0 + if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over") + var done = if (append) have else 0L + val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected + // The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput. + handle.progress(done, total) + val buffer = ByteArray(128 * 1024) + conn.inputStream.use { input -> + FileOutputStream(target, append).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + done += read + handle.progress(done, total) + } + } + } + if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code)) + return + } finally { + runCatching { conn?.disconnect() } + } + } + } + + private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? { + folder.mkdirs() + when (val kind = sniff(archive, filename)) { + "zip" -> unzip(archive, handle) + "7z" -> sevenZip(archive, handle) + "rar" -> unrar(archive, handle) + "exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" })) + "tar" -> untar(archive, null, handle) + "gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle) + else -> copy(archive, File(folder, filename.ifBlank { "game" })) + } + val hint = filename.substringBeforeLast('.') + val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title) + if (exe.isBlank()) { + handle.log("itch.io: no exe found in ${folder.name}") + return null + } + return exe + } + + private fun sniff(file: File, filename: String): String { + val extension = filename.substringAfterLast('.', "").lowercase() + if (extension == "exe") return "exe" + val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull() + ?: return extension + fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() } + return when { + at(0, 0x50, 0x4B) -> "zip" + at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z" + at(0, 0x52, 0x61, 0x72, 0x21) -> "rar" + at(0, 0x1F, 0x8B) -> "gz" + at(0, 0x42, 0x5A, 0x68) -> "bz2" + at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz" + at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar" + else -> extension + } + } + + private fun target(name: String): File? { + val file = File(folder, name.replace('\\', '/')) + val root = folder.canonicalPath + val path = file.canonicalPath + return if (path == root || path.startsWith(root + File.separator)) file else null + } + + // No JVM library reads RAR5; the runtime's bsdtar (libarchive) does. + private fun unrar(archive: File, handle: DownloadQueue.JobHandle) { + if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar")) + val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null + else listOf(folder.absolutePath) + fun bsdtar(args: List, onLine: (String) -> Unit) = + GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line -> + if (!line.startsWith("proot ")) onLine(line) + } + var entries = 0L + bsdtar(listOf("-tf", archive.absolutePath)) { entries++ } + var done = 0L + val tail = ArrayDeque() + val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line -> + if (line.startsWith("x ")) handle.stageProgress(++done, entries) else { + if (tail.size == 5) tail.removeFirst() + tail.addLast(line) + } + } + if (cancelled.get()) return + if (code != 0) { + handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}") + throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code)) + } + } + + private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) { + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + SevenZFile.builder().setFile(archive).get().use { seven -> + while (true) { + if (cancelled.get()) return + val entry = seven.nextEntry ?: break + val file = target(entry.name) + if (file == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + file.mkdirs() + continue + } + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return + val read = seven.read(buffer, 0, buffer.size) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + + private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? { + val total = archive.length() + var written = 0L + var entries = 0 + val buffer = ByteArray(64 * 1024) + val opened = runCatching { + FileInputStream(archive).use { raw -> + val stream = when (codec) { + "gz" -> GzipCompressorInputStream(BufferedInputStream(raw)) + "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw)) + "xz" -> XZCompressorInputStream(BufferedInputStream(raw)) + else -> BufferedInputStream(raw) + } + stream.use { input -> + TarArchiveInputStream(input).use { tar -> + while (true) { + if (cancelled.get()) return null + val entry = tar.nextEntry ?: break + entries++ + val file = target(entry.name) + if (file == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + file.mkdirs() + continue + } + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return null + val read = tar.read(buffer, 0, buffer.size) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + } + true + } + if (opened.isSuccess && entries > 0) return Unit + if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar") + return null + } + + private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) { + val name = archive.name.substringBeforeLast('.') + val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive") + file.parentFile?.mkdirs() + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + FileInputStream(archive).use { raw -> + val stream = when (codec) { + "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw)) + "xz" -> XZCompressorInputStream(BufferedInputStream(raw)) + else -> GzipCompressorInputStream(BufferedInputStream(raw)) + } + stream.use { input -> + FileOutputStream(file).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + + private fun copy(source: File, target: File) { + target.parentFile?.mkdirs() + FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } } + } + + // Streaming readers stop silently on zips whose data descriptors disagree with the local headers; + // the central directory is authoritative. + private fun unzip(archive: File, handle: DownloadQueue.JobHandle) { + val total = archive.length() + var written = 0L + val buffer = ByteArray(64 * 1024) + ZipFile.builder().setFile(archive).get().use { zip -> + for (entry in zip.entries) { + if (cancelled.get()) return + val target = target(entry.name) + if (target == null) { + handle.log("itch.io: skipped an entry outside the game folder") + continue + } + if (entry.isDirectory) { + target.mkdirs() + continue + } + target.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> + FileOutputStream(target).use { out -> + while (true) { + if (cancelled.get()) return + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + handle.stageProgress(written, total) + } + } + } + } + } + } + + private companion object +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt new file mode 100644 index 000000000..e4b7cba83 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt @@ -0,0 +1,162 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.cleanStoreText +import com.droiddeck.launcher.stores.mergeItems +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToInt + +internal object ItchLibrary { + private const val KEY_CACHE = "library_cache" + private const val KEY_AT = "library_synced_at" + private const val MAX_PAGES = 20 + private const val THROTTLE_MS = 6 * 60 * 60 * 1000L + + private val running = AtomicBoolean(false) + + fun cached(context: Context): List { + val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList() + return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList()) + } + + fun sync(context: Context, force: Boolean) { + val app = context.applicationContext + if (!running.compareAndSet(false, true)) return + try { + val key = ItchAuth.key(app) + val cache = cached(app) + val prefs = ItchPrefs.get(app) + if (key.isBlank()) { + StoresState.post { + StoresState.status.remove(Store.ITCH) + if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache + StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in) + } + return + } + val quiet = cache.isNotEmpty() && !force + if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) { + StoresState.post { StoresState.library[Store.ITCH] = cache } + return + } + if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) } + val items = fetch(key) + if (items == null) { + StoresState.post { + StoresState.status.remove(Store.ITCH) + if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache + StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed) + } + return + } + prefs.edit() + .putString(KEY_CACHE, ItchCatalog.array(items).toString()) + .putLong(KEY_AT, System.currentTimeMillis()) + .apply() + StoresState.post { + StoresState.status.remove(Store.ITCH) + StoresState.problems.remove(Store.ITCH) + StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items) + } + StoresState.logLine("itch.io: ${items.size} owned games") + } finally { + running.set(false) + } + } + + fun size(context: Context, item: CatalogItem): Long { + val key = ItchAuth.key(context.applicationContext) + if (key.isBlank()) return 0L + val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L } + val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L } + val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L + return ItchUploads.size(upload) + } + + private fun fetch(key: String): List? { + val out = ArrayList() + var page = 1 + var keys = 0 + while (page <= MAX_PAGES) { + val body = ItchApi.ownedKeys(key, page) ?: return null + val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out + if (list.length() == 0) break + out.addAll(items(list)) + keys += list.length() + page++ + } + val games = out.distinctBy { it.id } + StoresState.logLine("itch.io: $keys download keys, ${games.size} games") + return games + } + + internal fun items(keys: JSONArray): List { + val out = ArrayList(keys.length()) + for (i in 0 until keys.length()) { + val entry = keys.optJSONObject(i) ?: continue + val game = entry.optJSONObject("game") ?: continue + item(game, entry.optString("id"))?.let { out.add(it) } + } + return out + } + + private fun JSONObject.text(snake: String, camel: String): String = + optString(snake).ifBlank { optString(camel) } + + internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? { + val gameId = game.optLong("id") + if (gameId <= 0L) return null + val user = game.optJSONObject("user") + val url = game.optString("url").ifBlank { + val owner = user?.optString("username").orEmpty() + if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId") + } + val traits = game.optJSONArray("traits")?.let { arr -> + (0 until arr.length()).map { arr.optString(it) } + }.orEmpty() + val platforms = game.optJSONObject("platforms") + val sale = game.optJSONObject("sale") + val rate = sale?.optDouble("rate", 0.0) ?: 0.0 + val cents = game.optLong("min_price", game.optLong("minPrice")) + val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits)) + val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0 + val price = if (canBuy && cents > 0) money(cents) else "" + val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else "" + val tags = buildList { + if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows") + if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux") + if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS") + }.joinToString(", ") + val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") } + val tall = game.text("still_cover_url", "stillCoverUrl") + val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() } + return CatalogItem( + store = Store.ITCH, + id = ItchCatalog.slug(url).ifBlank { gameId.toString() }, + title = game.optString("title").ifBlank { "itch.io game $gameId" }, + imageUrl = cover.ifBlank { null }, + tallImageUrl = tall.ifBlank { null }, + tags = tags, + isFree = price.isEmpty(), + hasPrice = price.isNotEmpty(), + finalPrice = salePrice.ifEmpty { price }, + originalPrice = if (salePrice.isNotEmpty()) price else "", + discountPercent = if (salePrice.isNotEmpty()) discount else 0, + storeUrl = url, + developer = author, + publisher = author, + releaseDate = game.text("published_at", "publishedAt").take(10), + description = cleanStoreText(game.text("short_text", "shortText")), + owned = downloadKeyId.isNotBlank(), + extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()), + ) + } + + private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0') +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt new file mode 100644 index 000000000..43470e47a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt @@ -0,0 +1,11 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.SharedPreferences + +object ItchPrefs { + const val FILE = "stores.itch" + + @JvmStatic + fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt new file mode 100644 index 000000000..0fd783ed6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt @@ -0,0 +1,199 @@ +package com.droiddeck.launcher.stores.itch + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import android.util.Xml +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.StoreShelves +import com.droiddeck.launcher.stores.cleanStoreText +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale + +internal object ItchShelves { + private const val TAG = "ItchShelves" + private const val KEY = "shelves_cache" + private const val KEY_AT = "shelves_at" + private const val FRESH_MS = 3 * 60 * 60 * 1000L + private const val GAP_MS = 1_200L + private const val RETRY_MS = 20_000L + + private val FEEDS = listOf( + "https://itch.io/games/newest.xml", + "https://itch.io/games/on-sale.xml", + "https://itch.io/games/price-free.xml", + "https://itch.io/games/top-rated.xml", + ) + private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile") + + @Volatile + private var memory: StoreShelves? = null + + fun cached(context: Context): StoreShelves? { + memory?.let { return it } + val stored = stored(ItchPrefs.get(context.applicationContext)) + if (stored != null) memory = stored + return stored + } + + fun featured(context: Context, force: Boolean): StoreShelves? { + val prefs = ItchPrefs.get(context.applicationContext) + val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS + if (fresh && !force) { + cached(context)?.let { return it } + } + val fetched = fetch() + if (fetched != null) { + memory = fetched + prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply() + return fetched + } + return cached(context) + } + + fun search(context: Context, query: String): List { + val key = ItchAuth.key(context.applicationContext) + if (key.isBlank()) return emptyList() + val out = ArrayList() + for (page in 1..2) { + val games = ItchApi.search(key, query, page) ?: break + if (games.length() == 0) break + for (i in 0 until games.length()) { + val game = games.optJSONObject(i) ?: continue + ItchLibrary.item(game, "")?.let { out.add(it) } + } + if (games.length() < 30) break + Thread.sleep(GAP_MS) + } + return out.distinctBy { it.id } + } + + private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null)) + + private fun fetch(): StoreShelves? { + val lists = ArrayList>(FEEDS.size) + var answered = false + for ((index, url) in FEEDS.withIndex()) { + if (index > 0) Thread.sleep(GAP_MS) + var (code, body) = open(url) + if (code == 429) { + Log.w(TAG, "feed 429; backing off") + Thread.sleep(RETRY_MS) + val again = open(url) + code = again.first + body = again.second + if (code == 429) { + Log.w(TAG, "feed still 429; keeping the cached shelves") + lists.add(emptyList()) + break + } + } + if (code in 200..299 && body != null) { + answered = true + lists.add(parse(body)) + } else { + lists.add(emptyList()) + } + } + if (!answered) return null + while (lists.size < FEEDS.size) lists.add(emptyList()) + val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12) + val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art) + return shelves.takeUnless { it.isEmpty } + } + + private fun open(url: String): Pair { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 20_000 + readTimeout = 20_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", StoreNet.BROWSER_UA) + setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*") + } + val code = conn.responseCode + code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null + } catch (e: Exception) { + Log.w(TAG, "feed ${e.javaClass.simpleName}") + 0 to null + } finally { + runCatching { conn?.disconnect() } + } + } + + internal fun parse(xml: String): List { + val out = ArrayList() + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(StringReader(xml)) + var event = parser.eventType + var field: String? = null + var current: HashMap? = null + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + if (parser.name == "item") current = HashMap() + field = parser.name + } + XmlPullParser.TEXT -> { + val map = current + val name = field + if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text + } + XmlPullParser.END_TAG -> { + if (parser.name == "item") { + current?.let { item(it)?.let(out::add) } + current = null + } + field = null + } + } + event = parser.next() + } + return out + } + + internal fun item(m: Map): CatalogItem? { + val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() } + if (link.isBlank()) return null + val title = m["plainTitle"]?.trim().orEmpty() + .ifBlank { m["title"].orEmpty().substringBefore(" [").trim() } + .ifBlank { ItchCatalog.slug(link) } + val price = m["price"]?.trim().orEmpty() + val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true) + val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty()) + .map { it.groupValues[1].trim() } + .filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS } + .joinToString(", ") + return CatalogItem( + store = Store.ITCH, + id = ItchCatalog.slug(link), + title = title, + imageUrl = m["imageurl"]?.trim()?.ifBlank { null }, + tags = tags, + isFree = free, + hasPrice = !free, + finalPrice = if (free) "" else price, + originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(), + discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0, + storeUrl = link, + releaseDate = date(m["pubDate"]), + description = cleanStoreText(m["description"]), + ) + } + + private fun date(raw: String?): String { + val text = raw?.trim().orEmpty() + if (text.isBlank()) return "" + return runCatching { + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text) + }.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt new file mode 100644 index 000000000..773ad22c3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.stores.itch + +import org.json.JSONArray +import org.json.JSONObject + +internal object ItchUploads { + private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "") + private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android") + + fun windows(uploads: JSONArray?): JSONObject? { + if (uploads == null) return null + val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) } + .filter { it.optString("storage") != "external" && !it.optBoolean("demo") } + if (all.isEmpty()) return null + val games = all.filter { it.optString("type") in GAME_TYPES } + val preferred = games.ifEmpty { all } + val candidates = preferred.filter { windows(it) }.ifEmpty { preferred } + return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") })) + } + + fun windows(upload: JSONObject): Boolean { + val platforms = upload.optJSONObject("platforms") + if (platforms != null) { + if (platforms.optBoolean("windows")) return true + if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false + } + val traits = upload.optJSONArray("traits") ?: return true + val names = (0 until traits.length()).map { traits.optString(it) } + if (names.any { it == "p_windows" }) return true + return names.none { it in PLATFORM_TRAITS } + } + + fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes")) + + fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/') +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt new file mode 100644 index 000000000..cfeeeb8c4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.ui + +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.itch.ItchApi +import com.droiddeck.launcher.stores.itch.ItchAuth +import com.droiddeck.launcher.stores.itch.ItchLibrary + +private fun copiedKey(ctx: Context): String? { + val clipboard = ctx.getSystemService(ClipboardManager::class.java) + val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null + return ItchAuth.candidates(text).firstOrNull() +} + +@Composable +internal fun ItchKeyDialog(onDismiss: () -> Unit) { + val ctx = LocalContext.current + var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) } + var busy by remember { mutableStateOf(false) } + var rejected by remember { mutableStateOf(false) } + + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) { + copiedKey(ctx)?.let { key = it } + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + fun submit() { + val raw = key.trim() + if (raw.isEmpty() || busy) return + busy = true + rejected = false + Thread({ + val user = ItchAuth.verify(raw) + if (user == null) { + StoresState.post { busy = false; rejected = true } + return@Thread + } + ItchAuth.save(ctx, raw, user) + StoresState.post { busy = false; onDismiss() } + StoresState.refresh(ctx) + ItchLibrary.sync(ctx, true) + }, "itch-key").start() + } + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { Text(Store.ITCH.label) }, + text = { + Column { + AdbTextField( + value = key, + onValueChange = { key = it; rejected = false }, + label = stringResource(R.string.stores_itch_key), + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + focusOnShow = true, + onDone = { submit() }, + ) + Row(horizontalArrangement = Arrangement.Start) { + TextButton(onClick = { + ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE))) + }) { Text(stringResource(R.string.stores_itch_get_key)) } + } + if (rejected) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.stores_itch_key_rejected), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) { + Text(stringResource(R.string.game_env_save)) + } + }, + dismissButton = { + TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + }, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index ec12d19af..8a2222017 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -29,6 +29,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8)) Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD)) Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) + Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) @@ -40,6 +41,7 @@ internal fun sourceLabel(source: String): String = when (source) { Store.GOG.id -> Store.GOG.label Store.EPIC.id -> Store.EPIC.label Store.AMAZON.id -> Store.AMAZON.label + Store.ITCH.id -> Store.ITCH.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index e61130501..81a2fee3d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -182,10 +182,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) { private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) { val ctx = LocalContext.current ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) { - Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - SourceChip(item.store.id, small = true) - Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1) - Spacer(Modifier.weight(1f)) + StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D)) RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) } CancelAction { StoresState.clearUnfinished(ctx, item) } } @@ -207,14 +204,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx } } +@Composable +private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + SourceChip(store, small = true) + Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1) + } +} + @Composable private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) { val ctx = LocalContext.current ArtStrip(d.cover, d.name) { - Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - SourceChip(d.store.id, small = true) - Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1) - Spacer(Modifier.weight(1f)) + StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f)) if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) { RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index e9c0e39ac..883709667 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -576,13 +576,14 @@ private fun StoreChips( ) { onStore(store, rootBounds[store.id]) } } StoreChip( - label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, - modifier = Modifier.weight(1f).onGloballyPositioned { + label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, + modifier = Modifier.onGloballyPositioned { bounds[DOWNLOADS] = it.boundsInParent() StoresMotion.downloadsChip = it.boundsInRoot() }, lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, trail = if (active > 0) { { CountPill(active) } } else null, + description = stringResource(R.string.stores_tab_downloads), onFocused = onFocusChip, ) { onDownloads() } var cogAt by remember { mutableStateOf(null) } @@ -596,7 +597,7 @@ private fun StoreChips( @Composable private fun StoreChip( - label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit, + label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit, trail: (@Composable () -> Unit)? = null, description: String? = null, onFocused: (String?) -> Unit = {}, onClick: () -> Unit, ) { @@ -634,7 +635,7 @@ private fun StoreChip( androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) { Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() } } - Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) trail?.invoke() } } @@ -724,11 +725,16 @@ private fun SignInButton(store: Store, large: Boolean = false) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val placed = remember { arrayOfNulls(1) } + var askKey by remember { mutableStateOf(false) } + if (askKey) ItchKeyDialog { askKey = false } Box(Modifier.onGloballyPositioned { placed[0] = it }) { PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) { - val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() - if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store) - else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot) + if (store == Store.ITCH) askKey = true + else { + val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot() + if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store) + else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot) + } } } } @@ -752,13 +758,16 @@ private fun Storefront( val colors = MaterialTheme.colorScheme val library = StoresState.library[store] ?: emptyList() val shelves = StoresState.shelves[store] + val found = StoresState.found[store] ?: emptyList() val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) } val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } } val q = query.trim().lowercase() - fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) + val foundKeys = remember(found) { found.map { it.key }.toSet() } + fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned fun shelf(l: List?) = l?.filter(::shown) - val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } } + val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } + LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } val installedOnly = tab == "installed" val loaded = StoresState.library.containsKey(store) val showGrid = tab != "store" || q.isNotEmpty() @@ -998,6 +1007,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette { Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1) Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD) Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800) + Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) null -> return base } val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fcc1b2258..90a3fc96e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2045,6 +2045,16 @@ شراء · %1$s مثبَّتة في المكتبة + مفتاح API + الحصول على مفتاح + تم رفض المفتاح + ليس في مكتبة itch.io الخاصة بك + لا يوجد تنزيل لنظام Windows لهذه اللعبة + لم يتم العثور على لعبة Windows في التنزيل + تعذر استخراج التنزيل (رمز الخروج %1$d) + لم يستجب itch.io + أرشيفات %1$s غير مدعومة + استجاب itch.io بـ HTTP %1$d مجاني استئناف تحديث diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 877992f59..31e1da902 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2669,6 +2669,16 @@ Convirtiendo %1$s para Steam. Mantén DroidDeck abierto. Este dispositivo no puede decodificar ese video. No se pudo importar ese video + Clave de API + Obtener una clave + Clave rechazada + No está en tu biblioteca de itch.io + Este juego no tiene descarga para Windows + No se encontró un juego de Windows en la descarga + No se pudo extraer la descarga (salida %1$d) + itch.io no respondió + Los archivos %1$s no son compatibles + itch.io respondió HTTP %1$d Cámaras Luma Ultra — Experimental Seguimiento de posición de los Joy-Con Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento. diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 4e14e9940..bf5096cc1 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1961,6 +1961,16 @@ Купи · %1$s Инсталирана В библиотеката + API ключ + Вземи ключ + Ключът е отказан + Не е в библиотеката ви в itch.io + Няма изтегляне за Windows за тази игра + В изтеглянето не е намерена игра за Windows + Изтеглянето не можа да бъде разархивирано (код %1$d) + itch.io не отговори + Архивите %1$s не се поддържат + itch.io отговори HTTP %1$d Безплатна Продължи Обнови diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 366be9edc..086a4dc05 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2003,6 +2003,16 @@ Koupit · %1$s Nainstalováno V knihovně + Klíč API + Získat klíč + Klíč odmítnut + Není ve vaší knihovně itch.io + Pro tuto hru není ke stažení verze pro Windows + Ve stahování nebyla nalezena hra pro Windows + Stažený soubor nelze rozbalit (kód %1$d) + itch.io neodpovědělo + Archivy %1$s nejsou podporovány + itch.io odpovědělo HTTP %1$d Zdarma Pokračovat Obnovit diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 46d435398..e73f2a773 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1961,6 +1961,16 @@ Køb · %1$s Installeret I biblioteket + API-nøgle + Hent en nøgle + Nøgle afvist + Ikke i dit itch.io-bibliotek + Ingen Windows-download til dette spil + Der blev ikke fundet et Windows-spil i downloaden + Downloaden kunne ikke udpakkes (kode %1$d) + itch.io svarede ikke + %1$s-arkiver understøttes ikke + itch.io svarede HTTP %1$d Gratis Genoptag Opdater diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 66b18fde8..5c2f7286c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1950,6 +1950,16 @@ Kaufen · %1$s Installiert In der Bibliothek + API-Schlüssel + Schlüssel holen + Schlüssel abgelehnt + Nicht in deiner itch.io-Bibliothek + Kein Windows-Download für dieses Spiel + Im Download wurde kein Windows-Spiel gefunden + Download konnte nicht entpackt werden (Code %1$d) + itch.io hat nicht geantwortet + %1$s-Archive werden nicht unterstützt + itch.io antwortete mit HTTP %1$d Kostenlos Fortsetzen Aktualisieren diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 786b4ddb5..aaa0aa8af 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1961,6 +1961,16 @@ Αγορά · %1$s Εγκατεστημένο Στη βιβλιοθήκη + Κλειδί API + Λήψη κλειδιού + Το κλειδί απορρίφθηκε + Δεν είναι στη βιβλιοθήκη σας στο itch.io + Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι + Δεν βρέθηκε παιχνίδι Windows στη λήψη + Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d) + Το itch.io δεν απάντησε + Τα αρχεία %1$s δεν υποστηρίζονται + Το itch.io απάντησε HTTP %1$d Δωρεάν Συνέχιση Ανανέωση diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7db317747..073d3971f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1979,6 +1979,16 @@ Comprar · %1$s Instalado En la biblioteca + Clave de API + Obtener una clave + Clave rechazada + No está en tu biblioteca de itch.io + No hay descarga para Windows de este juego + No se ha encontrado un juego de Windows en la descarga + No se ha podido extraer la descarga (código %1$d) + itch.io no respondió + Los archivos %1$s no son compatibles + itch.io respondió HTTP %1$d Gratis Reanudar Actualizar diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index de265dcab..61a3d124a 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1961,6 +1961,16 @@ Osta · %1$s Asennettu Kirjastossa + API-avain + Hae avain + Avain hylätty + Ei itch.io-kirjastossasi + Ei Windows-latausta tälle pelille + Latauksesta ei löytynyt Windows-peliä + Latausta ei voitu purkaa (koodi %1$d) + itch.io ei vastannut + %1$s-arkistot eivät ole tuettuja + itch.io vastasi HTTP %1$d Ilmainen Jatka Päivitä diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1d09298d5..952fa65cb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1979,6 +1979,16 @@ Acheter · %1$s Installé Dans la bibliothèque + Clé d\'API + Obtenir une clé + Clé refusée + Absent de votre bibliothèque itch.io + Pas de téléchargement Windows pour ce jeu + Aucun jeu Windows trouvé dans le téléchargement + Impossible d\'extraire le téléchargement (code %1$d) + itch.io n\'a pas répondu + Les archives %1$s ne sont pas prises en charge + itch.io a répondu HTTP %1$d Gratuit Reprendre Actualiser diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 90c47ff45..691f28d59 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1961,6 +1961,16 @@ खरीदें · %1$s इंस्टॉल है लाइब्रेरी में + API कुंजी + कुंजी पाएं + कुंजी अस्वीकृत + आपकी itch.io लाइब्रेरी में नहीं + इस गेम के लिए Windows डाउनलोड नहीं है + डाउनलोड में कोई Windows गेम नहीं मिला + डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d) + itch.io ने जवाब नहीं दिया + %1$s आर्काइव समर्थित नहीं हैं + itch.io ने HTTP %1$d लौटाया फ़्री फिर से शुरू करें रीफ़्रेश करें diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 96cb5ed36..16a054ba4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1961,6 +1961,16 @@ Megvétel · %1$s Telepítve A könyvtárban + API-kulcs + Kulcs beszerzése + A kulcs elutasítva + Nincs az itch.io-könyvtáradban + Ehhez a játékhoz nincs Windows-letöltés + A letöltésben nem található Windows-játék + A letöltés nem bontható ki (kód: %1$d) + Az itch.io nem válaszolt + A(z) %1$s archívumok nem támogatottak + Az itch.io HTTP %1$d választ adott Ingyenes Folytatás Frissítés diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9ab984dce..0126a527a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1940,6 +1940,16 @@ Beli · %1$s Terinstal Dalam pustaka + Kunci API + Dapatkan kunci + Kunci ditolak + Tidak ada di pustaka itch.io Anda + Tidak ada unduhan Windows untuk game ini + Tidak ada game Windows di unduhan + Tidak dapat mengekstrak unduhan (kode %1$d) + itch.io tidak menjawab + Arsip %1$s tidak didukung + itch.io menjawab HTTP %1$d Gratis Lanjutkan Segarkan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cd769f615..a9d7cb606 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1971,6 +1971,16 @@ Acquista · %1$s Installato In libreria + Chiave API + Ottieni una chiave + Chiave rifiutata + Non è nella tua libreria itch.io + Nessun download per Windows di questo gioco + Nessun gioco Windows trovato nel download + Impossibile estrarre il download (codice %1$d) + itch.io non ha risposto + Gli archivi %1$s non sono supportati + itch.io ha risposto HTTP %1$d Gratis Riprendi Aggiorna diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b350ede00..f7899fea4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1940,6 +1940,16 @@ 購入 · %1$s インストール済み ライブラリにあり + APIキー + キーを取得 + キーが拒否されました + itch.io ライブラリにありません + このゲームには Windows 版のダウンロードがありません + ダウンロードに Windows 版のゲームが見つかりません + ダウンロードを展開できませんでした (終了コード %1$d) + itch.io から応答がありません + %1$s 形式のアーカイブには対応していません + itch.io が HTTP %1$d を返しました 無料 再開 更新 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f076dc4fb..db3418dc1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1940,6 +1940,16 @@ 구매 · %1$s 설치됨 라이브러리에 있음 + API 키 + 키 받기 + 키가 거부되었습니다 + itch.io 라이브러리에 없습니다 + 이 게임의 Windows 다운로드가 없습니다 + 다운로드에서 Windows 게임을 찾을 수 없습니다 + 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d) + itch.io가 응답하지 않았습니다 + %1$s 압축 파일은 지원하지 않습니다 + itch.io가 HTTP %1$d을(를) 반환했습니다 무료 재개 새로 고침 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 344e02520..4cec0534e 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1940,6 +1940,16 @@ Beli · %1$s Dipasang Dalam pustaka + Kunci API + Dapatkan kunci + Kunci ditolak + Tiada dalam pustaka itch.io anda + Tiada muat turun Windows untuk permainan ini + Tiada permainan Windows ditemui dalam muat turun + Tidak dapat mengekstrak muat turun (kod %1$d) + itch.io tidak menjawab + Arkib %1$s tidak disokong + itch.io menjawab HTTP %1$d Percuma Sambung Segar semula diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 3b316f00e..058a20cf8 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1961,6 +1961,16 @@ Kjøp · %1$s Installert I biblioteket + API-nøkkel + Hent en nøkkel + Nøkkelen ble avvist + Ikke i itch.io-biblioteket ditt + Ingen Windows-nedlasting for dette spillet + Fant ikke noe Windows-spill i nedlastingen + Kunne ikke pakke ut nedlastingen (kode %1$d) + itch.io svarte ikke + %1$s-arkiver støttes ikke + itch.io svarte HTTP %1$d Gratis Gjenoppta Oppdater diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ec1b209ab..cb5127130 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1961,6 +1961,16 @@ Kopen · %1$s Geïnstalleerd In bibliotheek + API-sleutel + Sleutel ophalen + Sleutel geweigerd + Niet in je itch.io-bibliotheek + Geen Windows-download voor dit spel + Geen Windows-game gevonden in de download + Download kan niet worden uitgepakt (code %1$d) + itch.io gaf geen antwoord + %1$s-archieven worden niet ondersteund + itch.io antwoordde HTTP %1$d Gratis Hervatten Vernieuwen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 35a3afaef..bb7e40dcf 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2003,6 +2003,16 @@ Kup · %1$s Zainstalowano W bibliotece + Klucz API + Pobierz klucz + Klucz odrzucony + Nie ma go w Twojej bibliotece itch.io + Brak wersji na Windows dla tej gry + W pobranych plikach nie znaleziono gry na Windows + Nie można rozpakować pobranego pliku (kod %1$d) + itch.io nie odpowiedziało + Archiwa %1$s nie są obsługiwane + itch.io odpowiedziało HTTP %1$d Bezpłatna Wznów Odśwież diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 614ece612..931f557bd 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1971,6 +1971,16 @@ Comprar · %1$s Instalado Na biblioteca + Chave de API + Obter uma chave + Chave rejeitada + Não está na sua biblioteca do itch.io + Sem transferência para Windows deste jogo + Não foi encontrado nenhum jogo para Windows na transferência + Não foi possível extrair a transferência (código %1$d) + O itch.io não respondeu + Ficheiros %1$s não são suportados + O itch.io respondeu HTTP %1$d Grátis Retomar Atualizar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b5c4889e3..bfc2b4120 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1971,6 +1971,16 @@ Comprar · %1$s Instalado Na biblioteca + Chave de API + Obter uma chave + Chave rejeitada + Não está na sua biblioteca do itch.io + Sem download para Windows deste jogo + Nenhum jogo para Windows encontrado no download + Não foi possível extrair o download (código %1$d) + O itch.io não respondeu + Arquivos %1$s não são suportados + O itch.io respondeu HTTP %1$d Grátis Retomar Atualizar diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 840d1dfb3..01f2f37f6 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1982,6 +1982,16 @@ Cumpără · %1$s Instalat În bibliotecă + Cheie API + Obține o cheie + Cheie respinsă + Nu este în biblioteca ta itch.io + Nu există descărcare pentru Windows la acest joc + Nu s-a găsit niciun joc Windows în descărcare + Descărcarea nu a putut fi extrasă (cod %1$d) + itch.io nu a răspuns + Arhivele %1$s nu sunt acceptate + itch.io a răspuns HTTP %1$d Gratuit Reluare Reîmprospătează diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f939fd97f..bab12fb06 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2000,6 +2000,16 @@ Купить · %1$s Установлено В библиотеке + Ключ API + Получить ключ + Ключ отклонён + Нет в вашей библиотеке itch.io + Для этой игры нет загрузки для Windows + В загрузке не найдена игра для Windows + Не удалось распаковать загрузку (код %1$d) + itch.io не ответил + Архивы %1$s не поддерживаются + itch.io ответил HTTP %1$d Бесплатно Продолжить Обновить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 58279d36b..33a2024b3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1961,6 +1961,16 @@ Köp · %1$s Installerat I biblioteket + API-nyckel + Hämta en nyckel + Nyckeln avvisades + Inte i ditt itch.io-bibliotek + Ingen Windows-nedladdning för det här spelet + Inget Windows-spel hittades i nedladdningen + Det gick inte att packa upp nedladdningen (kod %1$d) + itch.io svarade inte + %1$s-arkiv stöds inte + itch.io svarade HTTP %1$d Gratis Återuppta Uppdatera diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f13ce19aa..2d03c33e4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1940,6 +1940,16 @@ ซื้อ · %1$s ติดตั้งแล้ว อยู่ในไลบรารี + คีย์ API + รับคีย์ + คีย์ถูกปฏิเสธ + ไม่มีในคลัง itch.io ของคุณ + เกมนี้ไม่มีการดาวน์โหลดสำหรับ Windows + ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด + แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d) + itch.io ไม่ตอบสนอง + ไม่รองรับไฟล์ %1$s + itch.io ตอบกลับ HTTP %1$d ฟรี ดำเนินการต่อ รีเฟรช diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7a7e40f23..71906dca8 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1950,6 +1950,16 @@ Satın al · %1$s Yüklü Kütüphanede + API anahtarı + Anahtar al + Anahtar reddedildi + itch.io kütüphanenizde yok + Bu oyunun Windows indirmesi yok + İndirmede Windows oyunu bulunamadı + İndirme ayıklanamadı (kod %1$d) + itch.io yanıt vermedi + %1$s arşivleri desteklenmiyor + itch.io HTTP %1$d yanıtı verdi Ücretsiz Sürdür Yenile diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6a19b33c9..4a72e62c7 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2003,6 +2003,16 @@ Купити · %1$s Встановлено У бібліотеці + Ключ API + Отримати ключ + Ключ відхилено + Немає у вашій бібліотеці itch.io + Для цієї гри немає завантаження для Windows + У завантаженні не знайдено гри для Windows + Не вдалося розпакувати завантаження (код %1$d) + itch.io не відповів + Архіви %1$s не підтримуються + itch.io відповів HTTP %1$d Безкоштовно Продовжити Оновити diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index e1f58f9e8..eb7f8f4f2 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1940,6 +1940,16 @@ Mua · %1$s Đã cài Trong thư viện + Khóa API + Lấy khóa + Khóa bị từ chối + Không có trong thư viện itch.io của bạn + Không có bản tải Windows cho game này + Không tìm thấy trò chơi Windows trong bản tải xuống + Không thể giải nén bản tải xuống (mã %1$d) + itch.io không phản hồi + Không hỗ trợ tệp %1$s + itch.io trả về HTTP %1$d Miễn phí Tiếp tục Làm mới diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index caf528050..0c9146a79 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2016,6 +2016,16 @@ 购买 · %1$s 已安装 已在库中 + API 密钥 + 获取密钥 + 密钥被拒绝 + 不在你的 itch.io 库中 + 这个游戏没有 Windows 下载 + 下载内容中未找到 Windows 游戏 + 无法解压下载内容(代码 %1$d) + itch.io 没有响应 + 不支持 %1$s 压缩包 + itch.io 返回 HTTP %1$d 免费 继续 刷新 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 252446f85..422d8ee8b 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1940,6 +1940,16 @@ 購買 · %1$s 已安裝 已在收藏庫中 + API 金鑰 + 取得金鑰 + 金鑰被拒絕 + 不在你的 itch.io 收藏庫中 + 這個遊戲沒有 Windows 下載 + 下載內容中找不到 Windows 遊戲 + 無法解壓下載內容(代碼 %1$d) + itch.io 沒有回應 + 不支援 %1$s 壓縮檔 + itch.io 回應 HTTP %1$d 免費 繼續 重新整理 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 10865ff6d..59d65bad8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1940,6 +1940,16 @@ 購買 · %1$s 已安裝 已在收藏庫中 + API 金鑰 + 取得金鑰 + 金鑰被拒絕 + 不在你的 itch.io 收藏庫中 + 這個遊戲沒有 Windows 下載 + 下載內容中找不到 Windows 遊戲 + 無法解壓縮下載內容(代碼 %1$d) + itch.io 沒有回應 + 不支援 %1$s 壓縮檔 + itch.io 回應 HTTP %1$d 免費 繼續 重新整理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 424d8e35b..c1baba801 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2004,6 +2004,16 @@ Buy · %1$s Installed In library + API key + Get a key + Key rejected + Not in your itch.io library + No Windows download for this game + No Windows game found in the download + Could not extract the download (exit %1$d) + itch.io did not answer + %1$s archives are not supported + itch.io answered HTTP %1$d Free Resume Refresh From 5970de928e5387f8aa76b161361cb09f6d22e0ed Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:06:38 -0400 Subject: [PATCH 014/156] feat: library and store tabs per store with installed games listed first (#1116) --- .../launcher/ui/StoreGridNavigation.kt | 17 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 188 ++++++++---------- app/src/main/res/values-ar/strings.xml | 5 +- app/src/main/res/values-b+es+419/strings.xml | 5 +- app/src/main/res/values-bg/strings.xml | 5 +- app/src/main/res/values-cs/strings.xml | 5 +- app/src/main/res/values-da/strings.xml | 5 +- app/src/main/res/values-de/strings.xml | 5 +- app/src/main/res/values-el/strings.xml | 5 +- app/src/main/res/values-es/strings.xml | 5 +- app/src/main/res/values-fi/strings.xml | 5 +- app/src/main/res/values-fr/strings.xml | 5 +- app/src/main/res/values-hi/strings.xml | 5 +- app/src/main/res/values-hu/strings.xml | 5 +- app/src/main/res/values-in/strings.xml | 5 +- app/src/main/res/values-it/strings.xml | 5 +- app/src/main/res/values-ja/strings.xml | 5 +- app/src/main/res/values-ko/strings.xml | 5 +- app/src/main/res/values-ms/strings.xml | 5 +- app/src/main/res/values-nb/strings.xml | 5 +- app/src/main/res/values-nl/strings.xml | 5 +- app/src/main/res/values-pl/strings.xml | 5 +- app/src/main/res/values-pt-rPT/strings.xml | 5 +- app/src/main/res/values-pt/strings.xml | 5 +- app/src/main/res/values-ro/strings.xml | 5 +- app/src/main/res/values-ru/strings.xml | 5 +- app/src/main/res/values-sv/strings.xml | 5 +- app/src/main/res/values-th/strings.xml | 5 +- app/src/main/res/values-tr/strings.xml | 5 +- app/src/main/res/values-uk/strings.xml | 5 +- app/src/main/res/values-vi/strings.xml | 5 +- app/src/main/res/values-zh-rCN/strings.xml | 5 +- app/src/main/res/values-zh-rHK/strings.xml | 5 +- app/src/main/res/values-zh-rTW/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- .../launcher/ui/StoreGridNavigationTest.kt | 25 +++ .../droiddeck/launcher/ui/StoreTabsTest.kt | 9 +- 37 files changed, 199 insertions(+), 205 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt index d019bc009..3996d3042 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt @@ -1,6 +1,19 @@ package com.droiddeck.launcher.ui -internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? { +// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid. +internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? { if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null - return (index + columns * direction).takeIf { it in 0 until count } + if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count } + val inFirst = index < split + val start = if (inFirst) 0 else split + val end = if (inFirst) split else count + val local = index - start + val within = start + local + columns * direction + if (within in start until end) return within + val column = local % columns + return when { + inFirst && direction > 0 -> split + minOf(column, count - split - 1) + !inFirst && direction < 0 && local < columns -> ((split - 1) / columns) * columns + column.coerceAtMost((split - 1) % columns) + else -> null + } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 883709667..2feda7597 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -148,9 +148,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine private const val DOWNLOADS = "downloads" -private val TABS = listOf("store", "library", "installed") +private val TABS = listOf("library", "store") -internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.drop(1) else TABS +internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.take(1) else TABS internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library" @@ -214,41 +214,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } } val pal = LocalPalette.current - fun openModes(st: Store, at: Rect?) { - if (chip != st.id) { pickChip(st.id); return } - if (at == null || !StoresState.isSignedIn(st) || StoresState.expired[st] == true) { move("first"); return } - val modes = tabsFor(st) - val current = if (chip == st.id) shownTab else null - var picked = false - StoresMotion.ask(StepAsk( - anchor = at, side = StepSide.Right, accent = storePalette(pal, st).signal, items = modes.size, - onDismiss = { move(if (picked) "first" else "item:storechip:${st.id}") }, - handle = { ChipLabel(st, MaterialTheme.colorScheme.onBackground) }, - ) { - StoreTheme(st) { - val library = StoresState.library[st].orEmpty() - val installedCount = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(st, StoresState.installed, library).size } - StepList { - modes.forEachIndexed { i, m -> - val label = when (m) { - "store" -> stringResource(R.string.stores_tab_store) - "installed" -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedCount) - else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) - } - val focusHere = if (current != null) m == current else i == 0 - StepChoice( - label, selected = m == current, enabled = open, - modifier = Modifier.fillMaxWidth().stepItem(i).then(if (focusHere) Modifier.focusRequester(first) else Modifier), - ) { - picked = true - if (chip != st.id) { chip = st.id; openGame = null; query = "" } - tab = m - StoresMotion.fold() - } - } - } - } - }) + fun cycleStore(step: Int) { + val ids = Store.entries.map { it.id } + val at = ids.indexOf(chip) + pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size]) + } + fun openModes(st: Store) { + if (chip != st.id) pickChip(st.id) else move("first") } BackHandler(enabled = openGame != null) { closeGame() } val scroll = rememberScrollState() @@ -326,13 +298,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier Column( modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) .bumpers( - onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } }, - onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } }, + onPrevious = { if (openGame == null) cycleStore(-1) }, + onNext = { if (openGame == null) cycleStore(1) }, ), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { - Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, + Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) } } } @@ -390,7 +362,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } st == null -> {} !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } - else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }) + else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) }) } } } @@ -752,7 +724,7 @@ internal fun StoreLogo(store: Store, size: Dp) { private fun Storefront( store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions, grid: LazyGridState, - onQuery: (String) -> Unit, onOpen: (String) -> Unit, + onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit, ) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme @@ -768,7 +740,6 @@ private fun Storefront( fun shelf(l: List?) = l?.filter(::shown) val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } - val installedOnly = tab == "installed" val loaded = StoresState.library.containsKey(store) val showGrid = tab != "store" || q.isNotEmpty() val sort = s.storesSort @@ -782,63 +753,45 @@ private fun Storefront( val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L } lookup } - val isSizeSort = (tab == "library" || tab == "installed") && (sort == "sizeAsc" || sort == "sizeDesc") - val candidates = remember(library, installedItems, everything, tab, q, s.storesShowMature) { - when (tab) { - "library" -> library.filter(::matches) - "installed" -> installedItems.filter(::matches) - else -> everything.filter { matches(it) && shown(it) } - } + val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc") + val candidates = remember(library, everything, tab, q, s.storesShowMature) { + if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) } } val candidateKeys = remember(candidates) { candidates.map { it.key } } var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) } val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys var stableVisible by remember(store, tab) { mutableStateOf(emptyList()) } - val resolvedSizes = when { - !isSizeSort -> emptyMap() - installedOnly -> com.droiddeck.launcher.stores.StoreSizes.onDisk.toMap() - else -> com.droiddeck.launcher.stores.StoreSizes.known.toMap() - } - val sizeFor = remember(resolvedSizes, installedOnly) { + val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap() + val sizeFor = remember(resolvedSizes) { val resolved = resolvedSizes - val lookup: (CatalogItem) -> Long = { item -> - resolved[item.key] ?: if (installedOnly) 0L else item.sizeBytes - } + val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes } lookup } - val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor) { - if (tab == "library" || tab == "installed") - sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor) + val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) { + if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys) else candidates } val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) { if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible } + val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0 + val split = installedShown.takeIf { it in 1 until visible.size } ?: 0 SideEffect { if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible } LaunchedEffect(store, tab, sort, candidates, candidateKeys) { if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect val requests = candidates.map { item -> - val future: CompletableFuture = if (tab == "installed") { - StoresState.installedGame(store, item.id)?.let { - com.droiddeck.launcher.stores.StoreSizes.resolveDisk(ctx, item.key, it) - } ?: CompletableFuture.completedFuture(null) - } else { - com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) - } - item to future + item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item) } CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await() requests.forEach { (item, future) -> val bytes = future.getNow(null) ?: return@forEach - if (tab == "installed") com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] = bytes - else if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes + if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes } resolvedSizeSortKey = candidateKeys } val empty = stringResource(when { - installedOnly -> R.string.stores_installed_empty tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading else -> R.string.stores_nothing_matches }) @@ -849,7 +802,7 @@ private fun Storefront( val scope = rememberCoroutineScope() val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) - val listMode = s.storesView == "list" && (tab == "library" || tab == "installed") + val listMode = s.storesView == "list" && tab == "library" val card: @Composable (CatalogItem) -> Unit = { item -> val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true @@ -861,14 +814,14 @@ private fun Storefront( else -> 0 } val index = visible.indexOfFirst { it.key == item.key } - val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction) + val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split) when { direction == 0 || index < 0 -> false next == null -> direction > 0 else -> { if (key.action == KeyEvent.ACTION_DOWN) { val target = "card:${visible[next].key}" - val targetIndex = headerItems + next + val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2 val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex } if (!visibleTarget || (focus.attached[target] ?: 0) == 0 || runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch { @@ -894,21 +847,19 @@ private fun Storefront( ) { item(key = "header", span = { GridItemSpan(maxLineSpan) }) { Rise(1) { + val tabs = tabsFor(store) + val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) + else stringResource(R.string.stores_tab_library) ModeHeader( - when { - tab == "store" -> stringResource(R.string.stores_tab_store) - !loaded -> stringResource(if (installedOnly) R.string.stores_tab_installed else R.string.stores_tab_library) - installedOnly -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size) - else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size) - }, + libraryLabel, query, onQuery, - placeholder = when { - tab == "store" -> stringResource(R.string.stores_search_catalog, store.label) - installedOnly -> stringResource(R.string.stores_search_installed) - else -> stringResource(R.string.stores_search_library) - }, + placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library), + lead = if (tabs.size < 2) null else { { + val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel } + HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) } + } }, ) { - if (tab == "library" || tab == "installed") { + if (tab == "library") { SortButton(s, a) ViewButton(s, a) } @@ -934,8 +885,14 @@ private fun Storefront( StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } } StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } } when (tab) { - "library", "installed" -> if (!loaded) Unit - else storeGrid(visible, empty, card) + "library" -> if (!loaded) Unit + else if (split == 0) storeGrid(visible, empty, card) + else { + item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) } + storeGrid(visible.take(split), empty, card) + item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) } + storeGrid(visible.drop(split), empty, card) + } else -> { if (q.isNotEmpty()) storeGrid(visible, empty, card) else { @@ -965,14 +922,48 @@ private suspend fun CompletableFuture.await(): T = } @Composable -internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { +private fun SectionLabel(title: String) { + Text( + title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp), + ) +} + +@Composable +private fun HeaderTabs(labels: List, selected: Int, onSelect: (Int) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + labels.forEachIndexed { i, label -> + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pick = { onSelect(i) } + Text( + label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick) + .controllerConfirm(onClick = pick) + .padding(horizontal = 4.dp, vertical = 6.dp), + ) + } + } +} + +@Composable +internal fun ModeHeader( + title: String, query: String, onQuery: (String) -> Unit, placeholder: String, + lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current var searching by remember { mutableStateOf(query.isNotEmpty()) } val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen") Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) { - Text( + if (lead != null) lead() + else Text( title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, modifier = Modifier.padding(start = 4.dp, end = 4.dp), ) @@ -994,14 +985,6 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, } } -@Composable -private fun ChipLabel(store: Store, ink: Color) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(Modifier.size(12.dp).clip(CircleShape).background(sourceColours(store.id).dot)) - Text(store.shortLabel, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1) - } -} - internal fun storePalette(base: Palette, store: Store?): Palette { val (accent, deep) = when (store) { Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1) @@ -1228,6 +1211,11 @@ private fun Shelf(title: String, items: List?, card: @Composable (C } } +internal fun installedFirst(items: List, installed: Set): List { + val (on, off) = items.partition { it.id in installed } + return on + off +} + private fun LazyGridScope.storeGrid(games: List, empty: String, card: @Composable (CatalogItem) -> Unit) { if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 90a3fc96e..36caa8aee 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2010,7 +2010,6 @@ الإعدادات (لم يتم تسجيل الدخول) المتجر - المثبَّتة المكتبة الكل التنزيلات @@ -2022,10 +2021,8 @@ البقاء مسجَّل الدخول تسجيل الخروج البحث في كتالوج %1$s… - البحث في الألعاب المثبَّتة… البحث في مكتبتك… البحث - لا شيء مثبَّت جارٍ التحميل لا توجد ألعاب جارٍ تحميل المتجر… @@ -2044,6 +2041,8 @@ عرض على %1$s شراء · %1$s مثبَّتة + مثبَّتة + غير مثبَّت في المكتبة مفتاح API الحصول على مفتاح diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 31e1da902..da88522af 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1977,7 +1977,6 @@ Ajustes (sin iniciar sesión) Tienda - Instalados Biblioteca Todos Descargas @@ -1989,10 +1988,8 @@ Mantener la sesión Cerrar sesión Buscar en el catálogo de %1$s… - Buscar juegos instalados… Buscar en tu biblioteca… Buscar - Nada instalado Cargando No hay juegos Cargando la tienda… @@ -2011,6 +2008,8 @@ Ver en %1$s Comprar · %1$s Instalado + Instalado + No instalada En la biblioteca Gratis Reanudar diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index bf5096cc1..a76a3cd91 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1926,7 +1926,6 @@ Настройки (не е вписан) Магазин - Инсталирани Библиотека Всички Изтегляния @@ -1938,10 +1937,8 @@ Остани вписан Изход Търсене в каталога на %1$s… - Търсене в инсталираните игри… Търсене в твоята библиотека… Търсене - Нищо инсталирано Зареждане Няма игри Зареждане на магазина… @@ -1960,6 +1957,8 @@ Виж в %1$s Купи · %1$s Инсталирана + Инсталирана + Не е инсталирано В библиотеката API ключ Вземи ключ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 086a4dc05..5ab150110 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1968,7 +1968,6 @@ Nastavení (nepřihlášen) Obchod - Nainstalované Knihovna Vše Stahování @@ -1980,10 +1979,8 @@ Zůstat přihlášen Odhlásit se Hledat v katalogu %1$s… - Hledat v nainstalovaných hrách… Hledat ve vaší knihovně… Hledat - Nic není nainstalováno Načítání Žádné hry Načítání obchodu… @@ -2002,6 +1999,8 @@ Zobrazit na %1$s Koupit · %1$s Nainstalováno + Nainstalováno + Není nainstalováno V knihovně Klíč API Získat klíč diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e73f2a773..77f4eb7a0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1926,7 +1926,6 @@ Indstillinger (ikke logget ind) Butik - Installeret Bibliotek Alle Downloads @@ -1938,10 +1937,8 @@ Bliv logget ind Log ud Søg i %1$s-kataloget… - Søg i installerede spil… Søg i dit bibliotek… Søg - Intet installeret Indlæser Ingen spil Indlæser butikken… @@ -1960,6 +1957,8 @@ Se på %1$s Køb · %1$s Installeret + Installeret + Ikke installeret I biblioteket API-nøgle Hent en nøgle diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5c2f7286c..9a41a6627 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1915,7 +1915,6 @@ Einstellungen (nicht angemeldet) Store - Installiert Bibliothek Alle Downloads @@ -1927,10 +1926,8 @@ Angemeldet bleiben Abmelden Katalog von %1$s durchsuchen… - Installierte Spiele durchsuchen… Deine Bibliothek durchsuchen… Suchen - Nichts installiert Wird geladen Keine Spiele Store wird geladen… @@ -1949,6 +1946,8 @@ Auf %1$s ansehen Kaufen · %1$s Installiert + Installiert + Nicht installiert In der Bibliothek API-Schlüssel Schlüssel holen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index aaa0aa8af..430fb46b0 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1926,7 +1926,6 @@ Ρυθμίσεις (μη συνδεδεμένος) Κατάστημα - Εγκατεστημένα Βιβλιοθήκη Όλα Λήψεις @@ -1938,10 +1937,8 @@ Παραμονή συνδεδεμένου Αποσύνδεση Αναζήτηση στον κατάλογο %1$s… - Αναζήτηση εγκατεστημένων παιχνιδιών… Αναζήτηση στη βιβλιοθήκη σας… Αναζήτηση - Τίποτα εγκατεστημένο Φόρτωση Δεν υπάρχουν παιχνίδια Φόρτωση του καταστήματος… @@ -1960,6 +1957,8 @@ Προβολή στο %1$s Αγορά · %1$s Εγκατεστημένο + Εγκατεστημένο + Δεν είναι εγκατεστημένο Στη βιβλιοθήκη Κλειδί API Λήψη κλειδιού diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 073d3971f..4d6ba89f8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1944,7 +1944,6 @@ Ajustes (sin iniciar sesión) Tienda - Instalados Biblioteca Todos Descargas @@ -1956,10 +1955,8 @@ Mantener la sesión Cerrar sesión Buscar en el catálogo de %1$s… - Buscar juegos instalados… Buscar en tu biblioteca… Buscar - Nada instalado Cargando No hay juegos Cargando la tienda… @@ -1978,6 +1975,8 @@ Ver en %1$s Comprar · %1$s Instalado + Instalado + No instalada En la biblioteca Clave de API Obtener una clave diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 61a3d124a..bd2f9d176 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1926,7 +1926,6 @@ Asetukset (ei kirjautunut) Kauppa - Asennetut Kirjasto Kaikki Lataukset @@ -1938,10 +1937,8 @@ Pysy kirjautuneena Kirjaudu ulos Hae %1$s-luettelosta… - Hae asennetuista peleistä… Hae kirjastostasi… Hae - Ei mitään asennettuna Latautuu Ei pelejä Ladataan kauppaa… @@ -1960,6 +1957,8 @@ Näytä %1$s:ssä Osta · %1$s Asennettu + Asennettu + Ei asennettu Kirjastossa API-avain Hae avain diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 952fa65cb..7d8b89eca 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1944,7 +1944,6 @@ Paramètres (non connecté) Boutique - Installés Bibliothèque Tous Téléchargements @@ -1956,10 +1955,8 @@ Rester connecté Se déconnecter Rechercher dans le catalogue %1$s… - Rechercher dans les jeux installés… Rechercher dans votre bibliothèque… Rechercher - Aucun jeu installé Chargement Aucun jeu Chargement de la boutique… @@ -1978,6 +1975,8 @@ Voir sur %1$s Acheter · %1$s Installé + Installé + Non installé Dans la bibliothèque Clé d\'API Obtenir une clé diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 691f28d59..75adfcb7c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1926,7 +1926,6 @@ सेटिंग (साइन इन नहीं है) स्टोर - इंस्टॉल किए लाइब्रेरी सभी डाउनलोड @@ -1938,10 +1937,8 @@ साइन इन रहें साइन आउट करें %1$s के कैटलॉग में सर्च करें… - इंस्टॉल किए गेम में सर्च करें… अपनी लाइब्रेरी में सर्च करें… सर्च - कुछ इंस्टॉल नहीं है लोड हो रहा है कोई गेम नहीं स्टोर लोड हो रहा है… @@ -1960,6 +1957,8 @@ %1$s पर देखें खरीदें · %1$s इंस्टॉल है + इंस्टॉल है + इंस्टॉल नहीं है लाइब्रेरी में API कुंजी कुंजी पाएं diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 16a054ba4..fa569cd9b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1926,7 +1926,6 @@ Beállítások (nincs bejelentkezve) Áruház - Telepítve Könyvtár Összes Letöltések @@ -1938,10 +1937,8 @@ Bejelentkezve marad Kijelentkezés Keresés a %1$s katalógusban… - Keresés a telepített játékok között… Keresés a könyvtárában… Keresés - Nincs telepítve semmi Betöltés Nincs játék Az áruház betöltése… @@ -1960,6 +1957,8 @@ Megtekintés itt: %1$s Megvétel · %1$s Telepítve + Telepítve + Nincs telepítve A könyvtárban API-kulcs Kulcs beszerzése diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 0126a527a..a14662f53 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1905,7 +1905,6 @@ Setelan (belum masuk) Toko - Terinstal Pustaka Semua Unduhan @@ -1917,10 +1916,8 @@ Tetap masuk Keluar Cari katalog %1$s… - Cari gim yang terinstal… Cari pustaka Anda… Cari - Belum ada yang terinstal Memuat Tidak ada gim Memuat toko… @@ -1939,6 +1936,8 @@ Lihat di %1$s Beli · %1$s Terinstal + Terinstal + Belum terinstal Dalam pustaka Kunci API Dapatkan kunci diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a9d7cb606..301076df7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1936,7 +1936,6 @@ Impostazioni (accesso non eseguito) Negozio - Installati Libreria Tutti Download @@ -1948,10 +1947,8 @@ Resta connesso Esci Cerca nel catalogo di %1$s… - Cerca tra i giochi installati… Cerca nella tua libreria… Cerca - Niente di installato Caricamento Nessun gioco Caricamento del negozio… @@ -1970,6 +1967,8 @@ Visualizza su %1$s Acquista · %1$s Installato + Installato + Non installata In libreria Chiave API Ottieni una chiave diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f7899fea4..af2e8bba6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1905,7 +1905,6 @@ 設定 (未ログイン) ストア - インストール済み ライブラリ すべて ダウンロード @@ -1917,10 +1916,8 @@ ログインしたままにする ログアウト %1$sのカタログを検索… - インストール済みのゲームを検索… ライブラリを検索… 検索 - 何もインストールされていません 読み込み中 ゲームがありません ストアを読み込み中… @@ -1939,6 +1936,8 @@ %1$sで見る 購入 · %1$s インストール済み + インストール済み + 未インストール ライブラリにあり APIキー キーを取得 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index db3418dc1..aeb066798 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1905,7 +1905,6 @@ 설정 (로그인 안 됨) 스토어 - 설치됨 라이브러리 전체 다운로드 @@ -1917,10 +1916,8 @@ 로그인 유지 로그아웃 %1$s 카탈로그 검색… - 설치된 게임 검색… 내 라이브러리 검색… 검색 - 설치된 항목 없음 불러오는 중 게임 없음 스토어 불러오는 중… @@ -1939,6 +1936,8 @@ %1$s에서 보기 구매 · %1$s 설치됨 + 설치됨 + 설치되지 않음 라이브러리에 있음 API 키 키 받기 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 4cec0534e..afd271efc 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1905,7 +1905,6 @@ Tetapan (belum log masuk) Kedai - Dipasang Pustaka Semua Muat turun @@ -1917,10 +1916,8 @@ Kekal log masuk Log keluar Cari katalog %1$s… - Cari permainan dipasang… Cari pustaka anda… Cari - Tiada apa dipasang Memuatkan Tiada permainan Memuatkan kedai… @@ -1939,6 +1936,8 @@ Lihat pada %1$s Beli · %1$s Dipasang + Dipasang + Belum dipasang Dalam pustaka Kunci API Dapatkan kunci diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 058a20cf8..1649f4e4a 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1926,7 +1926,6 @@ Innstillinger (ikke innlogget) Butikk - Installert Bibliotek Alle Nedlastinger @@ -1938,10 +1937,8 @@ Fortsett innlogget Logg ut Søk i %1$s-katalogen… - Søk i installerte spill… Søk i biblioteket ditt… Søk - Ingenting installert Laster Ingen spill Laster butikken… @@ -1960,6 +1957,8 @@ Vis på %1$s Kjøp · %1$s Installert + Installert + Ikke installert I biblioteket API-nøkkel Hent en nøkkel diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index cb5127130..3ac9d8029 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1926,7 +1926,6 @@ Instellingen (niet aangemeld) Winkel - Geïnstalleerd Bibliotheek Alle Downloads @@ -1938,10 +1937,8 @@ Aangemeld blijven Afmelden Zoeken in de catalogus van %1$s… - Geïnstalleerde games zoeken… Zoeken in je bibliotheek… Zoeken - Niets geïnstalleerd Laden Geen games Winkel laden… @@ -1960,6 +1957,8 @@ Bekijken op %1$s Kopen · %1$s Geïnstalleerd + Geïnstalleerd + Niet geïnstalleerd In bibliotheek API-sleutel Sleutel ophalen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bb7e40dcf..5828bb5a1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1968,7 +1968,6 @@ Ustawienia (niezalogowano) Sklep - Zainstalowane Biblioteka Wszystkie Pobierania @@ -1980,10 +1979,8 @@ Zostań zalogowany Wyloguj się Szukaj w katalogu %1$s… - Szukaj zainstalowanych gier… Szukaj w Twojej bibliotece… Szukaj - Nic nie zainstalowano Wczytywanie Brak gier Wczytywanie sklepu… @@ -2002,6 +1999,8 @@ Zobacz w %1$s Kup · %1$s Zainstalowano + Zainstalowano + Nie zainstalowano W bibliotece Klucz API Pobierz klucz diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 931f557bd..eb3dbde1e 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1936,7 +1936,6 @@ Definições (sessão não iniciada) Loja - Instalados Biblioteca Todos Transferências @@ -1948,10 +1947,8 @@ Manter sessão iniciada Terminar sessão Pesquisar no catálogo %1$s… - Pesquisar jogos instalados… Pesquisar na sua biblioteca… Pesquisar - Nada instalado A carregar Nenhum jogo A carregar a loja… @@ -1970,6 +1967,8 @@ Ver em %1$s Comprar · %1$s Instalado + Instalado + Não instalado Na biblioteca Chave de API Obter uma chave diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index bfc2b4120..9119ead1b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1936,7 +1936,6 @@ Configurações (sem login) Loja - Instalados Biblioteca Todos Downloads @@ -1948,10 +1947,8 @@ Continuar conectado Sair Pesquisar no catálogo de %1$s… - Pesquisar jogos instalados… Pesquisar na sua biblioteca… Pesquisar - Nada instalado Carregando Nenhum jogo Carregando a loja… @@ -1970,6 +1967,8 @@ Ver em %1$s Comprar · %1$s Instalado + Instalado + Não instalado Na biblioteca Chave de API Obter uma chave diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 01f2f37f6..e16c6174a 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1947,7 +1947,6 @@ Setări (neautentificat) Magazin - Instalate Bibliotecă Toate Descărcări @@ -1959,10 +1958,8 @@ Rămâi autentificat Deconectare Caută în catalogul %1$s… - Caută jocurile instalate… Caută în biblioteca ta… Căutare - Nimic instalat Se încarcă Niciun joc Se încarcă magazinul… @@ -1981,6 +1978,8 @@ Vezi pe %1$s Cumpără · %1$s Instalat + Instalat + Neinstalat În bibliotecă Cheie API Obține o cheie diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bab12fb06..4c3e71733 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1965,7 +1965,6 @@ Настройки (вход не выполнен) Магазин - Установлено Библиотека Все Загрузки @@ -1977,10 +1976,8 @@ Не выходить Выйти Поиск в каталоге %1$s… - Поиск установленных игр… Поиск в библиотеке… Поиск - Ничего не установлено Загрузка Нет игр Загрузка магазина… @@ -1999,6 +1996,8 @@ Открыть в %1$s Купить · %1$s Установлено + Установлено + Не установлено В библиотеке Ключ API Получить ключ diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 33a2024b3..39f63eef1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1926,7 +1926,6 @@ Inställningar (inte inloggad) Butik - Installerade Bibliotek Alla Nedladdningar @@ -1938,10 +1937,8 @@ Fortsätt vara inloggad Logga ut Sök i %1$s-katalogen… - Sök installerade spel… Sök i ditt bibliotek… Sök - Inget installerat Läser in Inga spel Läser in butiken… @@ -1960,6 +1957,8 @@ Visa på %1$s Köp · %1$s Installerat + Installerat + Inte installerad I biblioteket API-nyckel Hämta en nyckel diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 2d03c33e4..ced2fb961 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1905,7 +1905,6 @@ การตั้งค่า (ยังไม่ลงชื่อเข้าใช้) สโตร์ - ติดตั้งแล้ว ไลบรารี ทั้งหมด การดาวน์โหลด @@ -1917,10 +1916,8 @@ ลงชื่อเข้าใช้ต่อไป ลงชื่อออก ค้นหาแคตตาล็อกของ %1$s… - ค้นหาเกมที่ติดตั้งไว้… ค้นหาไลบรารีของคุณ… ค้นหา - ไม่มีสิ่งที่ติดตั้งไว้ กำลังโหลด ไม่มีเกม กำลังโหลดสโตร์… @@ -1939,6 +1936,8 @@ ดูบน %1$s ซื้อ · %1$s ติดตั้งแล้ว + ติดตั้งแล้ว + ยังไม่ติดตั้ง อยู่ในไลบรารี คีย์ API รับคีย์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 71906dca8..42da82a97 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1915,7 +1915,6 @@ Ayarlar (oturum açılmadı) Mağaza - Yüklü Kütüphane Tümü İndirmeler @@ -1927,10 +1926,8 @@ Oturumu açık tut Oturumu kapat %1$s kataloğunda ara… - Yüklü oyunlarda ara… Kütüphanenizde ara… Ara - Yüklü bir şey yok Yükleniyor Oyun yok Mağaza yükleniyor… @@ -1949,6 +1946,8 @@ %1$s\'te görüntüle Satın al · %1$s Yüklü + Yüklü + Yüklü değil Kütüphanede API anahtarı Anahtar al diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4a72e62c7..03eed1e71 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1968,7 +1968,6 @@ Налаштування (вхід не виконано) Магазин - Встановлені Бібліотека Усі Завантаження @@ -1980,10 +1979,8 @@ Не виходити Вийти Пошук у каталозі %1$s… - Пошук встановлених ігор… Пошук у вашій бібліотеці… Пошук - Нічого не встановлено Завантаження Немає ігор Завантаження магазину… @@ -2002,6 +1999,8 @@ Переглянути на %1$s Купити · %1$s Встановлено + Встановлено + Не встановлено У бібліотеці Ключ API Отримати ключ diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index eb7f8f4f2..9a49e869b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1905,7 +1905,6 @@ Thiết lập (chưa đăng nhập) Cửa hàng - Đã cài Thư viện Tất cả Tải xuống @@ -1917,10 +1916,8 @@ Vẫn đăng nhập Đăng xuất Tìm trong danh mục %1$s… - Tìm game đã cài… Tìm trong thư viện của bạn… Tìm kiếm - Chưa cài gì Đang tải Không có game Đang tải store… @@ -1939,6 +1936,8 @@ Xem trên %1$s Mua · %1$s Đã cài + Đã cài + Chưa cài Trong thư viện Khóa API Lấy khóa diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0c9146a79..65896e07e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1981,7 +1981,6 @@ 设置 (未登录) 商店 - 已安装 库 全部 下载 @@ -1993,10 +1992,8 @@ 保持登录 退出登录 搜索 %1$s 目录… - 搜索已安装的游戏… 搜索你的库… 搜索 - 尚未安装任何内容 加载中 没有游戏 正在加载商店… @@ -2015,6 +2012,8 @@ 在 %1$s 上查看 购买 · %1$s 已安装 + 已安装 + 未安装 已在库中 API 密钥 获取密钥 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 422d8ee8b..1618d8501 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1905,7 +1905,6 @@ 設定 (未登入) 商店 - 已安裝 收藏庫 全部 下載 @@ -1917,10 +1916,8 @@ 保持登入 登出 搜尋 %1$s 目錄… - 搜尋已安裝的遊戲… 搜尋你的收藏庫… 搜尋 - 未安裝任何遊戲 正在載入 沒有遊戲 正在載入商店… @@ -1939,6 +1936,8 @@ 在 %1$s 查看 購買 · %1$s 已安裝 + 已安裝 + 未安裝 已在收藏庫中 API 金鑰 取得金鑰 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 59d65bad8..04dc2a74e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1905,7 +1905,6 @@ 設定 (未登入) 商店 - 已安裝 收藏庫 全部 下載 @@ -1917,10 +1916,8 @@ 保持登入 登出 搜尋 %1$s 目錄… - 搜尋已安裝的遊戲… 搜尋你的收藏庫… 搜尋 - 尚未安裝任何內容 正在載入 沒有遊戲 正在載入商店… @@ -1939,6 +1936,8 @@ 在 %1$s 檢視 購買 · %1$s 已安裝 + 已安裝 + 未安裝 已在收藏庫中 API 金鑰 取得金鑰 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1baba801..44fb4fc2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1969,7 +1969,6 @@ Settings (not signed in) Store - Installed Library All Downloads @@ -1981,10 +1980,8 @@ Stay signed in Sign out Search the %1$s catalog… - Search installed games… Search your library… Search - Nothing installed Loading No games Loading the store… @@ -2003,6 +2000,8 @@ View on %1$s Buy · %1$s Installed + Installed + Not installed In library API key Get a key diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt index 4f0b9d421..ad1b13df7 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt @@ -26,4 +26,29 @@ class StoreGridNavigationTest { assertEquals(5, storeGridNeighbor(2, 10, 3, 1)) assertEquals(6, storeGridNeighbor(2, 10, 4, 1)) } + + @Test fun downFromTheInstalledSectionLandsInTheSameColumnBelowTheDivider() { + assertEquals(6, storeGridNeighbor(0, 12, 4, 1, split = 6).let { storeGridNeighbor(it!!, 12, 4, 1, split = 6) }) + assertEquals(6, storeGridNeighbor(4, 12, 4, 1, split = 6)) + assertEquals(7, storeGridNeighbor(5, 12, 4, 1, split = 6)) + assertEquals(8, storeGridNeighbor(2, 12, 4, 1, split = 6)) + } + + @Test fun upFromBelowTheDividerClampsToTheShortInstalledRow() { + assertEquals(4, storeGridNeighbor(6, 12, 4, -1, split = 6)) + assertEquals(5, storeGridNeighbor(7, 12, 4, -1, split = 6)) + assertEquals(5, storeGridNeighbor(9, 12, 4, -1, split = 6)) + assertEquals(6, storeGridNeighbor(10, 12, 4, -1, split = 6)) + } + + @Test fun lastSectionStillStopsAtItsIncompleteRow() { + assertNull(storeGridNeighbor(9, 11, 4, 1, split = 6)) + assertEquals(10, storeGridNeighbor(6, 11, 4, 1, split = 6)) + assertNull(storeGridNeighbor(1, 11, 4, -1, split = 6)) + } + + @Test fun installedGamesComeFirstKeepingTheirSortedOrder() { + val items = listOf("a", "b", "c", "d").map { com.droiddeck.launcher.stores.CatalogItem(com.droiddeck.launcher.stores.Store.GOG, it, it, null) } + assertEquals(listOf("b", "d", "a", "c"), installedFirst(items, setOf("d", "b")).map { it.id }) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt index 2be67a4b6..a5578a0f7 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt @@ -5,12 +5,13 @@ import org.junit.Assert.assertEquals import org.junit.Test class StoreTabsTest { - @Test fun storeLibraryInstalledAmazonWithoutStore() { - assertEquals(listOf("store", "library", "installed"), tabsFor(Store.GOG)) - assertEquals(listOf("library", "installed"), tabsFor(Store.AMAZON)) + @Test fun libraryAndStoreAmazonLibraryOnly() { + assertEquals(listOf("library", "store"), tabsFor(Store.GOG)) + assertEquals(listOf("library", "store"), tabsFor(Store.ITCH)) + assertEquals(listOf("library"), tabsFor(Store.AMAZON)) assertEquals("library", tabFor(Store.AMAZON, "store")) assertEquals("library", tabFor(Store.GOG, "all")) - assertEquals("installed", tabFor(Store.EPIC, "installed")) + assertEquals("library", tabFor(Store.EPIC, "installed")) assertEquals("store", tabFor(Store.GOG, "store")) } } From c5f7f437263fd113d1f877f7ec9e7103b2ae6485 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:23:02 -0400 Subject: [PATCH 015/156] feat: play button on installed store game pages (#1125) --- .../main/java/com/droiddeck/launcher/ui/StoresDetail.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index f6bc614b9..151a487dc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -115,10 +117,13 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro Actions { when { installed != null -> { - ActionChip(stringResource(R.string.stores_in_steam), ok = true) val gone = StoresState.uninstalling["${store.id}:$id"] + PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { + launchStoreGame(ctx, store, id, s, a) + } + ActionChip(stringResource(R.string.stores_in_steam), ok = true) if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {} - else UninstallButton(title, installed, enabled = download == null, modifier = leadOn) { + else UninstallButton(title, installed, enabled = download == null) { StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } } } From 7733e29d10f8de1b510ae6ade4ddacfec2625009 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:45:58 -0400 Subject: [PATCH 016/156] feat: capsule view with DVD-case store art across all stores (#1117) --- .../launcher/session/SessionPrefs.kt | 2 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 59 +++++++++++++------ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-bg/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fi/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-hi/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 1 + app/src/main/res/values-nb/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values-th/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rHK/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 35 files changed, 76 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 49aa2e9ba..4c4cd9808 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -119,7 +119,7 @@ object SessionPrefs { } val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc") - val STORES_VIEWS = listOf("cards", "list") + val STORES_VIEWS = listOf("cards", "capsules", "list") const val STORES_OPEN_LIBRARY = "library" const val STORES_OPEN_STORE = "store" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 2feda7597..3933450d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -90,6 +90,7 @@ import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.ViewModule import com.droiddeck.launcher.stores.sortCatalogItems import com.droiddeck.launcher.stores.retainCatalogOrder import androidx.compose.foundation.layout.widthIn @@ -803,6 +804,8 @@ private fun Storefront( val headerItems = 1 + (if (sizeSortPending) 1 else 0) + (if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0) val listMode = s.storesView == "list" && tab == "library" + val capsules = s.storesView == "capsules" && tab == "library" + val tileWidth = if (capsules) CapsuleWidth else CardWidth val card: @Composable (CatalogItem) -> Unit = { item -> val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced firstPlaced = true @@ -834,12 +837,12 @@ private fun Storefront( } } if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation) - else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation) + else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules) } LazyVerticalGrid( - columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { + columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged { val gap = with(density) { 10.dp.roundToPx() } - val minimum = with(density) { (if (listMode) CardWidth * 2 else CardWidth).roundToPx() } + val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() } columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1) }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -1094,12 +1097,13 @@ private fun SortButton(s: FrontEndState, a: FrontEndActions) { @Composable private fun ViewButton(s: FrontEndState, a: FrontEndActions) { - val list = s.storesView == "list" + val views = SessionPrefs.STORES_VIEWS + val next = views[(views.indexOf(s.storesView) + 1) % views.size] HeaderIconButton( - if (list) stringResource(R.string.stores_view_cards) else stringResource(R.string.stores_view_list), - if (list) Icons.Filled.GridView else Icons.Filled.ViewList, + stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }), + when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView }, "view", - ) { a.onStoresView(if (list) "cards" else "list") } + ) { a.onStoresView(next) } } @Composable @@ -1226,9 +1230,13 @@ private fun LazyGridScope.storeGrid(games: List, empty: String, car } private val CardWidth = 112.dp +private val CapsuleWidth = 92.dp @Composable -private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, first: Boolean = false, navigation: Modifier = Modifier) { +private fun GameCard( + item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, + first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false, +) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -1241,7 +1249,8 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front val placed = remember { arrayOfNulls(1) } val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() } val open = { - placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) } + val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl + placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) } onOpen(item.key) } val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() } @@ -1259,7 +1268,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front .clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open), ) { - CardFace(item, download, hot) { CardMarker(item, installed, download) } + CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) } } } @@ -1281,13 +1290,28 @@ private val CardStrip = 26.dp @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable -private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, marker: @Composable () -> Unit) { - val url = item.imageUrl ?: item.tallImageUrl +private fun CardFace( + item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false, + marker: @Composable () -> Unit, +) { + val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl val blurs = android.os.Build.VERSION.SDK_INT >= 31 + var landscape by remember(url) { mutableStateOf(false) } BoxWithConstraints(Modifier.fillMaxWidth()) { - val total = maxWidth * 9f / 16f + CardStrip + val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) { - if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + if (url != null && capsule && landscape) { + // Stores without portrait covers (itch) would lose most of the art to a portrait crop. + AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp)) + Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f))) + AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip)) + } else if (url != null) AsyncImage( + model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), + onSuccess = { r -> + val art = r.result.drawable + if (capsule && art.intrinsicWidth > 0 && art.intrinsicHeight > 0) landscape = art.intrinsicWidth > art.intrinsicHeight * 0.95f + }, + ) Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) { if (url != null) AsyncImage( model = url, contentDescription = null, contentScale = ContentScale.Crop, @@ -1315,7 +1339,7 @@ private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores. } @Composable -private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?) { +private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val small = 10.sp @@ -1323,12 +1347,13 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { val ctx = LocalContext.current val game = StoresState.installedGame(item.store, item.id) - if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } - com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } + if (game != null && !compact) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) } + if (!compact) com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp)) } download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + item.owned && compact -> Unit item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1) item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 36caa8aee..e496994a4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2067,6 +2067,7 @@ تاريخ الإصدار عرض القائمة عرض البطاقات + عرض الأغلفة إظهار المحتوى للبالغين Epic EOS diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index da88522af..fab4a066f 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2024,6 +2024,7 @@ Fecha de lanzamiento Vista de lista Vista de tarjetas + Vista de portadas Mostrar contenido para adultos Epic EOS diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a76a3cd91..5e375bca2 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1983,6 +1983,7 @@ Дата на издаване Изглед на списък Изглед с карти + Изглед с обложки Показвай съдържание за възрастни Epic EOS diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5ab150110..b4d674c32 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2025,6 +2025,7 @@ Datum vydání Zobrazení seznamu Zobrazení karet + Zobrazení obalů Zobrazovat obsah pro dospělé Epic EOS diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 77f4eb7a0..3b2bc8979 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1983,6 +1983,7 @@ Udgivelsesdato Listevisning Kortvisning + Omslagsvisning Vis indhold for voksne Epic EOS diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9a41a6627..3a4c61b17 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1972,6 +1972,7 @@ Erscheinungsdatum Listenansicht Kartenansicht + Coveransicht Inhalte für Erwachsene anzeigen Epic EOS diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 430fb46b0..4f4bfaa44 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1983,6 +1983,7 @@ Ημερομηνία κυκλοφορίας Προβολή λίστας Προβολή καρτών + Προβολή εξωφύλλων Εμφάνιση περιεχομένου για ενήλικες Epic EOS diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4d6ba89f8..09660256e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2001,6 +2001,7 @@ Fecha de lanzamiento Vista de lista Vista de tarjetas + Vista de portadas Mostrar contenido para adultos Epic EOS diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index bd2f9d176..88d810a69 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1983,6 +1983,7 @@ Julkaisupäivä Luettelonäkymä Korttinäkymä + Kansinäkymä Näytä aikuisille tarkoitettu sisältö Epic EOS diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7d8b89eca..9393da87c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2001,6 +2001,7 @@ Date de sortie Vue liste Vue cartes + Vue jaquettes Afficher le contenu pour adultes Epic EOS diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 75adfcb7c..91fae22a1 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1983,6 +1983,7 @@ रिलीज़ तिथि सूची दृश्य कार्ड दृश्य + कवर व्यू वयस्क कंटेंट दिखाएं Epic EOS diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index fa569cd9b..48809c616 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1983,6 +1983,7 @@ Megjelenés dátuma Listanézet Kártyanézet + Borítónézet Felnőtt tartalom megjelenítése Epic EOS diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index a14662f53..85abaeec8 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1962,6 +1962,7 @@ Tanggal rilis Tampilan daftar Tampilan kartu + Tampilan sampul Tampilkan konten dewasa Epic EOS diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 301076df7..19fe2f598 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1993,6 +1993,7 @@ Data di uscita Visualizzazione elenco Visualizzazione schede + Vista copertine Mostra contenuti per adulti Epic EOS diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index af2e8bba6..c39625cd3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1962,6 +1962,7 @@ リリース日 リスト表示 カード表示 + パッケージ表示 成人向けコンテンツを表示 Epic EOS diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index aeb066798..8e2508ebe 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1962,6 +1962,7 @@ 출시일 목록 보기 카드 보기 + 표지 보기 성인용 콘텐츠 표시 Epic EOS diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index afd271efc..537bd5421 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1962,6 +1962,7 @@ Tarikh keluaran Paparan senarai Paparan kad + Paparan kulit Papar kandungan matang Epic EOS diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 1649f4e4a..977db283c 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1983,6 +1983,7 @@ Utgivelsesdato Listevisning Kortvisning + Omslagsvisning Vis innhold for voksne Epic EOS diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3ac9d8029..f841a1878 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1983,6 +1983,7 @@ Releasedatum Lijstweergave Kaartweergave + Hoesweergave Inhoud voor volwassenen tonen Epic EOS diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5828bb5a1..d89698468 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2025,6 +2025,7 @@ Data wydania Widok listy Widok kart + Widok okładek Pokaż treści dla dorosłych Epic EOS diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index eb3dbde1e..55d8d0108 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1993,6 +1993,7 @@ Data de lançamento Vista de lista Vista de cartões + Vista de capas Mostrar conteúdo para adultos Epic EOS diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9119ead1b..dd27961db 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1993,6 +1993,7 @@ Data de lançamento Vista de lista Vista de cartões + Visualização de capas Mostrar conteúdo adulto Epic EOS diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index e16c6174a..637ffea5e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2004,6 +2004,7 @@ Data lansării Vizualizare listă Vizualizare carduri + Vizualizare coperți Arată conținutul pentru adulți Epic EOS diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4c3e71733..b5a52740a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2022,6 +2022,7 @@ Дата выхода Вид списком Вид карточками + Вид обложек Показывать контент для взрослых Epic EOS diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 39f63eef1..6e97abd03 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1983,6 +1983,7 @@ Utgivningsdatum Listvy Kortvy + Omslagsvy Visa innehåll för vuxna Epic EOS diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ced2fb961..24a52fc9e 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1962,6 +1962,7 @@ วันวางจำหน่าย มุมมองรายการ มุมมองการ์ด + มุมมองปก แสดงเนื้อหาสำหรับผู้ใหญ่ Epic EOS diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 42da82a97..424db34ea 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1972,6 +1972,7 @@ Çıkış tarihi Liste görünümü Kart görünümü + Kapak görünümü Yetişkin içeriği göster Epic EOS diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 03eed1e71..2d79d0a94 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2025,6 +2025,7 @@ Дата виходу Вигляд списком Вигляд картками + Вигляд обкладинок Показувати контент для дорослих Epic EOS diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9a49e869b..bf2c67811 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1962,6 +1962,7 @@ Ngày phát hành Chế độ danh sách Chế độ thẻ + Chế độ xem bìa Hiện nội dung người lớn Epic EOS diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 65896e07e..206e30f1d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2038,6 +2038,7 @@ 发行日期 列表视图 卡片视图 + 封面视图 显示成人内容 Epic EOS diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 1618d8501..16f54ea4e 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1962,6 +1962,7 @@ 發行日期 列表檢視 卡片檢視 + 封面檢視 顯示成人內容 Epic EOS diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 04dc2a74e..95e5dd915 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1962,6 +1962,7 @@ 發行日期 列表檢視 卡片檢視 + 封面檢視 顯示成人內容 Epic EOS diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44fb4fc2a..603099d9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2026,6 +2026,7 @@ Release date List view Card view + Capsule view Show mature content Epic EOS From 55bf51cf37d46de612359c5e92440d77883e7207 Mon Sep 17 00:00:00 2001 From: kingokksa <47888451+kingokksa@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:54:19 +0800 Subject: [PATCH 017/156] Ship the desktop's translations as per-language packs (#942) --- .github/workflows/build-desktop-kde.yml | 6 ++- tools/desktop-kde/build.sh | 12 ++++++ tools/tests/test_desktop_language.py | 51 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tools/tests/test_desktop_language.py diff --git a/.github/workflows/build-desktop-kde.yml b/.github/workflows/build-desktop-kde.yml index 5a9933abc..365e3d4f8 100644 --- a/.github/workflows/build-desktop-kde.yml +++ b/.github/workflows/build-desktop-kde.yml @@ -64,6 +64,7 @@ jobs: KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out cp out/kwin/desktop-kde.kwin.txt out/ echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)" + echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)" - uses: actions/upload-artifact@v4 with: name: desktop-kde @@ -74,6 +75,8 @@ jobs: out/desktop-kde.packages.txt out/desktop-kde.kwin.txt out/desktop-kde.sha256 + out/desktop-kde.i18n.txt + out/desktop-kde-i18n-*.tar.zst - name: Publish if: github.event_name == 'workflow_dispatch' && inputs.tag != '' env: @@ -88,5 +91,6 @@ jobs: fi gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \ --notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \ - out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt + out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \ + out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst gh release edit "$TAG" --latest=false --prerelease=false diff --git a/tools/desktop-kde/build.sh b/tools/desktop-kde/build.sh index 524a76f46..80bd41e11 100755 --- a/tools/desktop-kde/build.sh +++ b/tools/desktop-kde/build.sh @@ -40,6 +40,18 @@ if [ -n "${KDE_KWIN_OVERRIDE:-}" ]; then install -m 755 "$KDE_KWIN_OVERRIDE/libkwin.so.6.7.5" "$work/root/usr/lib/libkwin.so.6.7.5" fi +if [ -d "$work/root/usr/share/locale" ]; then + for dir in "$work/root"/usr/share/locale/*/; do + loc=${dir#$work/root/usr/share/locale/}; loc=${loc%/} + case $loc in en*) continue ;; esac + [ -d "$dir/LC_MESSAGES" ] || continue + file=$out/desktop-kde-i18n-$loc.tar.zst + tar -C "$work/root" --zstd -cf "$file" "./usr/share/locale/$loc" + printf '%s %s %s\n' "$loc" "$(sha256sum "$file" | cut -d' ' -f1)" "$(stat -c %s "$file")" + done > "$out/desktop-kde.i18n.txt" + echo "i18n: $(wc -l < "$out/desktop-kde.i18n.txt") language packs" +fi + rm -rf "$work/root/usr/share/doc" "$work/root/usr/share/man" "$work/root/usr/share/info" \ "$work/root/usr/share/gtk-doc" "$work/root/usr/include" "$work/root/usr/lib/pkgconfig" \ "$work/root/usr/lib/cmake" "$work/root/usr/share/help" diff --git a/tools/tests/test_desktop_language.py b/tools/tests/test_desktop_language.py new file mode 100644 index 000000000..19bbe2851 --- /dev/null +++ b/tools/tests/test_desktop_language.py @@ -0,0 +1,51 @@ +import re +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path): + return path.read_text(encoding='utf-8') + + +BUILD = ROOT / 'tools/desktop-kde/build.sh' +WORKFLOW = ROOT / '.github/workflows/build-desktop-kde.yml' + + +class DesktopLanguageBuildTest(unittest.TestCase): + def setUp(self): + self.build = read(BUILD) + + def test_the_main_package_keeps_english_and_every_other_language_becomes_a_pack(self): + loop = self.build.split('usr/share/locale/*/', 1)[1].split('rm -rf "$work/root/usr/share/doc"', 1)[0] + self.assertIn("case $loc in en*) continue ;; esac", loop) + self.assertIn('[ -d "$dir/LC_MESSAGES" ] || continue', loop) + self.assertIn('desktop-kde-i18n-$loc.tar.zst', loop) + self.assertIn('> "$out/desktop-kde.i18n.txt"', loop) + self.assertIn('find "$work/root/usr/share/locale" -mindepth 1 -maxdepth 1 -type d ! -name \'en*\'', + self.build.split('i18n.txt"', 1)[1]) + + def test_the_index_lines_carry_what_the_app_verifies(self): + record = re.search(r"printf '%s %s %s\\n' \"\$loc\" \"\$\(sha256sum \"\$file\" \| cut -d' ' -f1\)\" \"\$\(stat -c %s \"\$file\"\)\"", + self.build) + self.assertIsNotNone(record) + line = 'de ' + 'a' * 64 + ' 262144' + fields = line.split() + self.assertEqual(3, len(fields)) + self.assertTrue(re.fullmatch(r'[0-9a-f]{64}', fields[1])) + self.assertEqual(262144, int(fields[2])) + + def test_the_workflow_ships_the_index_and_the_packs(self): + workflow = read(WORKFLOW) + self.assertIn('out/desktop-kde.i18n.txt', workflow) + self.assertEqual(1, len([line for line in workflow.splitlines() + if line.strip() == 'out/desktop-kde-i18n-*.tar.zst'])) + release = workflow.split('gh release create', 1)[1] + self.assertIn('out/desktop-kde.i18n.txt', release) + self.assertIn('out/desktop-kde-i18n-*.tar.zst', release) + + +if __name__ == '__main__': + unittest.main() From ab6d100cea91fab400c0bbb3e6ad794d23749709 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:58:24 -0400 Subject: [PATCH 018/156] fix: keep the TP UBWC hint on for Samsung One UI 8 Gen 2 (#1128) --- .../java/com/droiddeck/launcher/core/DeviceReport.kt | 1 + .../java/com/droiddeck/launcher/gpu/TpUbwcHint.kt | 11 +++++++---- .../java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt | 6 ++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 1290884bb..aad57eaef 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -200,6 +200,7 @@ object DeviceReport { h("Drivers") val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect() k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") + if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default") k("Driver mode", SessionPrefs.gpuDriverMode(context) + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context), diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt index 84440c213..1ccee78e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt @@ -11,8 +11,10 @@ object TpUbwcHint { // On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must // match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI. - internal fun features(model: Int, systemDriverVersion: String?): String? { + // Samsung's One UI firmware sets it whatever blob version it reports. + internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? { if (model != 740) return null + if (samsung) return "enable_tp_ubwc_flag_hint=1" val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0" } @@ -20,11 +22,12 @@ object TpUbwcHint { @Synchronized fun forThisDevice(): String? { if (resolved) return cached - val model = GpuInfo.detect().model + val gpu = GpuInfo.detect() + val model = gpu.model val version = if (model == 740) VulkanInfo.query()["driver_version"] else null - cached = features(model, version) + cached = features(model, version, gpu.oneUi8Gen2) resolved = true - Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}") + Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}") return cached } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt index ab037e152..fe5183cfa 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt @@ -13,9 +13,15 @@ class TpUbwcHintTest { assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10")) } + @Test fun a740OnOneUiTurnsTheHintOnWhateverTheBlobVersion() { + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.53", samsung = true)) + assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true)) + } + @Test fun otherGpusKeepTheirDriverDefault() { assertNull(TpUbwcHint.features(750, "512.762.10")) assertNull(TpUbwcHint.features(830, "512.676.53")) + assertNull(TpUbwcHint.features(750, "512.676.53", samsung = true)) } @Test fun anUnreadableSystemDriverKeepsTheDriverDefault() { From 2415ff0518a56d5b4820d75873ce5a1d6dbb255e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:21:32 -0400 Subject: [PATCH 019/156] fix: DD-FG 3x/4x flicker and rebrand Win-FG to DD-FG (#1132) --- app/src/main/cpp/CMakeLists.txt | 28 +- .../main/cpp/framegen/VulkanRendererContext.h | 10 +- .../framegen/{winfg => ddfg}/LICENSE.win-fg | 0 .../NOTICE_FIDELITYFX_OPTICALFLOW.md | 0 .../cpp/framegen/{winfg => ddfg}/config.hpp | 72 ++--- .../winfg_engine.cpp => ddfg/ddfg_engine.cpp} | 23 +- .../winfg_engine.h => ddfg/ddfg_engine.h} | 2 +- .../winfg_vkd.cpp => ddfg/ddfg_vkd.cpp} | 6 +- app/src/main/cpp/framegen/ddfg/ddfg_vkd.h | 4 + .../{winfg => ddfg}/embedded_shaders.hpp | 10 +- .../cpp/framegen/{winfg => ddfg}/framegen.cpp | 12 +- .../cpp/framegen/{winfg => ddfg}/framegen.hpp | 6 +- app/src/main/cpp/framegen/ddfg/log.hpp | 12 + .../framegen/{winfg => ddfg}/record_impl.inc | 301 +++++++++--------- .../framegen/{winfg => ddfg}/vk_dispatch.hpp | 2 +- app/src/main/cpp/framegen/winfg/log.hpp | 12 - app/src/main/cpp/framegen/winfg/winfg_vkd.h | 4 - app/src/main/cpp/waylandcomp/CMakeLists.txt | 16 +- .../cpp/waylandcomp/src/framegen_bridge.c | 8 +- .../cpp/waylandcomp/src/framegen_bridge.h | 4 +- .../cpp/waylandcomp/src/framegen_engine.cpp | 32 +- .../waylandcomp/src/framegen_engine_stub.c | 2 +- .../main/cpp/waylandcomp/src/framegen_jni.c | 4 +- .../com/droiddeck/launcher/gpu/FrameGen.kt | 21 +- .../com/droiddeck/launcher/session/PerfHud.kt | 2 +- .../com/droiddeck/launcher/ui/FrameGenMenu.kt | 2 +- .../launcher/wayland/WaylandCompositor.java | 4 +- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-bg/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fi/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-ms/strings.xml | 2 +- app/src/main/res/values-nb/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rPT/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-th/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../droiddeck/launcher/gpu/FrameGenTest.kt | 10 +- .../launcher/session/SettingsResetTest.kt | 2 +- 62 files changed, 353 insertions(+), 324 deletions(-) rename app/src/main/cpp/framegen/{winfg => ddfg}/LICENSE.win-fg (100%) rename app/src/main/cpp/framegen/{winfg => ddfg}/NOTICE_FIDELITYFX_OPTICALFLOW.md (100%) rename app/src/main/cpp/framegen/{winfg => ddfg}/config.hpp (78%) rename app/src/main/cpp/framegen/{winfg/winfg_engine.cpp => ddfg/ddfg_engine.cpp} (91%) rename app/src/main/cpp/framegen/{winfg/winfg_engine.h => ddfg/ddfg_engine.h} (99%) rename app/src/main/cpp/framegen/{winfg/winfg_vkd.cpp => ddfg/ddfg_vkd.cpp} (90%) create mode 100644 app/src/main/cpp/framegen/ddfg/ddfg_vkd.h rename app/src/main/cpp/framegen/{winfg => ddfg}/embedded_shaders.hpp (82%) rename app/src/main/cpp/framegen/{winfg => ddfg}/framegen.cpp (96%) rename app/src/main/cpp/framegen/{winfg => ddfg}/framegen.hpp (96%) create mode 100644 app/src/main/cpp/framegen/ddfg/log.hpp rename app/src/main/cpp/framegen/{winfg => ddfg}/record_impl.inc (65%) rename app/src/main/cpp/framegen/{winfg => ddfg}/vk_dispatch.hpp (99%) delete mode 100644 app/src/main/cpp/framegen/winfg/log.hpp delete mode 100644 app/src/main/cpp/framegen/winfg/winfg_vkd.h diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 01a0a97a2..136e0f0e9 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -11,23 +11,23 @@ add_subdirectory(adrenotools) # DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc). add_subdirectory(thirdparty/dxbc) -set(WINFG_UPSTREAM "v0.3.0") -set(WINFG_CHAIN_SRC - framegen/winfg/framegen.cpp - framegen/winfg/framegen.hpp - framegen/winfg/record_impl.inc - framegen/winfg/config.hpp - framegen/winfg/embedded_shaders.hpp - framegen/winfg/winfg_engine.cpp +set(DDFG_UPSTREAM "v0.3.0") +set(DDFG_CHAIN_SRC + framegen/ddfg/framegen.cpp + framegen/ddfg/framegen.hpp + framegen/ddfg/record_impl.inc + framegen/ddfg/config.hpp + framegen/ddfg/embedded_shaders.hpp + framegen/ddfg/ddfg_engine.cpp ) -set(WINFG_HASH_INPUT "") -foreach(f ${WINFG_CHAIN_SRC}) +set(DDFG_HASH_INPUT "") +foreach(f ${DDFG_CHAIN_SRC}) file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h) - string(APPEND WINFG_HASH_INPUT "${_h}") + string(APPEND DDFG_HASH_INPUT "${_h}") endforeach() -string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}") -string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH) -message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})") +string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}") +string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH) +message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})") add_subdirectory(waylandcomp) add_subdirectory(questxr) diff --git a/app/src/main/cpp/framegen/VulkanRendererContext.h b/app/src/main/cpp/framegen/VulkanRendererContext.h index efbc00c68..7cbbcdf42 100644 --- a/app/src/main/cpp/framegen/VulkanRendererContext.h +++ b/app/src/main/cpp/framegen/VulkanRendererContext.h @@ -131,7 +131,7 @@ struct VkTable { #include namespace lsfg { class Engine; } -namespace winfg { class Engine; } +namespace ddfg { class Engine; } static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2; @@ -280,7 +280,7 @@ public: static constexpr int32_t kFgCaptureGame = -1; void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0); void setFrameGenEngine(int kind); - void setWinFgTuning(int model, int perfPreset); + void setDdFgTuning(int model, int perfPreset); private: struct WinTex { @@ -468,12 +468,12 @@ private: uint64_t fgSourceFrames_ = 0; std::string lsfgCachePath_; bool lsfgEngineTried_ = false; - std::unique_ptr winfgEngine_; - bool winfgEngineTried_ = false; + std::unique_ptr ddfgEngine_; + bool ddfgEngineTried_ = false; std::atomic fgEngineKind_{0}; std::atomic fgModel_{4}; std::atomic fgPerfPreset_{1}; - bool ensureWinFgEngine(); + bool ensureDdFgEngine(); bool fgCapsOk() const; uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; } diff --git a/app/src/main/cpp/framegen/winfg/LICENSE.win-fg b/app/src/main/cpp/framegen/ddfg/LICENSE.win-fg similarity index 100% rename from app/src/main/cpp/framegen/winfg/LICENSE.win-fg rename to app/src/main/cpp/framegen/ddfg/LICENSE.win-fg diff --git a/app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md b/app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md similarity index 100% rename from app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md rename to app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md diff --git a/app/src/main/cpp/framegen/winfg/config.hpp b/app/src/main/cpp/framegen/ddfg/config.hpp similarity index 78% rename from app/src/main/cpp/framegen/winfg/config.hpp rename to app/src/main/cpp/framegen/ddfg/config.hpp index a3bf09a5e..b488711b5 100644 --- a/app/src/main/cpp/framegen/winfg/config.hpp +++ b/app/src/main/cpp/framegen/ddfg/config.hpp @@ -6,7 +6,7 @@ #include #include -namespace winfg { +namespace ddfg { struct Config { bool enabled = false; @@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) { } static inline std::string conf_path() { - if (const char* p = std::getenv("WIN_FG_CONF")) return p; + if (const char* p = std::getenv("DD_FG_CONF")) return p; const char* home = std::getenv("HOME"); - return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string(); + return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string(); } static inline std::string capture_root(const Config& c) { if (!c.capDir.empty()) return c.capDir; - if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture"; - return std::string("/tmp/winfg-capture"); + if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture"; + return std::string("/tmp/ddfg-capture"); } static inline Config load_config() { Config c; - c.enabled = envi("WIN_FG_ENABLE", 0) != 0; - if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug); - if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing); - c.model = envi("WIN_FG_MODEL", c.model); - c.multiplier = envi("WIN_FG_MULT", c.multiplier); - c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages); - c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset); - if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode); - if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode); - c.frIters = envi("WIN_FG_FR_ITERS", c.frIters); - c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda); - c.frDt = envf("WIN_FG_FR_DT", c.frDt); - c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge); - c.frEps = envf("WIN_FG_FR_EPS", c.frEps); - c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale); - c.beta = envf("WIN_FG_BETA", c.beta); - c.lambda = envf("WIN_FG_LAMBDA", c.lambda); - c.epsilon = envf("WIN_FG_EPSILON", c.epsilon); - c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale); - if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture); - c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW); - c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH); - if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode); - c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches); - c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize); - c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion); - c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB); - if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd; - if (const char* h = std::getenv("WIN_FG_HUD_RECT")) { + c.enabled = envi("DD_FG_ENABLE", 0) != 0; + if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug); + if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing); + c.model = envi("DD_FG_MODEL", c.model); + c.multiplier = envi("DD_FG_MULT", c.multiplier); + c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages); + c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset); + if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode); + if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode); + c.frIters = envi("DD_FG_FR_ITERS", c.frIters); + c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda); + c.frDt = envf("DD_FG_FR_DT", c.frDt); + c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge); + c.frEps = envf("DD_FG_FR_EPS", c.frEps); + c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale); + c.beta = envf("DD_FG_BETA", c.beta); + c.lambda = envf("DD_FG_LAMBDA", c.lambda); + c.epsilon = envf("DD_FG_EPSILON", c.epsilon); + c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale); + if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture); + c.captureW = envi("DD_FG_CAPTURE_W", c.captureW); + c.captureH = envi("DD_FG_CAPTURE_H", c.captureH); + if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode); + c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches); + c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize); + c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion); + c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB); + if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd; + if (const char* h = std::getenv("DD_FG_HUD_RECT")) { float r[4] = {0,0,0,0}; int n = 0; std::string s = h; size_t start = 0; for (; n < 4; ++n) { @@ -266,8 +266,8 @@ static inline Config load_config() { c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3]; } const char* home = std::getenv("HOME"); - if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml"); - if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p); + if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml"); + if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p); c.sanitize(); return c; } diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp similarity index 91% rename from app/src/main/cpp/framegen/winfg/winfg_engine.cpp rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp index 965e15a15..6e87890b3 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp +++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp @@ -1,10 +1,10 @@ -#include "winfg_engine.h" -#include "winfg_vkd.h" +#include "ddfg_engine.h" +#include "ddfg_vkd.h" #include "log.hpp" #include #include -namespace winfg { +namespace ddfg { namespace { float alphaFor(uint32_t k, uint32_t n) { @@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); } bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev, uint32_t queueFamily, VkQueue queue) { - if (!winfgVkdInit(table, dd_, id_)) { - WFG_LOGE("native: dispatch incomplete; frame generation unavailable"); + if (!ddfgVkdInit(table, dd_, id_)) { + DDFG_LOGE("native: dispatch incomplete; frame generation unavailable"); return false; } phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue; id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_); if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) { - WFG_LOGE("native: FrameGen::init failed"); + DDFG_LOGE("native: FrameGen::init failed"); unavailable_ = true; return false; } - WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)"); + DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)"); return true; } @@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) { if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) { - WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h); + DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h); unavailable_ = true; return false; } @@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) { } builtW_ = w; builtH_ = h; builtFmt_ = fmt; history_ = 0; curr_ = 0; frames_ = 0; - WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f", + DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f", w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale); return true; } +static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot"); + uint32_t Engine::plan(uint32_t capacity) const { if (!valid() || history_ < 2) return 0; const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0); @@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite); - fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_); + fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_, + generation, generation > 0); if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots; (void)w; (void)h; diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.h b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h similarity index 99% rename from app/src/main/cpp/framegen/winfg/winfg_engine.h rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.h index 9a607f531..55c1ef8fa 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_engine.h +++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h @@ -6,7 +6,7 @@ struct VkTable; -namespace winfg { +namespace ddfg { constexpr uint32_t kMaxGenerations = 3; diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp similarity index 90% rename from app/src/main/cpp/framegen/winfg/winfg_vkd.cpp rename to app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp index a0c2dd441..ba8a7b3ad 100644 --- a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp +++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp @@ -1,14 +1,14 @@ -#include "winfg_vkd.h" +#include "ddfg_vkd.h" #include "../VulkanRendererContext.h" #include -bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) { +bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) { bool ok = true; #define COPY(dst, fn) \ do { \ dst.fn = table.fn; \ if (!dst.fn) { \ - __android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \ + __android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \ "entry point vk" #fn " did not resolve"); \ ok = false; \ } \ diff --git a/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h new file mode 100644 index 000000000..8c5b69404 --- /dev/null +++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h @@ -0,0 +1,4 @@ +#pragma once +#include "vk_dispatch.hpp" +struct VkTable; +bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id); diff --git a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp similarity index 82% rename from app/src/main/cpp/framegen/winfg/embedded_shaders.hpp rename to app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp index 20dd4477c..61f5c154c 100644 --- a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp +++ b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp @@ -1,7 +1,7 @@ #pragma once #include #include -namespace winfg { namespace embedded { +namespace ddfg { namespace embedded { static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x00000035u,0x00000037u,0x0003003eu,0x00000030u,0x00000038u,0x0004003du,0x0000003du,0x00000040u,0x0000003fu,0x0004003du,0x0000002eu,0x00000041u,0x00000030u,0x00070058u,0x00000043u,0x00000044u,0x00000040u,0x00000041u,0x00000002u,0x00000042u,0x0008004fu,0x00000039u,0x00000045u,0x00000044u,0x00000044u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000003bu,0x00000045u,0x0004003du,0x00000039u,0x00000048u,0x0000003bu,0x00050094u,0x00000013u,0x0000004du,0x00000048u,0x0000004cu,0x0003003eu,0x00000047u,0x0000004du,0x0004003du,0x00000014u,0x0000004eu,0x00000016u,0x0004003du,0x00000007u,0x0000004fu,0x00000009u,0x0004003du,0x00000013u,0x00000050u,0x00000047u,0x00070050u,0x00000043u,0x00000051u,0x00000050u,0x00000042u,0x00000042u,0x00000042u,0x00040063u,0x0000004eu,0x0000004fu,0x00000051u,0x000100fdu,0x00010038u}; static const size_t OF3_LUMA_LEN = 481; static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x00050067u,0x00000007u,0x00000038u,0x00000037u,0x00000036u,0x0004006fu,0x0000002eu,0x00000039u,0x00000038u,0x0003003eu,0x00000030u,0x00000039u,0x0004003du,0x00000007u,0x0000003bu,0x00000009u,0x0004006fu,0x0000002eu,0x0000003cu,0x0000003bu,0x0005008eu,0x0000002eu,0x0000003eu,0x0000003cu,0x0000003du,0x00050050u,0x0000002eu,0x00000040u,0x0000003fu,0x0000003fu,0x00050081u,0x0000002eu,0x00000041u,0x0000003eu,0x00000040u,0x0004003du,0x0000002eu,0x00000042u,0x00000030u,0x00050088u,0x0000002eu,0x00000043u,0x00000041u,0x00000042u,0x0003003eu,0x0000003au,0x00000043u,0x0004003du,0x0000002eu,0x00000046u,0x00000030u,0x00050050u,0x0000002eu,0x00000047u,0x00000045u,0x00000045u,0x00050088u,0x0000002eu,0x00000048u,0x00000047u,0x00000046u,0x0003003eu,0x00000044u,0x00000048u,0x0004003du,0x00000032u,0x0000004bu,0x00000034u,0x0004003du,0x0000002eu,0x0000004cu,0x0000003au,0x00050041u,0x00000049u,0x0000004du,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000004eu,0x0000004du,0x0004007fu,0x00000013u,0x0000004fu,0x0000004eu,0x00050041u,0x00000049u,0x00000050u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000051u,0x00000050u,0x0004007fu,0x00000013u,0x00000052u,0x00000051u,0x00050050u,0x0000002eu,0x00000053u,0x0000004fu,0x00000052u,0x00050081u,0x0000002eu,0x00000054u,0x0000004cu,0x00000053u,0x00070058u,0x00000056u,0x00000057u,0x0000004bu,0x00000054u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000058u,0x00000057u,0x00000000u,0x0003003eu,0x0000004au,0x00000058u,0x0004003du,0x00000032u,0x0000005au,0x00000034u,0x0004003du,0x0000002eu,0x0000005bu,0x0000003au,0x00050041u,0x00000049u,0x0000005cu,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000005du,0x0000005cu,0x00050041u,0x00000049u,0x0000005eu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000005fu,0x0000005eu,0x0004007fu,0x00000013u,0x00000060u,0x0000005fu,0x00050050u,0x0000002eu,0x00000061u,0x0000005du,0x00000060u,0x00050081u,0x0000002eu,0x00000062u,0x0000005bu,0x00000061u,0x00070058u,0x00000056u,0x00000063u,0x0000005au,0x00000062u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000064u,0x00000063u,0x00000000u,0x0003003eu,0x00000059u,0x00000064u,0x0004003du,0x00000032u,0x00000066u,0x00000034u,0x0004003du,0x0000002eu,0x00000067u,0x0000003au,0x00050041u,0x00000049u,0x00000068u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000069u,0x00000068u,0x0004007fu,0x00000013u,0x0000006au,0x00000069u,0x00050041u,0x00000049u,0x0000006bu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000006cu,0x0000006bu,0x00050050u,0x0000002eu,0x0000006du,0x0000006au,0x0000006cu,0x00050081u,0x0000002eu,0x0000006eu,0x00000067u,0x0000006du,0x00070058u,0x00000056u,0x0000006fu,0x00000066u,0x0000006eu,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000070u,0x0000006fu,0x00000000u,0x0003003eu,0x00000065u,0x00000070u,0x0004003du,0x00000032u,0x00000072u,0x00000034u,0x0004003du,0x0000002eu,0x00000073u,0x0000003au,0x00050041u,0x00000049u,0x00000074u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000075u,0x00000074u,0x00050041u,0x00000049u,0x00000076u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000077u,0x00000076u,0x00050050u,0x0000002eu,0x00000078u,0x00000075u,0x00000077u,0x00050081u,0x0000002eu,0x00000079u,0x00000073u,0x00000078u,0x00070058u,0x00000056u,0x0000007au,0x00000072u,0x00000079u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x0000007bu,0x0000007au,0x00000000u,0x0003003eu,0x00000071u,0x0000007bu,0x0004003du,0x00000014u,0x0000007cu,0x00000016u,0x0004003du,0x00000007u,0x0000007du,0x00000009u,0x0004003du,0x00000013u,0x0000007eu,0x0000004au,0x0004003du,0x00000013u,0x0000007fu,0x00000059u,0x00050081u,0x00000013u,0x00000080u,0x0000007eu,0x0000007fu,0x0004003du,0x00000013u,0x00000081u,0x00000065u,0x00050081u,0x00000013u,0x00000082u,0x00000080u,0x00000081u,0x0004003du,0x00000013u,0x00000083u,0x00000071u,0x00050081u,0x00000013u,0x00000084u,0x00000082u,0x00000083u,0x00050085u,0x00000013u,0x00000086u,0x00000084u,0x00000085u,0x00070050u,0x00000056u,0x00000087u,0x00000086u,0x00000055u,0x00000055u,0x00000055u,0x00040063u,0x0000007cu,0x0000007du,0x00000087u,0x000100fdu,0x00010038u}; @@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b static const size_t OF3_GM_PREWARP_LEN = 706; static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,0x00040015u,0x0000002fu,0x00000020u,0x00000000u,0x0004002bu,0x0000002fu,0x00000030u,0x00000000u,0x00040017u,0x00000035u,0x0000002fu,0x00000003u,0x00040020u,0x00000036u,0x00000001u,0x00000035u,0x0004003bu,0x00000036u,0x00000037u,0x00000001u,0x00040017u,0x00000038u,0x0000002fu,0x00000002u,0x00020014u,0x00000040u,0x00040020u,0x00000041u,0x00000007u,0x00000006u,0x0004002bu,0x0000002fu,0x0000004au,0x00000001u,0x00040020u,0x00000054u,0x00000007u,0x00000009u,0x0003001eu,0x00000056u,0x0000000au,0x00040020u,0x00000057u,0x00000002u,0x00000056u,0x0004003bu,0x00000057u,0x00000058u,0x00000002u,0x00040020u,0x00000059u,0x00000002u,0x00000009u,0x0004002bu,0x0000002fu,0x00000060u,0x00000002u,0x0004002bu,0x0000002fu,0x00000064u,0x00000003u,0x00040020u,0x00000067u,0x00000007u,0x0000000au,0x0004002bu,0x00000006u,0x00000070u,0xffffffffu,0x0005002cu,0x00000007u,0x00000071u,0x00000070u,0x0000001bu,0x0005002cu,0x00000007u,0x00000079u,0x0000001eu,0x0000001bu,0x0005002cu,0x00000007u,0x00000081u,0x0000001bu,0x00000070u,0x0005002cu,0x00000007u,0x00000089u,0x0000001bu,0x0000001eu,0x0004002bu,0x00000009u,0x000000b0u,0x3f000000u,0x00040017u,0x000000b7u,0x00000009u,0x00000002u,0x0004002bu,0x00000009u,0x000000c1u,0x3d4ccccdu,0x0004002bu,0x00000009u,0x000000c2u,0x3f800000u,0x0004003bu,0x00000017u,0x00000121u,0x00000000u,0x00090019u,0x0000013cu,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000002u,0x00040020u,0x0000013du,0x00000000u,0x0000013cu,0x0004003bu,0x0000013du,0x0000013eu,0x00000000u,0x0004002bu,0x0000002fu,0x00000142u,0x00000010u,0x0006002cu,0x00000035u,0x00000143u,0x00000142u,0x00000142u,0x0000004au,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000034u,0x00000007u,0x0004003bu,0x00000008u,0x0000003cu,0x00000007u,0x0004003bu,0x00000054u,0x00000055u,0x00000007u,0x0004003bu,0x00000054u,0x0000005cu,0x00000007u,0x0004003bu,0x00000054u,0x0000005fu,0x00000007u,0x0004003bu,0x00000054u,0x00000063u,0x00000007u,0x0004003bu,0x00000067u,0x00000068u,0x00000007u,0x0004003bu,0x00000008u,0x00000069u,0x00000007u,0x0004003bu,0x00000008u,0x0000006bu,0x00000007u,0x0004003bu,0x00000067u,0x0000006eu,0x00000007u,0x0004003bu,0x00000008u,0x00000073u,0x00000007u,0x0004003bu,0x00000008u,0x00000074u,0x00000007u,0x0004003bu,0x00000067u,0x00000077u,0x00000007u,0x0004003bu,0x00000008u,0x0000007bu,0x00000007u,0x0004003bu,0x00000008u,0x0000007cu,0x00000007u,0x0004003bu,0x00000067u,0x0000007fu,0x00000007u,0x0004003bu,0x00000008u,0x00000083u,0x00000007u,0x0004003bu,0x00000008u,0x00000084u,0x00000007u,0x0004003bu,0x00000067u,0x00000087u,0x00000007u,0x0004003bu,0x00000008u,0x0000008bu,0x00000007u,0x0004003bu,0x00000008u,0x0000008cu,0x00000007u,0x0004003bu,0x00000008u,0x0000008fu,0x00000007u,0x0004003bu,0x00000054u,0x00000093u,0x00000007u,0x0004003bu,0x00000008u,0x00000096u,0x00000007u,0x0004003bu,0x00000008u,0x00000097u,0x00000007u,0x0004003bu,0x00000054u,0x0000009au,0x00000007u,0x0004003bu,0x00000008u,0x0000009du,0x00000007u,0x0004003bu,0x00000008u,0x0000009eu,0x00000007u,0x0004003bu,0x00000054u,0x000000a1u,0x00000007u,0x0004003bu,0x00000008u,0x000000a4u,0x00000007u,0x0004003bu,0x00000008u,0x000000a5u,0x00000007u,0x0004003bu,0x00000054u,0x000000a8u,0x00000007u,0x0004003bu,0x00000008u,0x000000abu,0x00000007u,0x0004003bu,0x00000008u,0x000000acu,0x00000007u,0x0004003bu,0x00000054u,0x000000afu,0x00000007u,0x0004003bu,0x00000054u,0x000000bbu,0x00000007u,0x0004003bu,0x00000067u,0x000000c4u,0x00000007u,0x0004003bu,0x00000067u,0x000000c8u,0x00000007u,0x0004003bu,0x00000067u,0x000000ccu,0x00000007u,0x0004003bu,0x00000067u,0x000000d0u,0x00000007u,0x0004003bu,0x00000054u,0x000000d4u,0x00000007u,0x0004003bu,0x00000054u,0x000000d8u,0x00000007u,0x0004003bu,0x00000054u,0x000000e1u,0x00000007u,0x0004003bu,0x00000054u,0x000000eau,0x00000007u,0x0004003bu,0x00000054u,0x000000f3u,0x00000007u,0x0004003bu,0x00000054u,0x000000fcu,0x00000007u,0x0004003bu,0x00000067u,0x00000104u,0x00000007u,0x0004003bu,0x00000067u,0x00000114u,0x00000007u,0x0004003bu,0x00000067u,0x00000120u,0x00000007u,0x0004003bu,0x00000054u,0x0000012au,0x00000007u,0x0004003bu,0x00000067u,0x0000012eu,0x00000007u,0x0004003bu,0x00000067u,0x00000132u,0x00000007u,0x0004003du,0x00000035u,0x00000039u,0x00000037u,0x0007004fu,0x00000038u,0x0000003au,0x00000039u,0x00000039u,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x0000003bu,0x0000003au,0x0003003eu,0x00000034u,0x0000003bu,0x0004003du,0x00000016u,0x0000003du,0x00000018u,0x00040064u,0x00000015u,0x0000003eu,0x0000003du,0x00050067u,0x00000007u,0x0000003fu,0x0000003eu,0x0000001bu,0x0003003eu,0x0000003cu,0x0000003fu,0x00050041u,0x00000041u,0x00000042u,0x00000034u,0x00000030u,0x0004003du,0x00000006u,0x00000043u,0x00000042u,0x00050041u,0x00000041u,0x00000044u,0x0000003cu,0x00000030u,0x0004003du,0x00000006u,0x00000045u,0x00000044u,0x000500afu,0x00000040u,0x00000046u,0x00000043u,0x00000045u,0x000400a8u,0x00000040u,0x00000047u,0x00000046u,0x000300f7u,0x00000049u,0x00000000u,0x000400fau,0x00000047u,0x00000048u,0x00000049u,0x000200f8u,0x00000048u,0x00050041u,0x00000041u,0x0000004bu,0x00000034u,0x0000004au,0x0004003du,0x00000006u,0x0000004cu,0x0000004bu,0x00050041u,0x00000041u,0x0000004du,0x0000003cu,0x0000004au,0x0004003du,0x00000006u,0x0000004eu,0x0000004du,0x000500afu,0x00000040u,0x0000004fu,0x0000004cu,0x0000004eu,0x000200f9u,0x00000049u,0x000200f8u,0x00000049u,0x000700f5u,0x00000040u,0x00000050u,0x00000046u,0x00000005u,0x0000004fu,0x00000048u,0x000300f7u,0x00000052u,0x00000000u,0x000400fau,0x00000050u,0x00000051u,0x00000052u,0x000200f8u,0x00000051u,0x000100fdu,0x000200f8u,0x00000052u,0x00060041u,0x00000059u,0x0000005au,0x00000058u,0x0000001bu,0x00000030u,0x0004003du,0x00000009u,0x0000005bu,0x0000005au,0x0003003eu,0x00000055u,0x0000005bu,0x00060041u,0x00000059u,0x0000005du,0x00000058u,0x0000001bu,0x0000004au,0x0004003du,0x00000009u,0x0000005eu,0x0000005du,0x0003003eu,0x0000005cu,0x0000005eu,0x00060041u,0x00000059u,0x00000061u,0x00000058u,0x0000001bu,0x00000060u,0x0004003du,0x00000009u,0x00000062u,0x00000061u,0x0003003eu,0x0000005fu,0x00000062u,0x00060041u,0x00000059u,0x00000065u,0x00000058u,0x0000001bu,0x00000064u,0x0004003du,0x00000009u,0x00000066u,0x00000065u,0x0003003eu,0x00000063u,0x00000066u,0x0004003du,0x00000007u,0x0000006au,0x00000034u,0x0003003eu,0x00000069u,0x0000006au,0x0004003du,0x00000007u,0x0000006cu,0x0000003cu,0x0003003eu,0x0000006bu,0x0000006cu,0x00060039u,0x0000000au,0x0000006du,0x0000000eu,0x00000069u,0x0000006bu,0x0003003eu,0x00000068u,0x0000006du,0x0004003du,0x00000007u,0x0000006fu,0x00000034u,0x00050080u,0x00000007u,0x00000072u,0x0000006fu,0x00000071u,0x0003003eu,0x00000073u,0x00000072u,0x0004003du,0x00000007u,0x00000075u,0x0000003cu,0x0003003eu,0x00000074u,0x00000075u,0x00060039u,0x0000000au,0x00000076u,0x0000000eu,0x00000073u,0x00000074u,0x0003003eu,0x0000006eu,0x00000076u,0x0004003du,0x00000007u,0x00000078u,0x00000034u,0x00050080u,0x00000007u,0x0000007au,0x00000078u,0x00000079u,0x0003003eu,0x0000007bu,0x0000007au,0x0004003du,0x00000007u,0x0000007du,0x0000003cu,0x0003003eu,0x0000007cu,0x0000007du,0x00060039u,0x0000000au,0x0000007eu,0x0000000eu,0x0000007bu,0x0000007cu,0x0003003eu,0x00000077u,0x0000007eu,0x0004003du,0x00000007u,0x00000080u,0x00000034u,0x00050080u,0x00000007u,0x00000082u,0x00000080u,0x00000081u,0x0003003eu,0x00000083u,0x00000082u,0x0004003du,0x00000007u,0x00000085u,0x0000003cu,0x0003003eu,0x00000084u,0x00000085u,0x00060039u,0x0000000au,0x00000086u,0x0000000eu,0x00000083u,0x00000084u,0x0003003eu,0x0000007fu,0x00000086u,0x0004003du,0x00000007u,0x00000088u,0x00000034u,0x00050080u,0x00000007u,0x0000008au,0x00000088u,0x00000089u,0x0003003eu,0x0000008bu,0x0000008au,0x0004003du,0x00000007u,0x0000008du,0x0000003cu,0x0003003eu,0x0000008cu,0x0000008du,0x00060039u,0x0000000au,0x0000008eu,0x0000000eu,0x0000008bu,0x0000008cu,0x0003003eu,0x00000087u,0x0000008eu,0x0004003du,0x00000016u,0x00000090u,0x00000026u,0x00040064u,0x00000015u,0x00000091u,0x00000090u,0x00050067u,0x00000007u,0x00000092u,0x00000091u,0x0000001bu,0x0003003eu,0x0000008fu,0x00000092u,0x0004003du,0x00000007u,0x00000094u,0x00000034u,0x00050080u,0x00000007u,0x00000095u,0x00000094u,0x00000071u,0x0003003eu,0x00000096u,0x00000095u,0x0004003du,0x00000007u,0x00000098u,0x0000008fu,0x0003003eu,0x00000097u,0x00000098u,0x00060039u,0x00000009u,0x00000099u,0x00000013u,0x00000096u,0x00000097u,0x0003003eu,0x00000093u,0x00000099u,0x0004003du,0x00000007u,0x0000009bu,0x00000034u,0x00050080u,0x00000007u,0x0000009cu,0x0000009bu,0x00000079u,0x0003003eu,0x0000009du,0x0000009cu,0x0004003du,0x00000007u,0x0000009fu,0x0000008fu,0x0003003eu,0x0000009eu,0x0000009fu,0x00060039u,0x00000009u,0x000000a0u,0x00000013u,0x0000009du,0x0000009eu,0x0003003eu,0x0000009au,0x000000a0u,0x0004003du,0x00000007u,0x000000a2u,0x00000034u,0x00050080u,0x00000007u,0x000000a3u,0x000000a2u,0x00000081u,0x0003003eu,0x000000a4u,0x000000a3u,0x0004003du,0x00000007u,0x000000a6u,0x0000008fu,0x0003003eu,0x000000a5u,0x000000a6u,0x00060039u,0x00000009u,0x000000a7u,0x00000013u,0x000000a4u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a7u,0x0004003du,0x00000007u,0x000000a9u,0x00000034u,0x00050080u,0x00000007u,0x000000aau,0x000000a9u,0x00000089u,0x0003003eu,0x000000abu,0x000000aau,0x0004003du,0x00000007u,0x000000adu,0x0000008fu,0x0003003eu,0x000000acu,0x000000adu,0x00060039u,0x00000009u,0x000000aeu,0x00000013u,0x000000abu,0x000000acu,0x0003003eu,0x000000a8u,0x000000aeu,0x0004003du,0x00000009u,0x000000b1u,0x0000009au,0x0004003du,0x00000009u,0x000000b2u,0x00000093u,0x00050083u,0x00000009u,0x000000b3u,0x000000b1u,0x000000b2u,0x0004003du,0x00000009u,0x000000b4u,0x000000a8u,0x0004003du,0x00000009u,0x000000b5u,0x000000a1u,0x00050083u,0x00000009u,0x000000b6u,0x000000b4u,0x000000b5u,0x00050050u,0x000000b7u,0x000000b8u,0x000000b3u,0x000000b6u,0x0006000cu,0x00000009u,0x000000b9u,0x00000001u,0x00000042u,0x000000b8u,0x00050085u,0x00000009u,0x000000bau,0x000000b0u,0x000000b9u,0x0003003eu,0x000000afu,0x000000bau,0x0004003du,0x00000009u,0x000000bcu,0x0000005fu,0x0004007fu,0x00000009u,0x000000bdu,0x000000bcu,0x0004003du,0x00000009u,0x000000beu,0x000000afu,0x00050085u,0x00000009u,0x000000bfu,0x000000bdu,0x000000beu,0x0006000cu,0x00000009u,0x000000c0u,0x00000001u,0x0000001bu,0x000000bfu,0x0008000cu,0x00000009u,0x000000c3u,0x00000001u,0x0000002bu,0x000000c0u,0x000000c1u,0x000000c2u,0x0003003eu,0x000000bbu,0x000000c3u,0x0004003du,0x0000000au,0x000000c5u,0x0000006eu,0x0004003du,0x0000000au,0x000000c6u,0x00000068u,0x00050083u,0x0000000au,0x000000c7u,0x000000c5u,0x000000c6u,0x0003003eu,0x000000c4u,0x000000c7u,0x0004003du,0x0000000au,0x000000c9u,0x00000077u,0x0004003du,0x0000000au,0x000000cau,0x00000068u,0x00050083u,0x0000000au,0x000000cbu,0x000000c9u,0x000000cau,0x0003003eu,0x000000c8u,0x000000cbu,0x0004003du,0x0000000au,0x000000cdu,0x0000007fu,0x0004003du,0x0000000au,0x000000ceu,0x00000068u,0x00050083u,0x0000000au,0x000000cfu,0x000000cdu,0x000000ceu,0x0003003eu,0x000000ccu,0x000000cfu,0x0004003du,0x0000000au,0x000000d1u,0x00000087u,0x0004003du,0x0000000au,0x000000d2u,0x00000068u,0x00050083u,0x0000000au,0x000000d3u,0x000000d1u,0x000000d2u,0x0003003eu,0x000000d0u,0x000000d3u,0x0004003du,0x00000009u,0x000000d5u,0x00000063u,0x0004003du,0x00000009u,0x000000d6u,0x00000063u,0x00050085u,0x00000009u,0x000000d7u,0x000000d5u,0x000000d6u,0x0003003eu,0x000000d4u,0x000000d7u,0x0004003du,0x00000009u,0x000000d9u,0x000000bbu,0x0004003du,0x0000000au,0x000000dau,0x000000c4u,0x0004003du,0x0000000au,0x000000dbu,0x000000c4u,0x00050094u,0x00000009u,0x000000dcu,0x000000dau,0x000000dbu,0x0004003du,0x00000009u,0x000000ddu,0x000000d4u,0x00050081u,0x00000009u,0x000000deu,0x000000dcu,0x000000ddu,0x0006000cu,0x00000009u,0x000000dfu,0x00000001u,0x00000020u,0x000000deu,0x00050085u,0x00000009u,0x000000e0u,0x000000d9u,0x000000dfu,0x0003003eu,0x000000d8u,0x000000e0u,0x0004003du,0x00000009u,0x000000e2u,0x000000bbu,0x0004003du,0x0000000au,0x000000e3u,0x000000c8u,0x0004003du,0x0000000au,0x000000e4u,0x000000c8u,0x00050094u,0x00000009u,0x000000e5u,0x000000e3u,0x000000e4u,0x0004003du,0x00000009u,0x000000e6u,0x000000d4u,0x00050081u,0x00000009u,0x000000e7u,0x000000e5u,0x000000e6u,0x0006000cu,0x00000009u,0x000000e8u,0x00000001u,0x00000020u,0x000000e7u,0x00050085u,0x00000009u,0x000000e9u,0x000000e2u,0x000000e8u,0x0003003eu,0x000000e1u,0x000000e9u,0x0004003du,0x00000009u,0x000000ebu,0x000000bbu,0x0004003du,0x0000000au,0x000000ecu,0x000000ccu,0x0004003du,0x0000000au,0x000000edu,0x000000ccu,0x00050094u,0x00000009u,0x000000eeu,0x000000ecu,0x000000edu,0x0004003du,0x00000009u,0x000000efu,0x000000d4u,0x00050081u,0x00000009u,0x000000f0u,0x000000eeu,0x000000efu,0x0006000cu,0x00000009u,0x000000f1u,0x00000001u,0x00000020u,0x000000f0u,0x00050085u,0x00000009u,0x000000f2u,0x000000ebu,0x000000f1u,0x0003003eu,0x000000eau,0x000000f2u,0x0004003du,0x00000009u,0x000000f4u,0x000000bbu,0x0004003du,0x0000000au,0x000000f5u,0x000000d0u,0x0004003du,0x0000000au,0x000000f6u,0x000000d0u,0x00050094u,0x00000009u,0x000000f7u,0x000000f5u,0x000000f6u,0x0004003du,0x00000009u,0x000000f8u,0x000000d4u,0x00050081u,0x00000009u,0x000000f9u,0x000000f7u,0x000000f8u,0x0006000cu,0x00000009u,0x000000fau,0x00000001u,0x00000020u,0x000000f9u,0x00050085u,0x00000009u,0x000000fbu,0x000000f4u,0x000000fau,0x0003003eu,0x000000f3u,0x000000fbu,0x0004003du,0x00000009u,0x000000fdu,0x000000d8u,0x0004003du,0x00000009u,0x000000feu,0x000000e1u,0x00050081u,0x00000009u,0x000000ffu,0x000000fdu,0x000000feu,0x0004003du,0x00000009u,0x00000100u,0x000000eau,0x00050081u,0x00000009u,0x00000101u,0x000000ffu,0x00000100u,0x0004003du,0x00000009u,0x00000102u,0x000000f3u,0x00050081u,0x00000009u,0x00000103u,0x00000101u,0x00000102u,0x0003003eu,0x000000fcu,0x00000103u,0x0004003du,0x00000009u,0x00000105u,0x000000d8u,0x0004003du,0x0000000au,0x00000106u,0x0000006eu,0x0005008eu,0x0000000au,0x00000107u,0x00000106u,0x00000105u,0x0004003du,0x00000009u,0x00000108u,0x000000e1u,0x0004003du,0x0000000au,0x00000109u,0x00000077u,0x0005008eu,0x0000000au,0x0000010au,0x00000109u,0x00000108u,0x00050081u,0x0000000au,0x0000010bu,0x00000107u,0x0000010au,0x0004003du,0x00000009u,0x0000010cu,0x000000eau,0x0004003du,0x0000000au,0x0000010du,0x0000007fu,0x0005008eu,0x0000000au,0x0000010eu,0x0000010du,0x0000010cu,0x00050081u,0x0000000au,0x0000010fu,0x0000010bu,0x0000010eu,0x0004003du,0x00000009u,0x00000110u,0x000000f3u,0x0004003du,0x0000000au,0x00000111u,0x00000087u,0x0005008eu,0x0000000au,0x00000112u,0x00000111u,0x00000110u,0x00050081u,0x0000000au,0x00000113u,0x0000010fu,0x00000112u,0x0003003eu,0x00000104u,0x00000113u,0x0004003du,0x0000000au,0x00000115u,0x00000068u,0x0004003du,0x00000009u,0x00000116u,0x00000055u,0x0004003du,0x0000000au,0x00000117u,0x00000104u,0x0005008eu,0x0000000au,0x00000118u,0x00000117u,0x00000116u,0x00050081u,0x0000000au,0x00000119u,0x00000115u,0x00000118u,0x0004003du,0x00000009u,0x0000011au,0x00000055u,0x0004003du,0x00000009u,0x0000011bu,0x000000fcu,0x00050085u,0x00000009u,0x0000011cu,0x0000011au,0x0000011bu,0x00050081u,0x00000009u,0x0000011du,0x000000c2u,0x0000011cu,0x00070050u,0x0000000au,0x0000011eu,0x0000011du,0x0000011du,0x0000011du,0x0000011du,0x00050088u,0x0000000au,0x0000011fu,0x00000119u,0x0000011eu,0x0003003eu,0x00000114u,0x0000011fu,0x0004003du,0x00000016u,0x00000122u,0x00000121u,0x0004003du,0x00000007u,0x00000123u,0x00000034u,0x0004003du,0x00000007u,0x00000124u,0x0000003cu,0x00050050u,0x00000007u,0x00000125u,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000126u,0x00000124u,0x00000125u,0x0008000cu,0x00000007u,0x00000127u,0x00000001u,0x0000002du,0x00000123u,0x0000001cu,0x00000126u,0x00040064u,0x00000015u,0x00000128u,0x00000122u,0x0007005fu,0x0000000au,0x00000129u,0x00000128u,0x00000127u,0x00000002u,0x0000001bu,0x0003003eu,0x00000120u,0x00000129u,0x0004003du,0x00000009u,0x0000012bu,0x00000055u,0x0004003du,0x00000009u,0x0000012cu,0x0000005cu,0x00050085u,0x00000009u,0x0000012du,0x0000012bu,0x0000012cu,0x0003003eu,0x0000012au,0x0000012du,0x0004003du,0x0000000au,0x0000012fu,0x00000114u,0x0004003du,0x0000000au,0x00000130u,0x00000120u,0x00050083u,0x0000000au,0x00000131u,0x0000012fu,0x00000130u,0x0003003eu,0x0000012eu,0x00000131u,0x0004003du,0x0000000au,0x00000133u,0x00000114u,0x0004003du,0x0000000au,0x00000134u,0x0000012eu,0x0004003du,0x00000009u,0x00000135u,0x0000012au,0x0004007fu,0x00000009u,0x00000136u,0x00000135u,0x00070050u,0x0000000au,0x00000137u,0x00000136u,0x00000136u,0x00000136u,0x00000136u,0x0004003du,0x00000009u,0x00000138u,0x0000012au,0x00070050u,0x0000000au,0x00000139u,0x00000138u,0x00000138u,0x00000138u,0x00000138u,0x0008000cu,0x0000000au,0x0000013au,0x00000001u,0x0000002bu,0x00000134u,0x00000137u,0x00000139u,0x00050083u,0x0000000au,0x0000013bu,0x00000133u,0x0000013au,0x0003003eu,0x00000132u,0x0000013bu,0x0004003du,0x0000013cu,0x0000013fu,0x0000013eu,0x0004003du,0x00000007u,0x00000140u,0x00000034u,0x0004003du,0x0000000au,0x00000141u,0x00000132u,0x00040063u,0x0000013fu,0x00000140u,0x00000141u,0x000100fdu,0x00010038u,0x00050036u,0x0000000au,0x0000000eu,0x00000000u,0x0000000bu,0x00030037u,0x00000008u,0x0000000cu,0x00030037u,0x00000008u,0x0000000du,0x000200f8u,0x0000000fu,0x0004003du,0x00000016u,0x00000019u,0x00000018u,0x0004003du,0x00000007u,0x0000001au,0x0000000cu,0x0004003du,0x00000007u,0x0000001du,0x0000000du,0x00050050u,0x00000007u,0x0000001fu,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000020u,0x0000001du,0x0000001fu,0x0008000cu,0x00000007u,0x00000021u,0x00000001u,0x0000002du,0x0000001au,0x0000001cu,0x00000020u,0x00040064u,0x00000015u,0x00000022u,0x00000019u,0x0007005fu,0x0000000au,0x00000023u,0x00000022u,0x00000021u,0x00000002u,0x0000001bu,0x000200feu,0x00000023u,0x00010038u,0x00050036u,0x00000009u,0x00000013u,0x00000000u,0x00000010u,0x00030037u,0x00000008u,0x00000011u,0x00030037u,0x00000008u,0x00000012u,0x000200f8u,0x00000014u,0x0004003du,0x00000016u,0x00000027u,0x00000026u,0x0004003du,0x00000007u,0x00000028u,0x00000011u,0x0004003du,0x00000007u,0x00000029u,0x00000012u,0x00050050u,0x00000007u,0x0000002au,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x0000002bu,0x00000029u,0x0000002au,0x0008000cu,0x00000007u,0x0000002cu,0x00000001u,0x0000002du,0x00000028u,0x0000001cu,0x0000002bu,0x00040064u,0x00000015u,0x0000002du,0x00000027u,0x0007005fu,0x0000000au,0x0000002eu,0x0000002du,0x0000002cu,0x00000002u,0x0000001bu,0x00050051u,0x00000009u,0x00000031u,0x0000002eu,0x00000000u,0x000200feu,0x00000031u,0x00010038u}; static const size_t OF3_FLOWREG_LEN = 1936; -static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u}; -static const size_t WFG_SYNTH_LEN = 3082; +static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u}; +static const size_t DDFG_SYNTH_LEN = 3082; struct Blob { const uint32_t* code; size_t words; }; -enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT }; +enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT }; static inline Blob blob(Shader s) { switch (s) { case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN }; @@ -35,7 +35,7 @@ static inline Blob blob(Shader s) { case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN }; case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN }; case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN }; - case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN }; + case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN }; default: return { nullptr, 0 }; } } diff --git a/app/src/main/cpp/framegen/winfg/framegen.cpp b/app/src/main/cpp/framegen/ddfg/framegen.cpp similarity index 96% rename from app/src/main/cpp/framegen/winfg/framegen.cpp rename to app/src/main/cpp/framegen/ddfg/framegen.cpp index 4ddd12021..82d0a764b 100644 --- a/app/src/main/cpp/framegen/winfg/framegen.cpp +++ b/app/src/main/cpp/framegen/ddfg/framegen.cpp @@ -4,9 +4,9 @@ #include #include -namespace winfg { +namespace ddfg { -#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0) +#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0) uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const { for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i) @@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id, if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false; if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false; if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false; - if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false; - if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); } - WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)", + if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false; + if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); } + DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)", queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters); return true; } @@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) { if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false; if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false; ready_ = true; - WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)", + DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)", extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset); return true; } diff --git a/app/src/main/cpp/framegen/winfg/framegen.hpp b/app/src/main/cpp/framegen/ddfg/framegen.hpp similarity index 96% rename from app/src/main/cpp/framegen/winfg/framegen.hpp rename to app/src/main/cpp/framegen/ddfg/framegen.hpp index 8b32fd0c0..70719e796 100644 --- a/app/src/main/cpp/framegen/winfg/framegen.hpp +++ b/app/src/main/cpp/framegen/ddfg/framegen.hpp @@ -3,7 +3,7 @@ #include "config.hpp" #include -namespace winfg { +namespace ddfg { class FrameGen { public: @@ -12,7 +12,8 @@ public: void configure(const Config& c); bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false); void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView, - VkImageView outView, float alpha, uint32_t gmSlot = 0); + VkImageView outView, float alpha, uint32_t gmSlot = 0, + uint32_t synthSlot = 0, bool reuseFlow = false); void destroy(); bool valid() const { return device_ != VK_NULL_HANDLE && ready_; } const Config& config() const { return cfg_; } @@ -25,6 +26,7 @@ public: static constexpr int kGmSlots = 3; + static constexpr int kSynthSlots = 3; static constexpr int kGmLevel = 3; static constexpr int kGmThreads = 512; static constexpr int kGmStride = 32; diff --git a/app/src/main/cpp/framegen/ddfg/log.hpp b/app/src/main/cpp/framegen/ddfg/log.hpp new file mode 100644 index 000000000..b4c6c5a8d --- /dev/null +++ b/app/src/main/cpp/framegen/ddfg/log.hpp @@ -0,0 +1,12 @@ +#pragma once +#if defined(__ANDROID__) + #include + #define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__) + #define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__) +#else + #include + #define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) + #define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) +#endif + +#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0) diff --git a/app/src/main/cpp/framegen/winfg/record_impl.inc b/app/src/main/cpp/framegen/ddfg/record_impl.inc similarity index 65% rename from app/src/main/cpp/framegen/winfg/record_impl.inc rename to app/src/main/cpp/framegen/ddfg/record_impl.inc index 2e8fba009..554c05c7f 100644 --- a/app/src/main/cpp/framegen/winfg/record_impl.inc +++ b/app/src/main/cpp/framegen/ddfg/record_impl.inc @@ -1,31 +1,34 @@ namespace { struct ScratchState { - VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr; + VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{}; + VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{}; + void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{}; std::vector flowUbo; std::vector flowUboMem; std::vector flowUboPtr; VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr; VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr; - VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{}; - VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{}; - void* gmUboPtr[winfg::FrameGen::kGmSlots]{}; + VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{}; + VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{}; + void* gmUboPtr[ddfg::FrameGen::kGmSlots]{}; VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE; std::vector sDownA, sDownB, sFlow; - VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{}; + VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{}; VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE; - VkDescriptorSet sSynth = VK_NULL_HANDLE; - VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{}; - std::vector sGmPrewarp[winfg::FrameGen::kGmSlots]; + VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{}; + VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{}; + std::vector sGmPrewarp[ddfg::FrameGen::kGmSlots]; VkExtent2D builtFor{0,0}; bool built = false; bool flowInited = false; + bool flowReady = false; }; ScratchState g_scratch; } -static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev, +static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, const VkPhysicalDeviceMemoryProperties& mp, VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) { VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; @@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev, return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS; } -static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev, +static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorPool pool, VkDescriptorSetLayout layout) { VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout; VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s; } -static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) { VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = ⅈ dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr); } -static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkBuffer buf, VkDeviceSize range) { VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; @@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi; dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr); } -static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, +static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding, VkBuffer buf, VkDeviceSize range) { VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range; VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; @@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3]) out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0; } -namespace winfg { +namespace ddfg { static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool, @@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr; }; auto& S = g_scratch; - freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]); freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr); freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr); for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]); @@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) { int want = flowFinestForPreset(cfg_.perfPreset); const int maxFinest = kLevels - 2; if (want > maxFinest) { - WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist " + DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist " "(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest); want = maxFinest; } @@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) { const int prevFinest = flowFinest_; flowFinest_ = want; const bool live = (flowFinest_ != prevFinest) && ready_; - WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d", + DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d", cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0); if (!live) return; @@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) { dd_->DeviceWaitIdle(device_); destroyScratch(dd_, device_, descPool_); if (!onResize(extent_, VK_FORMAT_UNDEFINED, true)) - WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize", + DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize", extent_.width, extent_.height); for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0; for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; } gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f; - WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_); + DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_); } bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) { @@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) { } void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView, - VkImageView outView, float alpha, uint32_t gmSlot) { + VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) { if (!valid()) return; if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0; + if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0; const bool m4 = (cfg_.model == 4); const Pipe& flowP = m4 ? pFlowM4_ : pFlow_; const Pipe& expandP = m4 ? pExpandM4_ : pExpand_; @@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_); } - bool gmEngaged = updateGlobalMotion(gmSlot, extent_); - if (g_scratch.gmUboPtr[gmSlot]) { - GMUBO g{}; - g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3]; - g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f; - if (gmEngaged) { - g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3]; - g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5]; + // Every generation of one real frame is recorded into the same command buffer, so the flow + // between prev and curr is computed once and each generation gets its own synth UBO and set. + if (reuseFlow && !g_scratch.flowReady) return; + if (!reuseFlow) { + bool gmEngaged = updateGlobalMotion(gmSlot, extent_); + if (g_scratch.gmUboPtr[gmSlot]) { + GMUBO g{}; + g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3]; + g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f; + if (gmEngaged) { + g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3]; + g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5]; + } + std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g)); } - std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g)); - } - { - static int lastEng = -1; - if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) { - WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)", - gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model); - lastEng = gmEngaged ? 1 : 0; + { + static int lastEng = -1; + if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) { + DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)", + gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model); + lastEng = gmEngaged ? 1 : 0; + } } + + wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); } SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0, cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1}; - if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su)); + if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su)); - wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL); + const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot]; + wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL); auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) { dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline); @@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur }; auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); }; - for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); - { - VkClearColorValue zero{}; - VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (!reuseFlow) { + for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL); + { + VkClearColorValue zero{}; + VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + for (int l = flowFinest_; l < kLevels; ++l) { + VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + b.image = flowLvl_[l].image; b.subresourceRange = rng; + b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng); + b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL; + b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + } + } + + disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0)); + disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0)); + bar(pyrA_[0]); bar(pyrB_[0]); + for (int l = 1; l < kLevels; ++l) { + disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l)); + disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l)); + bar(pyrA_[l]); bar(pyrB_[l]); + } + if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) { + dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline); + dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr); + dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1); + VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT, + 0, 1, &mb, 0, nullptr, 0, nullptr); + } for (int l = flowFinest_; l < kLevels; ++l) { - VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; - b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - b.image = flowLvl_[l].image; b.subresourceRange = rng; - b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); - dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng); - b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL; - b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b); + disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l)); + bar(pyrAs_[l]); + } + for (int l = kLevels - 1; l >= flowFinest_; --l) { + disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l)); + bar(flowLvl_[l]); } - } - disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0)); - disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0)); - bar(pyrA_[0]); bar(pyrB_[0]); - for (int l = 1; l < kLevels; ++l) { - disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l)); - disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l)); - bar(pyrA_[l]); bar(pyrB_[l]); - } - if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) { - dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline); - dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr); - dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1); - VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT, - 0, 1, &mb, 0, nullptr, 0, nullptr); - } - for (int l = flowFinest_; l < kLevels; ++l) { - disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l)); - bar(pyrAs_[l]); - } - for (int l = kLevels - 1; l >= flowFinest_; --l) { - disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l)); - bar(flowLvl_[l]); - } + const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0); + if (frEngaged) { + const int F = flowFinest_; + const VkExtent2D fe = levelExtent(extent_, F); + if (g_scratch.flowRegUboPtr) { + FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps}; + std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu)); + } + FrameGen::Img* last = nullptr; + for (int it = 0; it < cfg_.frIters; ++it) { + VkDescriptorSet set; FrameGen::Img* outImg; + if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; } + else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; } + else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; } + dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline); + dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr); + dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1); + bar(*outImg); + last = outImg; + } + VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 1, &mb, 0, nullptr, 0, nullptr); + VkImageCopy region{}; + region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.extent = {fe.width, fe.height, 1}; + dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL, + flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion); + VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + 0, 1, &mb2, 0, nullptr, 0, nullptr); + } + { + static int lastFr = -1; + int fe = frEngaged ? 1 : 0; + if (fe != lastFr || (gmFrames_ % 300ull) == 0) { + DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)", + fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model); + lastFr = fe; + } + } - const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0); - if (frEngaged) { - const int F = flowFinest_; - const VkExtent2D fe = levelExtent(extent_, F); - if (g_scratch.flowRegUboPtr) { - FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps}; - std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu)); - } - FrameGen::Img* last = nullptr; - for (int it = 0; it < cfg_.frIters; ++it) { - VkDescriptorSet set; FrameGen::Img* outImg; - if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; } - else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; } - else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; } - dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline); - dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr); - dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1); - bar(*outImg); - last = outImg; - } - VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; - mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, - 0, 1, &mb, 0, nullptr, 0, nullptr); - VkImageCopy region{}; - region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.extent = {fe.width, fe.height, 1}; - dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL, - flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion); - VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - 0, 1, &mb2, 0, nullptr, 0, nullptr); + disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0)); + bar(flowExpA_); bar(flowExpB_); + g_scratch.flowReady = true; } - { - static int lastFr = -1; - int fe = frEngaged ? 1 : 0; - if (fe != lastFr || (gmFrames_ % 300ull) == 0) { - WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)", - fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model); - lastFr = fe; - } - } - - disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0)); - bar(flowExpA_); bar(flowExpB_); - disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0)); + disp(pSynth_, synthSet, levelExtent(extent_, 0)); } static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, @@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, S = ScratchState{}; const auto GEN = VK_IMAGE_LAYOUT_GENERAL; - makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) + makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]); makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr); if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); } makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr); @@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev, wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN); } - S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout); - wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO)); - wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN); - wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN); + for (int s = 0; s < FrameGen::kSynthSlots; ++s) { + S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout); + wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO)); + wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN); + wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN); + } S.builtFor = ext; S.built = true; return true; diff --git a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp similarity index 99% rename from app/src/main/cpp/framegen/winfg/vk_dispatch.hpp rename to app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp index ef259250b..7d61ba165 100644 --- a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp +++ b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp @@ -3,7 +3,7 @@ #include #include -namespace winfg { +namespace ddfg { static inline void* dispatch_key(void* handle) { return *reinterpret_cast(handle); } diff --git a/app/src/main/cpp/framegen/winfg/log.hpp b/app/src/main/cpp/framegen/winfg/log.hpp deleted file mode 100644 index 056f136f4..000000000 --- a/app/src/main/cpp/framegen/winfg/log.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#if defined(__ANDROID__) - #include - #define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__) - #define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__) -#else - #include - #define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) - #define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0) -#endif - -#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0) diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.h b/app/src/main/cpp/framegen/winfg/winfg_vkd.h deleted file mode 100644 index 8faffc826..000000000 --- a/app/src/main/cpp/framegen/winfg/winfg_vkd.h +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once -#include "vk_dispatch.hpp" -struct VkTable; -bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id); diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 7f6e0f7d9..33585818b 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE ) set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) -set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg) +set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg) if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp - AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp) - message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)") + AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp) + message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)") target_sources(droiddeckwayland PRIVATE src/framegen_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp @@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp - ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp + ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp ) target_include_directories(droiddeckwayland PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/.. @@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp target_compile_options(droiddeckwayland PRIVATE $<$:-std=c++17 -fvisibility=hidden>) target_compile_definitions(droiddeckwayland PRIVATE - WINFG_UPSTREAM="${WINFG_UPSTREAM}" - WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}" + DDFG_UPSTREAM="${DDFG_UPSTREAM}" + DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}" ) target_link_libraries(droiddeckwayland dxbc atomic) else() diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 379087045..94bb581a0 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -86,7 +86,7 @@ static int64_t now_ns(void) { void vkp_framegen_set_engine(int kind) { - if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG; + if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG; atomic_store(&g_kind, kind); atomic_store(&g_failed_kind, -1); atomic_store(&g_cfg_dirty, 1); @@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) { atomic_store(&g_cfg_dirty, 1); } -void vkp_framegen_set_winfg_tuning(int model, int perf_preset) { +void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) { atomic_store(&g_model, model == 3 ? 3 : 4); atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset); atomic_store(&g_cfg_dirty, 1); @@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops); fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled); g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier; - FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s", + FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s", fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(), - fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable"); + fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable"); } void vkp_framegen_device_lost(void) { g_dev_lost = 1; } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index 5e27db40d..573d6fd3c 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -6,14 +6,14 @@ #endif #include -enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 }; +enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 }; #define VKP_FG_MAX_GENERATIONS 3 void vkp_framegen_set_engine(int kind); void vkp_framegen_set_armed(int armed, int multiplier, int target_fps); void vkp_framegen_set_lsfg_cache_path(const char *path); void vkp_framegen_set_tuning(float flow_scale, float refresh_hz); -void vkp_framegen_set_winfg_tuning(int model, int perf_preset); +void vkp_framegen_set_ddfg_tuning(int model, int perf_preset); int vkp_framegen_problem(void); const char *vkp_framegen_caps_reason(void); void vkp_framegen_stats(float out[6]); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 43edb1240..8558ae3d3 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -12,7 +12,7 @@ #include "lsfg/lsfg_engine.h" #include "lsfg/lsfg_probe.h" #include "lsfg/lsfg_vkd.h" -#include "winfg/winfg_engine.h" +#include "ddfg/ddfg_engine.h" #define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__) #define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__) @@ -30,7 +30,7 @@ uint32_t g_qfam = 0; bool g_probed = false, g_ready = false; std::unique_ptr g_lsfg; -std::unique_ptr g_winfg; +std::unique_ptr g_ddfg; int g_kind = -1; char g_build[96] = ""; @@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) { fge_stop(); if (!g_ready) return -1; if (kind == 1) { - auto e = std::make_unique(); + auto e = std::make_unique(); if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) { - ELOGE("winfg-native: engine init failed"); + ELOGE("ddfg-native: engine init failed"); return -1; } - g_winfg = std::move(e); + g_ddfg = std::move(e); g_kind = 1; -#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH) - std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH); +#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH) + std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH); #else std::snprintf(g_build, sizeof(g_build), "embedded chain"); #endif @@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) { } void fge_stop(void) { - if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev); + if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev); g_lsfg.reset(); - g_winfg.reset(); + g_ddfg.reset(); g_kind = -1; } int fge_unavailable(void) { - if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0; + if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0; return g_lsfg && g_lsfg->unavailable() ? 1 : 0; } void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model, int perf_preset) { if (multiplier < 2) multiplier = 2; - if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale); + if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale); else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz); } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { - if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0; + if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0; if (!g_lsfg) return 0; g_lsfg->setGuestExtent(w, h); return g_lsfg->prepare(w, h, fmt) ? 1 : 0; } uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) { - if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0; + if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0; if (!g_lsfg) return 0; g_lsfg->setPresentedRate(presented_rate); return g_lsfg->plan(capacity, source_frames); } void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) { - if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; } + if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; } if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens); } void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view, uint32_t w, uint32_t h) { - if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; } + if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; } if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h); } @@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) { } } -const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; } +const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; } const char *fge_build_info(void) { return g_build; } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index b9c4dff36..310e10c34 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -34,5 +34,5 @@ void fge_forget_targets(void) {} void fge_telemetry(float *accepted, float *source_rate, float *thermal) { *accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f; } -const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; } +const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; } const char *fge_build_info(void) { return "stub"; } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c index ea5ce0396..35c5a3de4 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c @@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN } JNIEXPORT void JNICALL -Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz, +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz, jint model, jint perfPreset) { (void)env; (void)clazz; - vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset); + vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset); } JNIEXPORT jint JNICALL diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 952ada616..4282ecf6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor object FrameGen { const val ENGINE_OFF = "off" - const val ENGINE_WINFG = "winfg" + const val ENGINE_DDFG = "ddfg" + private const val LEGACY_ENGINE_WINFG = "winfg" const val ENGINE_LSFG = "lsfg" - private const val WINFG_PERF_PRESET = 2 - private const val WINFG_MODEL = 4 - private const val FLOW_SCALE_WINFG = 0.60f + private const val DDFG_PERF_PRESET = 2 + private const val DDFG_MODEL = 4 + private const val FLOW_SCALE_DDFG = 0.60f private const val FLOW_SCALE_LSFG = 0.80f val ADAPTIVE_TARGETS = listOf(120, 90, 60) @@ -29,7 +30,7 @@ object FrameGen { fun mode(context: Context): Mode { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF + val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: ENGINE_OFF } val target = prefs.getInt("target", 0) if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target) return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4)) @@ -43,7 +44,7 @@ object FrameGen { fun label(context: Context): String = label(context, mode(context)) fun label(context: Context, mode: Mode): String = when { - mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier) + mode.engine == ENGINE_DDFG -> context.getString(R.string.frame_gen_ddfg, mode.multiplier) mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target) mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier) else -> context.getString(R.string.frame_gen_off) @@ -68,10 +69,10 @@ object FrameGen { WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz) WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target) } - ENGINE_WINFG -> { - WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG) - WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET) - WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz) + ENGINE_DDFG -> { + WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG) + WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET) + WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz) WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0) } else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index 042396b9f..55ab3f7fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -77,7 +77,7 @@ class PerfHud(context: Context) { val mode = FrameGen.mode(context) if (mode.engine == FrameGen.ENGINE_OFF) return fps(base) val lsfg = mode.engine == FrameGen.ENGINE_LSFG - val name = if (lsfg) "LSFG" else "Win-FG" + val name = if (lsfg) "LSFG" else "DD-FG" val label = FrameGen.label(context, mode) if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name) val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt index 816177bc1..bac30bd3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -27,7 +27,7 @@ internal fun FrameGenMenu( onPick(m); host.open = null } item(FrameGen.Mode.OFF, focus = firstItemFocus) - for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m)) + for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_DDFG, m)) val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } + FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) } lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index e15d80998..48d9b3576 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -290,7 +290,7 @@ public final class WaylandCompositor { public static native void nativeSendSceneInput(int type, int a, int b); - public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_WINFG = 1; + public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_DDFG = 1; public static native void nativeSetFrameGenEngine(int kind); @@ -300,7 +300,7 @@ public final class WaylandCompositor { public static native void nativeSetFrameGenTuning(float flowScale, float refreshHz); - public static native void nativeSetWinFgTuning(int model, int perfPreset); + public static native void nativeSetDdFgTuning(int model, int perfPreset); public static native int nativeFrameGenProblem(); public static native String nativeFrameGenCapsReason(); diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e496994a4..24209cf92 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -30,7 +30,7 @@ توليد الإطارات اختر وضع توليد الإطارات إيقاف - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG تكيّفي %1$d يتطلب Lossless Scaling على Steam diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index fab4a066f..58c46e41f 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -30,7 +30,7 @@ Generación de fotogramas Elige el modo de generación de fotogramas Desactivada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptativo %1$d Requiere Lossless Scaling en Steam diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 5e375bca2..910c6a994 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -30,7 +30,7 @@ Генериране на кадри Изберете режима на генериране на кадри Изключено - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Адаптивно %1$d Изисква Lossless Scaling в Steam diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b4d674c32..8b13a6ccd 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -30,7 +30,7 @@ Generování snímků Vyberte režim generování snímků Vypnuto - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptivní %1$d Vyžaduje Lossless Scaling na Steamu diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3b2bc8979..e5eebc3df 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -30,7 +30,7 @@ Billedgenerering Vælg metode til billedgenerering Fra - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Kræver Lossless Scaling på Steam diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3a4c61b17..27564b23d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -30,7 +30,7 @@ Frame-Generierung Modus der Frame-Generierung auswählen Aus - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Benötigt Lossless Scaling auf Steam diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4f4bfaa44..70790dc77 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -30,7 +30,7 @@ Δημιουργία καρέ Επιλέξτε τη λειτουργία δημιουργίας καρέ Ανενεργό - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG προσαρμοστικό %1$d Απαιτεί το Lossless Scaling στο Steam diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 09660256e..0d3475efe 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -30,7 +30,7 @@ Generación de fotogramas Elige el modo de generación de fotogramas Desactivada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptativo %1$d Requiere Lossless Scaling en Steam diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 88d810a69..c207b4e4c 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -30,7 +30,7 @@ Kehysten generointi Valitse kehysten generoinnin tila Pois - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Mukautuva %1$d Vaatii Lossless Scalingin Steamista diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9393da87c..49d302080 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -30,7 +30,7 @@ Génération d\'images Sélectionnez le mode de génération d\'images à utiliser Désactivé - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptatif %1$d Nécessite Lossless Scaling sur Steam diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 91fae22a1..b17009905 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -30,7 +30,7 @@ फ़्रेम जनरेशन फ़्रेम जनरेशन मोड चुनें बंद - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG एडैप्टिव %1$d Steam पर Lossless Scaling की ज़रूरत है diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 48809c616..a0dd71c18 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -30,7 +30,7 @@ Képkocka-generálás Válassza ki a képkocka-generálás módját Ki - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptív %1$d A Lossless Scaling szükséges a Steamen diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 85abaeec8..61be2c5f1 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -30,7 +30,7 @@ Pembuatan frame Pilih mode pembuatan frame Nonaktif - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptif %1$d Memerlukan Lossless Scaling di Steam diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 19fe2f598..98567796a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,7 +30,7 @@ Generazione di frame Seleziona la modalità di generazione di frame Disattivata - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adattivo %1$d Richiede Lossless Scaling su Steam diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c39625cd3..9da8b70b6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -30,7 +30,7 @@ フレーム生成 使用するフレーム生成モードを選択してください オフ - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG アダプティブ %1$d Steam版のLossless Scalingが必要です diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8e2508ebe..0227531a5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -30,7 +30,7 @@ 프레임 생성 사용할 프레임 생성 모드를 선택하세요 끔 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 적응형 %1$d Steam에서 Lossless Scaling이 필요합니다 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 537bd5421..19c8a6d57 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -30,7 +30,7 @@ Penjanaan bingkai Pilih mod penjanaan bingkai Mati - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptif %1$d Memerlukan Lossless Scaling pada Steam diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 977db283c..26f514ce6 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -30,7 +30,7 @@ Bildegenerering Velg modus for bildegenerering Av - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Krever Lossless Scaling på Steam diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f841a1878..6d38c5395 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -30,7 +30,7 @@ Framegeneratie Kies de modus voor framegeneratie Uit - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptief %1$d Vereist Lossless Scaling op Steam diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d89698468..7886e8f0d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -30,7 +30,7 @@ Generowanie klatek Wybierz tryb generowania klatek Wył. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptacyjne %1$d Wymaga Lossless Scaling ze Steam diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 55d8d0108..35f2b5577 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -30,7 +30,7 @@ Geração de fotogramas Selecione o modo de geração de fotogramas Desligado - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptativo %1$d Requer o Lossless Scaling no Steam diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index dd27961db..0672620b8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -30,7 +30,7 @@ Geração de quadros Selecione o modo de geração de quadros Desativada - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptativo %1$d Requer o Lossless Scaling na Steam diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 637ffea5e..329865e8f 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -30,7 +30,7 @@ Generare de cadre Selectează modul de generare a cadrelor Dezactivat - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptiv %1$d Necesită Lossless Scaling pe Steam diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b5a52740a..e25310312 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -30,7 +30,7 @@ Генерация кадров Выберите режим генерации кадров Выкл. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Адаптивный %1$d Требуется Lossless Scaling из Steam diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6e97abd03..0cf9b426c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -30,7 +30,7 @@ Bildrutegenerering Välj läge för bildrutegenerering Av - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG adaptiv %1$d Kräver Lossless Scaling på Steam diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 24a52fc9e..8bdf43b37 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -30,7 +30,7 @@ การสร้างเฟรม เลือกโหมดการสร้างเฟรม ปิด - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG แบบปรับอัตโนมัติ %1$d ต้องมี Lossless Scaling บน Steam diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 424db34ea..a81213aef 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -30,7 +30,7 @@ Kare oluşturma Kare oluşturma modunu seçin Kapalı - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Uyarlanabilir %1$d Steam\'de Lossless Scaling gerekli diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2d79d0a94..2af43de09 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Генерація кадрів Виберіть режим генерації кадрів Вимк. - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG адаптивний %1$d Потрібен Lossless Scaling зі Steam diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index bf2c67811..70026208a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -30,7 +30,7 @@ Tạo khung hình Chọn chế độ tạo khung hình Tắt - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG thích ứng %1$d Cần Lossless Scaling trên Steam diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 206e30f1d..7de45e1b8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -30,7 +30,7 @@ 帧生成 选择帧生成模式 关闭 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自适应 %1$d 需要 Steam 上的 Lossless Scaling diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 16f54ea4e..66207ea8d 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -30,7 +30,7 @@ 幀生成 選擇幀生成模式 關閉 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自適應 %1$d 需要 Steam 上的 Lossless Scaling diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 95e5dd915..7fdd8c25b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -30,7 +30,7 @@ 影格生成 請選擇你要使用的影格生成模式 關閉 - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG 自適應 %1$d 需要 Steam 上的 Lossless Scaling diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 603099d9f..021c3217c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,7 +34,7 @@ Frame generation Select the frame generation mode Off - Win-FG %1$d× + DD-FG %1$d× LSFG %1$d× LSFG Adaptive %1$d Needs Lossless Scaling on Steam diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt index fe3c9773d..f4c3b79ea 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt @@ -36,7 +36,13 @@ class FrameGenTest { @Test fun onlyLsfgIsAdaptive() { context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() - .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit() - assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context)) + .putString("engine", FrameGen.ENGINE_DDFG).putInt("multiplier", 4).putInt("target", 60).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 4), FrameGen.mode(context)) + } + + @Test fun aSavedWinFgChoiceBecomesDdFg() { + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() + .putString("engine", "winfg").putInt("multiplier", 2).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 2), FrameGen.mode(context)) } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt index e636bee70..81ace45da 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt @@ -34,7 +34,7 @@ class SettingsResetTest { SessionPrefs.setGuestHostname(app, "MyDeck") ControllerPrefs.setOpacity(app, 40) AppUiPrefs.setScale(app, 150) - FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_WINFG, 3)) + FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_DDFG, 3)) prefs("steam_touch").edit().putBoolean("gyro", false).commit() assertTrue(SettingsReset.resetAll(app, gameProfiles = false)) From a789d60f6ab5aa83ff2082fec5c7b1a1b52f9ce9 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:31:23 -0400 Subject: [PATCH 020/156] fix: register SteamVR only during a DroidDeck VR session (#1137) --- .../overlay/usr/local/bin/droiddeck-session | 1 + .../overlay/usr/local/bin/droiddeck-steamvr | 23 ++++++++++- tools/tests/test_steamvr_helper.py | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 1566a1ba7..fdadc61ed 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -129,6 +129,7 @@ export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" rm -f "${XDG_RUNTIME_DIR:-/run/droiddeck}/droiddeck-steamvr/active" +/usr/local/bin/droiddeck-steamvr unregister >/dev/null 2>&1 || true if [ "$mode" = lxqt ] || [ "$mode" = desktop ]; then /usr/local/bin/droiddeck-desktop-bookmarks || true fi diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr index 242bd169e..1de7cb89e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr @@ -41,6 +41,22 @@ configure_graphics() { esac } +# Any OpenXR probe (Steam runs one through Proton at every client start) starts vrserver +# while a runtime is registered, so SteamVR is registered only during a DroidDeck VR session. +unregister() { + if grep -qF "\"$steamvr\"" "$HOME/.config/openvr/openvrpaths.vrpath" 2>/dev/null; then + rm -f "$HOME/.config/openvr/openvrpaths.vrpath" + fi + case "$(readlink "$HOME/.config/openxr/1/active_runtime.json" 2>/dev/null)" in + "$steamvr"/*) rm -f "$HOME/.config/openxr/1/active_runtime.json" ;; + esac +} + +idle() { + [ ! -f "$state/active" ] && ! running && ! pgrep -x vrstartup >/dev/null 2>&1 && + ( flock -n 9 ) 9>"$state/start.lock" +} + stop() { rm -f "$state/active" for n in vrcompositor vrserver vrmonitor vrdashboard vrstartup vrwebhelper; do @@ -56,6 +72,7 @@ stop() { [ "$attempts" -lt 20 ] || { echo 'SteamVR did not stop; preferences remain backed up' >&2; return 1; } sleep 0.1 done + unregister quest_settings restore } @@ -74,6 +91,7 @@ status) i=0; installed && i=1 r=0; running && r=1 if [ "$r" = 0 ] && [ -n "$(find "$state/active" -mmin +2 2>/dev/null)" ]; then rm -f "$state/active"; fi + if idle; then unregister; fi n=0; [ -f "$state/installing" ] && n=1 d=0; [ -f "$driver/bin/linuxarm64/driver_droiddeck.so" ] && [ -f "$layer/libVkLayer_droiddeck_vr.so" ] && d=1 echo "installed=$i running=$r installing=$n driver=$d" @@ -100,6 +118,9 @@ install) stop) stop || exit 1 ;; +unregister) + if idle; then unregister; fi + ;; start) exec 9>"$state/start.lock" flock -n 9 || { echo "SteamVR start already in progress"; exit 0; } @@ -234,7 +255,7 @@ JSON echo "SteamVR starting; logs in $logs and $state/vrstartup.log" ;; *) - echo "usage: droiddeck-steamvr status|install|start|stop|launch" >&2 + echo "usage: droiddeck-steamvr status|install|start|stop|launch|unregister" >&2 exit 2 ;; esac diff --git a/tools/tests/test_steamvr_helper.py b/tools/tests/test_steamvr_helper.py index 3fa3e8225..f99f5cbdc 100644 --- a/tools/tests/test_steamvr_helper.py +++ b/tools/tests/test_steamvr_helper.py @@ -210,6 +210,44 @@ class SteamVrHelperTest(unittest.TestCase): def test_flat_presenter_does_not_enable_the_producer_lease(self): self.assertIn('present=unset lease=unset', self.lease_log()) + def registration(self): + return (self.home / '.config/openvr/openvrpaths.vrpath', + self.home / '.config/openxr/1/active_runtime.json') + + def helper_action(self, action): + subprocess.run(['sh', str(self.helper), action], env=self.environment, check=True, + capture_output=True, timeout=5) + + def test_runtime_is_registered_only_while_a_session_is_active(self): + vrpath, xr = self.registration() + self.start() + self.assertTrue(vrpath.is_file()) + self.assertTrue(xr.is_symlink()) + self.helper_action('status') + self.assertTrue(vrpath.is_file()) + self.helper_action('stop') + self.assertFalse(vrpath.exists()) + self.assertFalse(xr.is_symlink()) + + def test_idle_status_and_session_start_clear_a_leftover_registration(self): + vrpath, xr = self.registration() + for action in ('status', 'unregister'): + self.start() + (self.runtime / 'droiddeck-steamvr/active').unlink() + self.helper_action(action) + self.assertFalse(vrpath.exists(), action) + self.assertFalse(xr.is_symlink(), action) + + def test_unregister_leaves_another_runtime_alone(self): + vrpath, xr = self.registration() + vrpath.parent.mkdir(parents=True) + xr.parent.mkdir(parents=True) + vrpath.write_text('{"runtime": ["/opt/other-runtime"]}') + xr.symlink_to('/opt/monado/openxr_monado.json') + self.helper_action('unregister') + self.assertTrue(vrpath.is_file()) + self.assertTrue(xr.is_symlink()) + if __name__ == '__main__': unittest.main() From 7c20d4b5c3cf38504d4ea310cef3ed878273a69f Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:18:26 -0400 Subject: [PATCH 021/156] Fix direct-touch right-click and Steam Touch mode restoration (#677) --- .../com/droiddeck/launcher/SessionActivity.kt | 12 ++++ .../launcher/input/DirectTouchHold.kt | 53 ++++++++++++++ .../launcher/input/DirectTouchHoldTest.kt | 66 ++++++++++++++++++ .../launcher/input/SessionTouchRoutingTest.kt | 69 +++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9f145ab57..8ad1af889 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -75,6 +75,7 @@ import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PadMouse import com.droiddeck.launcher.input.PadState import com.droiddeck.launcher.input.SteamControllerBle +import com.droiddeck.launcher.input.DirectTouchHold import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplays @@ -188,6 +189,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var watching = true private lateinit var touchpad: TouchpadGestures + private lateinit var directTouchHold: DirectTouchHold private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap private lateinit var scrollTouch: ScrollTouchGestures private lateinit var padMouse: PadMouse @@ -483,6 +485,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) } keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) + directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) { + WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + } threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2) padMouse = PadMouse(padMouseListener) bridge.setTouchRoute(object : PadBridge.TouchRoute { @@ -1296,6 +1301,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val nextTouchMode = SessionPrefs.touchMode(this) if (touchMode != nextTouchMode) { if (::touchpad.isInitialized) touchpad.cancel() + if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) @@ -2286,6 +2292,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (next == routedTouchMode) return if (::touchpad.isInitialized) touchpad.cancel() + if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0) if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false @@ -2353,6 +2360,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false return true } + if (directTouchHold.onTouch(event)) return true fun sendTouch(action: Int, index: Int) { val x = event.getX(index).coerceIn(rect.left, rect.right) val y = event.getY(index).coerceIn(rect.top, rect.bottom) @@ -2502,6 +2510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { controls.visibility = View.GONE steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false + applyTouchRouting() return } val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM @@ -2514,6 +2523,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (steam == null) { steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false + applyTouchRouting() } controls.setButtonsOnly(buttonsOnly) val show = when (forced) { @@ -2576,6 +2586,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releasePads() touchpad.cancel() scrollTouch.cancel() + directTouchHold.cancel() guestKeysDown.toList().forEach { sendGuestKey(it, false) } keyboard?.hide() sessionClipboard.stop() @@ -2702,6 +2713,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } sessionClipboard.stop() + if (::directTouchHold.isInitialized) directTouchHold.cancel() drawerOpen = false pcKeyboardOpen = false achievementsOpen = false diff --git a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt new file mode 100644 index 000000000..318941c71 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.input + +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import kotlin.math.abs + +class DirectTouchHold( + private val slopPx: Float, + private val listener: PointerGestures.Listener, + private val cancelTouch: () -> Unit, +) { + private val handler = Handler(Looper.getMainLooper()) + private var x = 0f + private var y = 0f + private var consumed = false + private var pressed = false + private val release = Runnable { + if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y) + pressed = false + } + private val hold = Runnable { + cancelTouch() + consumed = true + listener.onLongPress() + listener.onButton(PointerGestures.BTN_RIGHT, true, x, y) + pressed = true + handler.postDelayed(release, 50L) + } + + fun cancel() { + handler.removeCallbacks(hold) + handler.removeCallbacks(release) + release.run() + consumed = false + } + + fun onTouch(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + cancel() + x = event.x; y = event.y + handler.postDelayed(hold, 500L) + } + MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 || + abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold) + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> handler.removeCallbacks(hold) + MotionEvent.ACTION_CANCEL -> cancel() + } + return consumed + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt new file mode 100644 index 000000000..d7dd5307a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.view.MotionEvent +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 = [28], manifest = Config.NONE) +class DirectTouchHoldTest { + private val events = mutableListOf() + private val hold = DirectTouchHold(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { + assertEquals(PointerGestures.BTN_RIGHT, button) + events += "$pressed at $x,$y" + } + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) { events += "cancel touch" } + + @Test fun holdReleasesRawTouchBeforeClickAndConsumesTheLift() { + assertFalse(touch(MotionEvent.ACTION_DOWN)) + advance(500) + assertEquals(listOf("cancel touch", "true at 40.0,30.0"), events) + assertTrue(touch(MotionEvent.ACTION_UP)) + advance(50) + assertEquals("false at 40.0,30.0", events.last()) + assertFalse(touch(MotionEvent.ACTION_DOWN)) + hold.cancel() + } + + @Test fun tapDragSecondFingerAndCancellationDoNotRightClick() { + for (action in listOf(MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_CANCEL)) { + touch(MotionEvent.ACTION_DOWN) + assertFalse(touch(action, 60f)) + advance(600) + assertTrue(events.isEmpty()) + hold.cancel() + } + } + + @Test fun modeChangeCancelsPendingHoldAndReleasesAnActiveClick() { + touch(MotionEvent.ACTION_DOWN) + hold.cancel() + advance(600) + assertTrue(events.isEmpty()) + touch(MotionEvent.ACTION_DOWN) + advance(500) + hold.cancel() + advance(100) + assertEquals(listOf("cancel touch", "true at 40.0,30.0", "false at 40.0,30.0"), events) + } + + private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) + private fun touch(action: Int, x: Float = 40f): Boolean { + val event = MotionEvent.obtain(0, 0, action, x, 30f, 0) + return hold.onTouch(event).also { event.recycle() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt new file mode 100644 index 000000000..610f7499e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.input + +import android.view.View +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SessionTouchRoutingTest { + private lateinit var activity: SessionActivity + private lateinit var steam: SteamTouchControls + private lateinit var touchpad: TouchpadGestures + private val directory = Files.createTempDirectory("touch-routing").toFile() + + @Before fun setUp() { + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + activity.getSharedPreferences("session", 0).edit().clear().commit() + SessionState.mode = SessionService.MODE_STEAM + SteamTouchDevice.prepare(directory) + steam = SteamTouchControls(activity, SteamTouchDevice.current!!, {}, {}) + steam.visibility = View.VISIBLE + touchpad = TouchpadGestures(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {} + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) + field("onScreenControls", OnScreenControls(activity, null).apply { visibility = View.GONE }) + field("steamTouchControls", steam) + field("steamTouchOptions", SteamTouchConfig.Options(inputMode = SteamTouchConfig.INPUT_MOUSE, mouseMode = SteamTouchConfig.MOUSE_ABSOLUTE, trackpadSensitivity = 2f)) + field("touchpad", touchpad) + field("routedTouchMode", SessionPrefs.TOUCH_DIRECT) + } + + @After fun tearDown() { + SteamTouchDevice.release() + directory.deleteRecursively() + SessionState.mode = SessionService.MODE_STEAM + } + + @Test fun disablingSteamTouchRestoresEverySavedModeEvenWhenRegularControlsAreAlreadyHidden() { + for (mode in listOf(SessionPrefs.TOUCH_PAD, SessionPrefs.TOUCH_DIRECT, SessionPrefs.TOUCH_AUTO, SessionPrefs.TOUCH_OFF)) { + SessionPrefs.setTouchMode(activity, mode) + SessionPrefs.setOscMode(activity, SessionPrefs.OSC_NEVER) + steam.visibility = View.VISIBLE + invoke("readPrefs") + assertEquals(SessionPrefs.TOUCH_DIRECT, ReflectionHelpers.getField(activity, "routedTouchMode")) + invoke("updateOnScreenControls") + assertEquals(View.GONE, steam.visibility) + assertEquals(mode, ReflectionHelpers.getField(activity, "routedTouchMode")) + assertEquals(1.15f, touchpad.sensitivity, 0f) + } + } + + private fun field(name: String, value: Any) = ReflectionHelpers.setField(activity, name, value) + private fun invoke(name: String) = ReflectionHelpers.callInstanceMethod(activity, name) +} From 2ea86b4abb5ba57e8d79d4cf1c93d2efde7dcb41 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:18:52 -0400 Subject: [PATCH 022/156] fix: run x86 Proton under FEX in the Steam runtime it requires (#1034) --- .../overlay/usr/local/bin/droiddeck-fex | 82 +++++++++++++------ .../overlay/usr/local/bin/steam-compatibility | 31 ++++--- tools/tests/test_droiddeck_fex.py | 53 +++++++++++- 3 files changed, 127 insertions(+), 39 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 0de377ef0..a8627ca3a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -30,6 +30,8 @@ FEX_APPID = "3127680" SNIPER_APPID = "1628350" SLR4_APPID = "4183110" INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper", SLR4_APPID: "SteamLinuxRuntime_4"} +RUNTIMES = {SNIPER_APPID: ("sniper_platform_", "Steam Linux Runtime 3.0 (sniper"), + SLR4_APPID: ("steamrt4_platform_", "Steam Linux Runtime 4.0 (steamrt4")} MARKER = ".droiddeck-fex" LAYOUT = 1 ROOTFS_LAYOUT = 1 @@ -174,15 +176,15 @@ def platform_version(path): return tuple(int(part) for part in re.findall(r"\d+", os.path.basename(path))) -def runtime_platform(appid=SNIPER_APPID, prefix="sniper_platform_"): - runtime = app_dir(appid) +def runtime_platform(app=SNIPER_APPID): + runtime = app_dir(app) if not runtime: return None try: entries = os.listdir(runtime) except OSError: return None - platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(prefix)] + platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(RUNTIMES[app][0])] platforms = [path for path in platforms if os.path.isdir(os.path.join(path, "files")) and os.path.isfile(os.path.join(path, "usr-mtree.txt.gz"))] return max(platforms, key=platform_version, default=None) @@ -245,7 +247,7 @@ def materialize(platform): def graphics_platform(): - return runtime_platform(SLR4_APPID, "steamrt4_platform_") + return runtime_platform(SLR4_APPID) def provides_newer(base, provider): @@ -366,6 +368,24 @@ def link_steam_sdk(): break +def link_guest_sdk(root): + # Proton's lsteamclient only looks in $HOME/.steam/sdk64; on ARM Steam that is the arm64 client, so + # the x86 runtime gets its own link, which FEX resolves before the host path. + for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): + want = "x86_64" if sdk == "sdk64" else "i386" + directory = next((os.path.join(STEAM_ROOT, candidate) for candidate in candidates + if elf_arch(os.path.join(STEAM_ROOT, candidate, "steamclient.so")) == want), None) + if not directory: + continue + link = os.path.join(root, HOME.lstrip("/"), ".steam", sdk) + if os.path.islink(link) and os.readlink(link) == directory: + continue + os.makedirs(os.path.dirname(link), exist_ok=True) + if os.path.lexists(link): + os.unlink(link) + os.symlink(directory, link) + + def steam_running(): for pid in os.listdir("/proc"): if not pid.isdigit(): @@ -380,13 +400,15 @@ def steam_running(): return False -def request_install(): +def request_install(app=SNIPER_APPID): os.makedirs(os.path.dirname(WANTED), exist_ok=True) write_if_changed(WANTED, "1\n") if not steam_running(): return [] try: compat = runpy.run_path(os.path.join(BIN_DIR, "steam-compatibility")) + if app != SNIPER_APPID and app_dir(app) is None: + return compat["request_app"](STEAM_ROOT, app) return compat["request_fex"](STEAM_ROOT, set()) except Exception as error: say("could not ask the client to install FEX (%s)" % error) @@ -406,10 +428,10 @@ def describe(fex, platform, graphics=None): } -def prepare(): +def prepare(app=SNIPER_APPID): with locked(): fex = fex_install() - platform = runtime_platform() + platform = runtime_platform(app) status = describe(fex, platform) if not status["ready"]: missing = [] @@ -422,8 +444,8 @@ def prepare(): elif not fex["server"]: missing.append("FEXServer beside %s" % fex["bin"]) if not platform: - missing.append("Steam Linux Runtime 3.0 (sniper, Steam app %s)" % SNIPER_APPID) - requested = request_install() + missing.append("%s, Steam app %s)" % (RUNTIMES[app][1], app)) + requested = request_install(app) say("x86 Linux programs need %s; %s" % (" and ".join(missing), "the Steam client was asked to install it" if requested else "open Steam once and it is installed for you")) write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") @@ -432,20 +454,24 @@ def prepare(): if materialize(platform): say("prepared %s as the x86 runtime" % os.path.basename(platform)) install_preloads(root) - provider = graphics_platform() - if provider and materialize(provider): - say("prepared %s for the x86 runtime's C and C++ libraries" % os.path.basename(provider)) - if not provider: - say("Steam Linux Runtime 4.0 (Steam app %s) is not installed yet; x86 games may not find their graphics drivers" - % SLR4_APPID) - elif not provides_newer(platform, provider): - say("%s has no newer C and C++ libraries than %s" % (os.path.basename(provider), os.path.basename(platform))) - provider = None - else: - if build_rootfs(platform, provider): - say("built the x86 runtime from %s with the C and C++ libraries of %s" - % (os.path.basename(platform), os.path.basename(provider))) - root = os.path.join(STATE, "rootfs") + provider = None + if app == SNIPER_APPID: + provider = graphics_platform() + if provider and materialize(provider): + say("prepared %s for the x86 runtime's C and C++ libraries" % os.path.basename(provider)) + if not provider: + say("Steam Linux Runtime 4.0 (Steam app %s) is not installed yet; x86 games may not find their graphics drivers" + % SLR4_APPID) + elif not provides_newer(platform, provider): + say("%s has no newer C and C++ libraries than %s" % (os.path.basename(provider), os.path.basename(platform))) + provider = None + else: + if build_rootfs(platform, provider): + say("built the x86 runtime from %s with the C and C++ libraries of %s" + % (os.path.basename(platform), os.path.basename(provider))) + root = os.path.join(STATE, "rootfs") + with contextlib.suppress(OSError): + link_guest_sdk(root) write_config(fex, root) fex["rootfs"] = root try: @@ -530,6 +556,8 @@ def guest_environment(fex, env): result["STEAM_COMPAT_DATA_PATH"] = short result.setdefault("FEX_SILENTLOG", "0") result.setdefault("FEX_OUTPUTLOG", "stderr") + # The session's tunables are for the host libc; an x86 ld.so copying them under FEX fails in sbrk. + result.pop("GLIBC_TUNABLES", None) # Mono's incremental Boehm collector corrupts its heap under FEX, so Unity games crash at their first collections. result.setdefault("GC_DISABLE_INCREMENTAL", "1") if fex["portable"]: @@ -594,6 +622,12 @@ def vulkan_thunk_loads(fex, folder): return True +def required_runtime(program): + text = read_text(os.path.join(os.path.dirname(program), "toolmanifest.vdf")) or "" + match = re.search(r'"require_tool_appid"\s+"(\d+)"', text) + return match.group(1) if match and match.group(1) in RUNTIMES else SNIPER_APPID + + def resolve(program, env): if "/" in program: return os.path.abspath(program) @@ -605,7 +639,7 @@ def launch(command, env, mode, decide=None): use = mode == "on" or (mode == "auto" and needs_fex(decide or program)) if not use: return os.execvpe(command[0], command, env) - fex = prepare() + fex = prepare(required_runtime(program)) if fex is None: return 126 guest = guest_environment(fex, env) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index fb84fd94d..242d21723 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -901,24 +901,33 @@ def fex_wanted_marker(): return os.path.join(os.environ.get("HOME", "/root"), ".config", "droiddeck", "fex-wanted") -def request_fex(steam_root, asked): +def request_app(steam_root, app): import subprocess + client = os.path.join(steam_root, "steamrtarm64", "steam") + if not os.access(client, os.X_OK): + return [] + try: + process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + return [] + try: + process.wait(timeout=REQUEST_HANDOFF_SECONDS) + except subprocess.TimeoutExpired: + pass + return [app] + + +def request_fex(steam_root, asked): client = os.path.join(steam_root, "steamrtarm64", "steam") missing = fex_missing(steam_root) if not os.access(client, os.X_OK) or any(app in asked for app in missing): return [] for app in missing: asked.add(app) - try: - process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True) - except OSError: - continue - try: - process.wait(timeout=REQUEST_HANDOFF_SECONDS) - except subprocess.TimeoutExpired: - pass - return [app] + requested = request_app(steam_root, app) + if requested: + return requested return [] diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index 0af8e7f4f..79a8a38bf 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -69,9 +69,10 @@ class FexTestCase(unittest.TestCase): self.write(Path(library or self.steam) / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }') return tool - def runtime(self, version="3.0.20260805.254768", lines=None): - runtime = self.steam / "steamapps/common/SteamLinuxRuntime_sniper" - platform = runtime / ("sniper_platform_" + version) + def runtime(self, version="3.0.20260805.254768", lines=None, app="1628350", folder="SteamLinuxRuntime_sniper", + prefix="sniper_platform_"): + runtime = self.steam / "steamapps/common" / folder + platform = runtime / (prefix + version) files = platform / "files" self.write(files / "lib/x86_64-linux-gnu/libc.so.6", elf(62)) self.write(files / "ab/cdef-1.bin", b"blob") @@ -89,9 +90,16 @@ class FexTestCase(unittest.TestCase): ] with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out: out.write("\n".join(mtree) + "\n") - self.write(self.steam / "steamapps/appmanifest_1628350.acf", '"AppState" { "installdir" "SteamLinuxRuntime_sniper" }') + self.write(self.steam / ("steamapps/appmanifest_%s.acf" % app), '"AppState" { "installdir" "%s" }' % folder) return platform + def steamrt4(self): + return self.runtime("4.0.20260928.262390", app="4183110", folder="SteamLinuxRuntime_4", prefix="steamrt4_platform_") + + def proton(self, require): + proton = self.steam / "steamapps/common/Proton - Experimental" + self.write(proton / "toolmanifest.vdf", '"manifest"\n{\n "commandline" "/proton %%verb%%"\n "require_tool_appid" "%s"\n}\n' % require) + return self.write(proton / "proton", "#!/usr/bin/env python3\n", 0o755) def graphics(self, sniper, libc=b"GLIBC_2.2.5 GLIBC_2.31 GLIBC_2.34 GLIBC_2.41", libstdcxx=b"GLIBCXX_3.4.28 GLIBCXX_3.4.33"): files = Path(sniper) / "files" x86 = files / "lib/x86_64-linux-gnu" @@ -272,6 +280,29 @@ class PrepareTest(FexTestCase): status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) self.assertFalse(status["ready"]) + def test_a_missing_steam_runtime_4_is_named(self): + self.fex_tool() + self.runtime() + with mock.patch.dict(G, {"steam_running": lambda: False}): + fex, said = self.quiet(FEX["prepare"], "4183110") + self.assertIsNone(fex) + self.assertIn("Steam Linux Runtime 4.0 (steamrt4, Steam app 4183110)", said) + self.assertNotIn("FEX (Steam app", said) + + def test_the_x86_runtime_gets_its_own_steam_client_link(self): + self.fex_tool() + platform = self.runtime() + self.write(self.steam / "linuxarm64/steamclient.so", elf(183)) + self.write(self.steam / "linux64/steamclient.so", elf(62)) + self.write(self.steam / "linux32/steamclient.so", elf(3)) + (self.home / ".steam").mkdir() + os.symlink(self.steam / "linuxarm64", self.home / ".steam/sdk64") + self.quiet(FEX["prepare"]) + guest = platform / "files" / str(self.home).lstrip("/") / ".steam" + self.assertEqual(os.readlink(guest / "sdk64"), str(self.steam / "linux64")) + self.assertEqual(os.readlink(guest / "sdk32"), str(self.steam / "linux32")) + self.assertEqual(os.readlink(self.home / ".steam/sdk64"), str(self.steam / "linux64")) + def test_a_ready_install_is_prepared(self): tool = self.fex_tool() platform = self.runtime() @@ -528,6 +559,20 @@ class LaunchTest(FexTestCase): thunks = json.loads((self.home / ".local/share/droiddeck-fex/config/ThunksConfig.json").read_text()) self.assertEqual(thunks, {"ThunksDB": {"GL": 1, "Vulkan": 1}}) + def test_host_libc_tunables_stay_on_the_host(self): + guest = FEX["guest_environment"]({"portable": True}, {"GLIBC_TUNABLES": "glibc.pthread.rseq=0"}) + self.assertNotIn("GLIBC_TUNABLES", guest) + + def test_proton_runs_in_the_runtime_its_manifest_requires(self): + self.fex_tool() + self.runtime() + platform = self.steamrt4() + proton = self.proton("4183110") + with mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(proton), "waitforexitandrun", "game.exe"], {}, "on") + self.assertEqual(emulated.call_args[0][2]["FEX_ROOTFS"], str(platform / "files")) + self.assertEqual(FEX["required_runtime"](str(self.proton("1391110"))), "1628350") + def test_native_programs_run_directly(self): program = self.write(self.tmp / "native", elf(183), 0o755) with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated: From adcb04f62476c3036294c2f2f1db0acd88a10c97 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:19:10 -0400 Subject: [PATCH 023/156] fix: skip DirectAudio for Steam install scripts (#1046) --- .../overlay/usr/local/bin/steam-compatibility | 15 ++++++- tools/tests/test_game_environment.py | 42 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 242d21723..67a33b424 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -243,6 +243,13 @@ bl_vacdiag() { """ BL_DIRECTAUDIO_SETUP = """ +bl_is_steam_installscript_target() { + local target=${1:-} + local steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam} + local helper="$steam_root/legacycompat/iscriptevaluator.exe" + [ -f "$helper" ] && [[ -n "$target" && "$target" -ef "$helper" ]] +} + bl_directaudio() { case "${verb:-}" in run|waitforexitandrun|runinprefix) ;; @@ -251,6 +258,10 @@ bl_directaudio() { base=$1 [ -n "${BL_DIRECTAUDIO:-}" ] || return 0 [ -d "$BL_DIRECTAUDIO" ] || return 0 + if bl_is_steam_installscript_target "${2:-}"; then + echo "droiddeck-proton: DirectAudio skipped for Steam's install-script evaluator" >&2 + return 0 + fi version=$("$base/files/bin-arm64/wine" --version 2>/dev/null) major=${version#wine-} major=${major%%.*} @@ -338,7 +349,7 @@ for name in %s; do done if [ -n "$depot" ]; then bl_components "$depot" - bl_directaudio "$depot" + bl_directaudio "$depot" "$@" bl_game_cpus bl_idtech3 "$@" bl_vacdiag "$@" @@ -393,7 +404,7 @@ if [ -n "${PROOT_FP_KEY:-}" ] && [ -f "$audit" ]; then esac fi bl_components "$here" -bl_directaudio "$here" +bl_directaudio "$here" "$@" bl_game_cpus bl_idtech3 "$@" bl_vacdiag "$@" diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 9c8a98c20..6757d46d6 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -434,6 +434,8 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(changes, {"42": COMPAT["TOOL"]}) def test_both_proton_wrappers_call_environment_launcher(self): + self.assertIn('bl_directaudio "$depot" "$@"', COMPAT["LAUNCHER_SH"]) + self.assertIn('bl_directaudio "$here" "$@"', COMPAT["EXTRA_WRAPPER_SH"] % "proton") for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): subprocess.run(["bash", "-n"], input=script, text=True, check=True) self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', script) @@ -849,12 +851,15 @@ class DirectAudioPrefixTest(unittest.TestCase): (self.windows / folder).mkdir(parents=True) self.reg = self.compat / "pfx/user.reg" - def run_setup(self, verb="waitforexitandrun"): - script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n' - env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat)) + def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None): + script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n' + steam = self.compat.parent / "Steam" + env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat), + STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam)) env.pop("WINEDLLPATH", None) env.pop("WINE_AUDIO_DRIVER", None) - return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb], env=env, capture_output=True, text=True, check=True) + return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb, target], cwd=cwd, env=env, + capture_output=True, text=True, check=True) def audio_values(self): found, current = [], None @@ -869,6 +874,35 @@ class DirectAudioPrefixTest(unittest.TestCase): path = self.windows / folder / "winedirectaudio.drv" return os.readlink(path) if path.is_symlink() else None + def test_steam_installscript_evaluator_does_not_change_prefix_audio(self): + legacycompat = self.compat.parent / "Steam/legacycompat" + legacycompat.mkdir(parents=True) + (legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator") + self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n") + + result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat) + + self.assertIn("DirectAudio skipped for Steam's install-script evaluator", result.stderr) + self.assertIn("WINEDLLPATH=\n", result.stdout) + self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout) + self.assertEqual(self.reg.read_text(), "WINE REGISTRY Version 2\n\n#arch=win64\n") + self.assertIsNone(self.link("system32")) + self.assertIsNone(self.link("syswow64")) + + def test_same_basename_outside_steam_legacycompat_keeps_normal_setup(self): + legacycompat = self.compat.parent / "Steam/legacycompat" + legacycompat.mkdir(parents=True) + (legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator") + other = self.compat.parent / "other" + other.mkdir() + (other / "iscriptevaluator.exe").write_bytes(b"unrelated executable") + self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n") + + result = self.run_setup(target="iscriptevaluator.exe", cwd=other) + + self.assertIn("DirectAudio selected", result.stderr) + self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv")) + self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')]) def test_non_launch_verbs_preserve_audio_configuration(self): for verb in ("getcompatpath", "getnativepath", "destroyprefix"): with self.subTest(verb=verb): From aa6fae192496be37f95b8210bfb91968ec783452 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:52:30 -0400 Subject: [PATCH 024/156] Fix intermittent CI package updates and escaped test worker (#1151) Fix escaped cloud-save test worker and CI package updates --- .github/workflows/build.yml | 3 ++- .../java/com/droiddeck/launcher/stores/CloudSaves.kt | 12 ++++++++---- .../com/droiddeck/launcher/stores/CloudSavesTest.kt | 7 +++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b1af9a2d..25ef8cc09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -192,7 +192,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - sudo apt-get update -qq + sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \ + -o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null D=app/src/main/assets/linuxfs mkdir -p "$D" diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index ccfd221ac..7c00981b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -13,6 +13,7 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL import java.security.MessageDigest +import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import org.json.JSONObject @@ -150,19 +151,22 @@ object CloudSaves { fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true) fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true) - fun downloadOrDefer(context: Context, store: Store, id: String): Result { + fun downloadOrDefer(context: Context, store: Store, id: String): Result = + downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() }) + + internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result { val app = context.applicationContext val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed")) if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off")) if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id) - Thread({ + executor.execute { val end = System.currentTimeMillis() + PREFIX_WAIT_MS while (System.currentTimeMillis() < end) { - if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread } + if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute } Thread.sleep(2000) } done(store, id, "down", Result("skipped", 0, 0, "no-prefix")) - }, "cloud-deferred").start() + } return done(store, id, "down", Result("deferred", 0, 0, "no-prefix")) } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt index 023013ed5..0c3044743 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.stores import java.io.File +import java.util.concurrent.Executor import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -11,7 +12,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) +@Config(sdk = [33], application = android.app.Application::class) class CloudSavesTest { private val app get() = RuntimeEnvironment.getApplication() @@ -83,9 +84,11 @@ class CloudSavesTest { } @Test fun aFirstLaunchWithoutAPrefixIsDeferredNotBlocked() { + val deferred = ArrayList() val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "ELDERBORN").apply { mkdirs() } File(folder, "ELDERBORN.exe").writeText("MZ") StoreGameSidecar(Store.GOG, "1207664643", "ELDERBORN", "ELDERBORN.exe").write(folder) - assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643").result) + assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643", Executor { deferred += it }).result) + assertEquals(1, deferred.size) } } From 8e02904a049281deab6af1f3fe366d854615672b Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:43:03 -0400 Subject: [PATCH 025/156] fix: adding a games folder on an SD card found nothing (#1153) Adding a folder on an SD card outside the Steam library added nothing and said "Nothing new in this folder": every game's exe had no guest path, so each one was skipped. Since #542 nothing created the game-folder binds that let older builds use such folders; only migrated ones remained. addFolder now binds the picked folder under /root/Games (named as GamesFolderMigration names them, with a suffix when the name is taken) when the session cannot see it, then adds its games. The bind is mounted from the next session start. --- .../droiddeck/launcher/frontend/AddedGames.kt | 16 +++++++ .../launcher/frontend/AddedExesTest.kt | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index f0808edab..0c72812d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -243,6 +243,8 @@ object AddedGames { fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult { val app = context.applicationContext + // Binds are mounted at session start, so a new one reaches the session from the next start. + if (guestPath(app, dir) == null) bindFolder(app, dir) val removed = SessionPrefs.removedAddedGames(app).toSet() val listed = listedFolders(app, removed) var already = 0 @@ -266,6 +268,20 @@ object AddedGames { return FolderResult(added, already) } + @Synchronized + internal fun bindFolder(context: Context, dir: File) { + if (!dir.isDirectory || !dir.canRead()) return + val binds = SessionPrefs.gameFolderBinds(context) + if (binds.any { it.first == dir.path }) return + val taken = binds.map { it.second }.toSet() + val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest + var guest = base + var n = 2 + while (guest in taken) guest = "$base-${n++}" + SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest)) + Log.i(TAG, "games: bound $dir at $guest") + } + private fun listedFolders(context: Context, removed: Set): List { val out = ArrayList() GamesFolderMigration.run(context) diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 2d0a29de4..6cc36e909 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -4,6 +4,7 @@ import android.os.Environment import com.droiddeck.launcher.session.SessionPrefs import java.io.File import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -92,6 +93,50 @@ class AddedExesTest { assertEquals(0, AddedGames.scan(app).size) } + @Test fun addingAFolderTheSessionCannotSeeBindsItAndAddsItsGames() { + noGamesFolders() + val games = File(tmp.newFolder("card"), "Winlator/Games") + exe(File(games, "Alpha/Alpha.exe")) + exe(File(games, "Beta/Beta.exe")) + File(games, "steamapps").mkdirs() + assertNull(AddedGames.guestPath(app, games)) + + val result = AddedGames.addFolder(app, games) + assertEquals(listOf("Alpha", "Beta"), result.added.map { it.name }) + assertEquals(listOf(games.path to "${AddedGames.GUEST_DIR}/Games"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Games/Alpha/Alpha.exe", result.added.first().guestExe) + + val again = AddedGames.addFolder(app, games) + assertEquals(0, again.added.size) + assertEquals(2, again.already) + assertEquals(1, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun aSecondFolderWithTheSameNameGetsItsOwnGuestPath() { + noGamesFolders() + val first = File(tmp.newFolder("a"), "Games").apply { exe(File(this, "One/One.exe")) } + val second = File(tmp.newFolder("b"), "Games").apply { exe(File(this, "Two/Two.exe")) } + AddedGames.addFolder(app, first) + AddedGames.addFolder(app, second) + val guests = SessionPrefs.gameFolderBinds(app).map { it.second } + assertEquals(2, guests.toSet().size) + assertEquals("${AddedGames.GUEST_DIR}/Games", guests.first()) + } + + @Test fun aGuestPathAlreadyTakenIsNeverReused() { + noGamesFolders() + val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() } + val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() } + val c = File(tmp.newFolder("c"), "Games").apply { exe(File(this, "Three/Three.exe")) } + val clash = GamesFolderMigration.legacyRoots(listOf(a.path, b.path, c.path)).last().guest + SessionPrefs.setGameFolderBinds(app, listOf(a.path to "${AddedGames.GUEST_DIR}/Games", b.path to clash)) + AddedGames.addFolder(app, c) + val guests = SessionPrefs.gameFolderBinds(app).map { it.second } + assertEquals(3, guests.size) + assertEquals(3, guests.toSet().size) + assertEquals("$clash-2", guests.last()) + } + @Test fun aRemovedSingleExeGameStaysOffUntilPickedAgain() { noGamesFolders() val pick = onStorage("Download/Celeste/Celeste.exe") From a63f3b1fbc0ca8809b36fef71c0324a5728a14f1 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:44:10 -0400 Subject: [PATCH 026/156] Add experimental Xbox and PC Game Pass store support (#1156) --- .github/workflows/build.yml | 46 +- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 4 + .../main/java/com/droiddeck/launcher/App.kt | 5 + .../launcher/session/SessionFiles.kt | 5 +- .../droiddeck/launcher/stores/CloudSaves.kt | 6 +- .../droiddeck/launcher/stores/LoginFlows.kt | 2 +- .../com/droiddeck/launcher/stores/Store.kt | 1 + .../launcher/stores/StoreAccounts.kt | 12 +- .../droiddeck/launcher/stores/StoreCatalog.kt | 6 +- .../droiddeck/launcher/stores/StoreLaunch.kt | 9 + .../launcher/stores/StoreLaunchRequests.kt | 7 + .../droiddeck/launcher/stores/StoreSizes.kt | 8 +- .../droiddeck/launcher/stores/StoresState.kt | 16 +- .../launcher/stores/download/SavedQueue.kt | 4 +- .../launcher/stores/xbox/XboxBackend.kt | 146 ++++ .../launcher/stores/xbox/XboxCatalog.kt | 107 +++ .../launcher/stores/xbox/XboxContent.kt | 105 +++ .../launcher/stores/xbox/XboxCredentials.kt | 86 +++ .../launcher/stores/xbox/XboxInstaller.kt | 104 +++ .../launcher/stores/xbox/XboxLibraryCache.kt | 57 ++ .../launcher/stores/xbox/XboxLoginActivity.kt | 182 +++++ .../launcher/stores/xbox/XboxManifest.kt | 70 ++ .../launcher/stores/xbox/XboxRuntime.kt | 46 ++ .../droiddeck/launcher/stores/xbox/Xodus.kt | 106 +++ .../com/droiddeck/launcher/ui/StoreChips.kt | 2 + .../com/droiddeck/launcher/ui/StoresDetail.kt | 23 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 68 +- .../stores/StoreAccountsCipherTest.kt | 19 + .../launcher/stores/StoreLaunchTest.kt | 56 ++ .../launcher/stores/xbox/XboxCatalogTest.kt | 77 ++ .../stores/xbox/XboxLibraryCacheTest.kt | 53 ++ .../launcher/stores/xbox/XboxManifestTest.kt | 55 ++ docs/development/xbox-store.md | 106 +++ tools/build_local.sh | 7 + .../overlay/usr/local/bin/droiddeck-game-env | 14 + .../overlay/usr/local/bin/droiddeck-session | 9 +- .../overlay/usr/local/bin/droiddeck-xbox | 183 +++++ tools/release/runtime_inputs.py | 5 +- tools/tests/test_game_environment.py | 11 + tools/tests/test_steam_cold_launch.py | 27 +- tools/tests/test_xbox_launch.py | 73 ++ tools/xbox/LICENSE.LGPL-2.1 | 502 +++++++++++++ tools/xbox/LICENSE.nexus | 21 + tools/xbox/LICENSE.nghttp2 | 23 + tools/xbox/LICENSE.xodus | 674 ++++++++++++++++++ tools/xbox/README.md | 79 ++ tools/xbox/backend.lock.json | 109 +++ tools/xbox/build-local.sh | 18 + tools/xbox/build.sh | 37 + tools/xbox/droiddeck-credentials.patch | 154 ++++ tools/xbox/droiddeck-launch-view.patch | 86 +++ tools/xbox/prepare.py | 36 + tools/xbox/runtime-probe.c | 23 + tools/xbox/runtime.lock.json | 17 + tools/xbox/runtime.py | 59 ++ 56 files changed, 3720 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt create mode 100644 docs/development/xbox-store.md create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox create mode 100644 tools/tests/test_xbox_launch.py create mode 100644 tools/xbox/LICENSE.LGPL-2.1 create mode 100644 tools/xbox/LICENSE.nexus create mode 100644 tools/xbox/LICENSE.nghttp2 create mode 100644 tools/xbox/LICENSE.xodus create mode 100644 tools/xbox/README.md create mode 100644 tools/xbox/backend.lock.json create mode 100755 tools/xbox/build-local.sh create mode 100755 tools/xbox/build.sh create mode 100644 tools/xbox/droiddeck-credentials.patch create mode 100644 tools/xbox/droiddeck-launch-view.patch create mode 100755 tools/xbox/prepare.py create mode 100644 tools/xbox/runtime-probe.c create mode 100644 tools/xbox/runtime.lock.json create mode 100755 tools/xbox/runtime.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25ef8cc09..46fca6d7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,46 @@ env: PRELOAD_CACHE_VERSION: v6 jobs: + xbox: + name: Xbox ARM64 backend + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - uses: actions/cache@v4 + id: xbox-cache + with: + path: | + app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + app/build/xbox/xodus-droiddeck-source.tar.xz + app/build/xbox/runtime/*-source.tar.xz + key: xbox-arm64-${{ hashFiles('tools/xbox/**') }} + - name: Build Xbox backend + if: steps.xbox-cache.outputs.cache-hit != 'true' + run: | + rustup toolchain install 1.99.0 --profile minimal + sudo apt-get update -qq + sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils + bash tools/xbox/build.sh + - uses: actions/upload-artifact@v4 + with: + name: xbox-arm64 + path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + retention-days: 14 + - uses: actions/upload-artifact@v4 + with: + name: xbox-corresponding-source + path: | + app/build/xbox/xodus-droiddeck-source.tar.xz + app/build/xbox/runtime/*-source.tar.xz + if-no-files-found: error + retention-days: 14 + build: + needs: xbox name: Build APK if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 @@ -184,7 +223,7 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -464,6 +503,11 @@ jobs: restore-keys: | cxx-${{ env.NDK_VERSION }}- + - uses: actions/download-artifact@v4 + with: + name: xbox-arm64 + path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox + - name: Build GOG Galaxy companion if: steps.preloads.outputs.cache-hit != 'true' run: | diff --git a/app/build.gradle b/app/build.gradle index b38e1b35a..2e269ac50 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -318,6 +318,7 @@ dependencies { implementation 'com.termux.termux-app:terminal-view:0.118.0' implementation 'androidx.camera:camera-camera2:1.4.2' implementation 'androidx.camera:camera-lifecycle:1.4.2' + implementation 'androidx.webkit:webkit:1.12.1' implementation('com.google.mediapipe:tasks-vision:1.0.0') { exclude group: 'com.google.auto.value', module: 'auto-value' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 095abb5bb..87768b8f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -147,6 +147,10 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density" android:theme="@style/AppTheme" /> + + = 28) Application.getProcessName() else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) .runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName + if (process == "$packageName:xboxlogin") { + if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox") + return + } if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return CrashHandler.install(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) @@ -36,5 +40,6 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend) + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 681287c86..46d84c50a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -66,6 +66,7 @@ object SessionFiles { "usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks", "usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim", "usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts", + "usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox", "usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch", "usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic", "usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam", @@ -133,7 +134,9 @@ object SessionFiles { "usr/local/bin/gamescope" to "usr/local/bin/gamescope", "usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", - ) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) -> + ) + listOf("xodus-cli", "xodus-service", "LICENSE.xodus", "LICENSE.nexus", "LICENSE.LGPL-2.1", "LICENSE.nghttp2", "backend.lock.json", "runtime.lock.json", "version", + "windows/xgameruntime.dll", "windows/winhttp.dll", "windows/windows.web.dll", "windows/twinapi.appcore.dll", "windows/libnghttp2.dll") + .map { "usr/local/lib/droiddeck-xbox/$it" to "usr/local/lib/droiddeck-xbox/$it" } + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 7c00981b2..669a13eee 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -58,7 +58,7 @@ object CloudSaves { val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON, Store.ITCH -> "" + Store.AMAZON, Store.ITCH, Store.XBOX -> "" } ?: return null p.edit().putString("cloud_template_$id", fetched).apply() Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}") @@ -96,7 +96,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON, Store.ITCH -> null + Store.AMAZON, Store.ITCH, Store.XBOX -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -121,7 +121,7 @@ object CloudSaves { val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON, Store.ITCH -> null + Store.AMAZON, Store.ITCH, Store.XBOX -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index 43517ebfe..be1b29ed0 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -12,7 +12,7 @@ internal object LoginFlows { Store.GOG -> GogFlow(state) Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() - Store.ITCH -> null + Store.ITCH, Store.XBOX -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index d7cc814cb..b5e0d1fbf 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -4,6 +4,7 @@ enum class Store(val id: String, val label: String, val shortLabel: String) { GOG("gog", "GOG", "GOG"), EPIC("epic", "Epic Games", "Epic"), AMAZON("amazon", "Amazon Games", "Amazon"), + XBOX("xbox", "Xbox", "Xbox"), ITCH("itch", "itch.io", "itch"); companion object { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt index 6b5f30186..27bf133f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt @@ -67,7 +67,10 @@ object StoreAccounts { fun write(context: Context, store: Store, json: JSONObject) { unavailable.remove(store) val f = credentialsFile(context, store) - if (seal(f, json) == null) put(f, json.toString()) + if (seal(f, json, retry = store == Store.XBOX) == null) { + check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" } + put(f, json.toString()) + } } @Synchronized @@ -86,18 +89,19 @@ object StoreAccounts { fun signedInAs(context: Context, store: Store): String? { val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null + if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() } if (json.optString("access_token", "").isEmpty()) return null return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label } } - private fun seal(f: File, json: JSONObject): File? { - if (keystoreFailed) return null + private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? { + if (keystoreFailed && !retry) return null val text = json.toString() val envelope = try { CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } } } catch (e: Exception) { keystoreFailed = true - Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})") + Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})") return null } put(f, envelope.toString()) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index 049e2b926..6f4678c7f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -21,7 +21,9 @@ data class CatalogItem( val sizeBytes: Long = 0L, val extra: Map = emptyMap(), val mature: Boolean = false, + val subscription: Boolean = false, ) { + val accessible: Boolean get() = owned || subscription val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank() val key: String get() = "${store.id}:$id" } @@ -49,7 +51,9 @@ fun installedCards(store: Store, installed: List, library: L return installed.filter { it.sidecar.store == store }.map { game -> val s = game.sidecar byId[s.id] ?: byTitle[s.title.lowercase()] - ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true) + ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, + owned = s.store != Store.XBOX || s.extra["access"] == "owned", + subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass") }.distinctBy { it.id }.sortedBy { it.title.lowercase() } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index 4486df81e..e17aac578 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores import android.content.Context import android.util.Log +import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.frontend.Library import java.io.File @@ -63,6 +64,14 @@ object StoreLaunch { private const val CODE_VAR = "DD_X" private const val CLIENT_VAR = "DD_A" + fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? { + val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder -> + StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true + } ?: return null + val game = AddedGames.single(context, folder.path) ?: return null + return Library.addedGame(context, game) + } + fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) { if (sidecar.launcher != LAUNCHER) return val file = File(folder, LAUNCHER) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index 4005ff2b3..909c0b7fc 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -37,6 +37,7 @@ object StoreLaunchRequests { } fun stop() { + Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start() synchronized(lock) { observer?.stopWatching() observer = null @@ -50,6 +51,12 @@ object StoreLaunchRequests { return } val reply = when (request.optString("op")) { + "xbox-released" -> { + com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id")) + JSONObject().put("ok", true) + } + "xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) } + .getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") } "gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) } .getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") } "epic-code" -> { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 01014dbd7..b9b9354b7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -31,7 +31,7 @@ object StoreSizes { private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) } fun request(context: Context, item: CatalogItem) { - if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return + if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return resolve(context, item).thenAccept { bytes -> if (bytes != null) StoresState.post { known[item.key] = bytes } } @@ -39,7 +39,7 @@ object StoreSizes { fun resolve(context: Context, item: CatalogItem): CompletableFuture { val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 } - if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available) + if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available) if (available != null) return CompletableFuture.completedFuture(available) val app = context.applicationContext val version = item.extra["version"].orEmpty() @@ -55,7 +55,7 @@ object StoreSizes { Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.ITCH -> {} - Store.GOG -> {} + Store.GOG, Store.XBOX -> {} } } }, pool).exceptionally { error -> @@ -108,7 +108,7 @@ object StoreSizes { val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L AmazonManifest.parse(bytes).totalInstallSize } - Store.GOG -> 0L + Store.GOG, Store.XBOX -> 0L Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 63cc4a4cf..4aa57757c 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -20,6 +20,8 @@ object StoresState { private val main = Handler(Looper.getMainLooper()) val accounts = mutableStateMapOf() + var accountsLoaded by mutableStateOf(false) + private set val library = mutableStateMapOf>() @@ -84,11 +86,15 @@ object StoresState { val partial = StoreInstallRoot.unfinished(app) val version = if (StoresNative.available) StoresNative.version ?: "" else "" main.post { - accounts.clear() - found.forEach { (store, name) -> accounts[store] = name } - installed = onDisk - unfinished = partial - engine = version + androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot { + val restored = found.toMap() + accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) } + accounts.putAll(restored) + installed = onDisk + unfinished = partial + engine = version + accountsLoaded = true + } } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt index 0c9fd0ccb..5a5eae4fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt @@ -78,7 +78,7 @@ object SavedQueue { put("store", i.store.id); put("id", i.id); put("title", i.title) put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "") put("tags", i.tags); put("developer", i.developer); put("description", i.description) - put("size", i.sizeBytes); put("owned", i.owned) + put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription) put("extra", JSONObject(i.extra)) } @@ -89,7 +89,7 @@ object SavedQueue { id = o.getString("id"), title = o.getString("title"), imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null }, tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"), - owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"), + owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"), extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt new file mode 100644 index 000000000..2eaa59080 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -0,0 +1,146 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.* +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import java.io.File + +object XboxBackend : StoreBackend { + override val store = Store.XBOX + @Volatile private var access: Map = emptyMap() + @Volatile private var generation = 0 + private var syncing: Int? = null + + override fun signIn(context: Context) { + context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + @Synchronized override fun signOut(context: Context) { + generation++ + XboxCredentials.invalidate(context) + XboxLibraryCache.clear(context) + XboxRuntime.stop() + access = emptyMap() + StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) } + } + + @Synchronized override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + val current = generation + if (syncing == current) return + syncing = current + Thread({ + try { + val account = StoreAccounts.signedInAs(app, store) ?: error("Sign in to Xbox first.") + val market = Xodus.market() + val cached = XboxLibraryCache.read(app, account, market) + synchronized(this) { + if (current != generation) return@Thread + if (cached != null) access = cached.games.associate { it.id to it.extra.getValue("access") } + } + StoresState.post { + if (current == generation) { + if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games) + StoresState.problems.remove(store) + if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…" + else StoresState.status.remove(store) + } + } + if (cached != null && !cached.needsRefresh(force)) return@Thread + val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market) + val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() } + synchronized(this) { + if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread + access = licenses + runCatching { XboxLibraryCache.write(app, account, market, games) } + .onFailure { StoresState.logLine("Xbox: library cache could not be saved") } + } + StoresState.post { + if (current == generation) { + StoresState.library[store] = mergeItems(StoresState.library[store], games) + StoresState.status.remove(store) + StoresState.problems.remove(store) + } + } + } catch (e: Exception) { + StoresState.post { + if (current == generation) { + StoresState.status.remove(store) + StoresState.problems[store] = e.message ?: "Xbox library could not be loaded." + } + } + } finally { + synchronized(this) { if (syncing == current) syncing = null } + } + }, "xbox-library").start() + } + + override fun loadShelves(context: Context, force: Boolean) { + val current = generation + Thread({ + runCatching { XboxCatalog.featured(access) }.onSuccess { games -> + if (current == generation) StoresState.post { StoresState.shelves[store] = StoreShelves(trending = games) } + }.onFailure { if (current == generation) StoresState.post { StoresState.problems[store] = "Microsoft Store could not be reached. Try again." } } + }, "xbox-store").start() + } + + override fun search(context: Context, query: String): List = XboxCatalog.search(query, access) + + class PackageChoice(val item: CatalogItem, val root: File, val choices: List) + var packageChoice by androidx.compose.runtime.mutableStateOf(null) + private set + + fun dismissPackages() { packageChoice = null } + fun selectPackage(context: Context, choice: org.json.JSONObject) { + val pending = packageChoice ?: return + packageChoice = null + val item = pending.item.copy(extra = pending.item.extra + ("contentId" to choice.getString("contentId"))) + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, pending.root) + enqueue(context.applicationContext, item, pending.root) + } + + override fun install(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + if (!item.extra["baseId"].isNullOrBlank()) { XboxContent.install(app, item); return } + if (!item.extra["contentId"].isNullOrBlank()) { enqueue(app, item, root); return } + Thread({ + try { + val plan = Xodus.run(app, listOf("resolve-package", item.id, "--json", "--market", item.extra["market"] ?: Xodus.market())) + require(plan.getInt("schema") == 1 && plan.getString("rootProductId") == item.id) + val choices = XboxCatalog.objects(plan.getJSONArray("choices")).filter { it.optBoolean("supported") && it.optString("kind") == "Game" } + check(choices.isNotEmpty()) { "This offer has no supported PC package." } + if (plan.getString("status") == "ready" && choices.size == 1) { + val selected = item.copy(extra = item.extra + ("contentId" to choices.single().getString("contentId"))) + com.droiddeck.launcher.stores.download.SavedQueue.remember(app, selected, root) + enqueue(app, selected, root) + } else StoresState.post { packageChoice = PackageChoice(item, root, choices) } + } catch (e: Exception) { + DownloadQueue.recordFailed(app, DownloadEntry(store, item.id, item.title), e.message ?: "Xbox package could not be resolved.") { install(app, item, root) } + } + }, "xbox-package").start() + } + + fun update(context: Context, game: InstalledStoreGame) { + check(!XboxRuntime.isActive(game.sidecar.id)) { "Close this Xbox game before updating it." } + val sidecar = game.sidecar + val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover, + owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass", extra = sidecar.extra) + val root = game.folder.parentFile!!.parentFile!! + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root) + enqueue(context, item, root) + } + + private fun enqueue(context: Context, item: CatalogItem, root: File) { + val app = context.applicationContext + val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root) + val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes, + location = StoreInstallRoot.labelFor(app, folder)) + DownloadQueue.enqueue(app, entry) { XboxInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt new file mode 100644 index 000000000..bb3fab4a2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -0,0 +1,107 @@ +package com.droiddeck.launcher.stores.xbox + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreNet +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +internal object XboxCatalog { + private val productId = Regex("[A-Z0-9]{12}") + private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") + + fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map { + require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) + val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds + require(age in -60..299) + val array = json.getJSONArray("entitlements") + require(array.length() <= 10_000) + val rows = LinkedHashMap() + val blocked = HashSet() + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val id = row.optString("productId") + if (!productId.matches(id)) continue + val valid = row.opt("status") is String && row.opt("isTrial") is Boolean && row.opt("special") is Boolean && + row.opt("sharingSource") is String && runCatching { timestamp(row.getString("endDate")) }.isSuccess && + (row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId"))) + if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row + } + fun kind(id: String, visited: Set = emptySet()): String? { + if (id in blocked || id in visited || visited.size >= 16) return null + val row = rows[id] ?: return null + if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") || + row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null + val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } + if (parent == null) return if (id in plans) null else "owned" + if (parent in blocked) return null + if (parent in plans) return "gamepass" + return kind(parent, visited + id) + } + return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + } + + private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant() + + fun products(ids: List, access: Map = emptyMap(), market: String = Xodus.market()): List { + return rawProducts(ids, market).mapNotNull { product(it, access) } + } + + fun rawProducts(ids: List, market: String = Xodus.market()): List { + return ids.map { it.uppercase() }.filter { productId.matches(it) }.distinct().chunked(25).flatMap { batch -> + val body = get("https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${batch.joinToString(",")}&market=$market&languages=en-us,neutral") + objects(body.optJSONArray("Products")).filter { it.optString("ProductId").uppercase() in batch } + } + } + + fun search(query: String, access: Map): List { + if (productId.matches(query.uppercase())) return products(listOf(query.uppercase()), access) + if (query.length !in 2..100) return emptyList() + val q = java.net.URLEncoder.encode(query, "UTF-8") + val body = get("https://apps.microsoft.com/api/products/search?query=$q&mediaType=games&age=all&price=all&category=all&subscription=all&gl=${Xodus.market()}&hl=en-us") + val ids = objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(60) + return products(ids, access) + } + + fun featured(access: Map): List { + val body = get("https://apps.microsoft.com/api/Reco/GetComputedProductsList?listName=TopTrending&pgNo=1&noItems=12&filteredCategories=AllProducts&mediaType=Games&gl=${Xodus.market()}&hl=en-us&exp=0") + return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access) + } + + internal fun product(json: JSONObject, access: Map): CatalogItem? { + val id = json.optString("ProductId").uppercase() + if (!productId.matches(id) || json.optString("ProductType") != "Game") return null + val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null + val title = localized.optString("ProductTitle").takeIf { it.isNotBlank() } ?: return null + val packages = objects(json.optJSONArray("DisplaySkuAvailabilities")).flatMap { + objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) + } + val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } } + if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null + val images = objects(localized.optJSONArray("Images")) + fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose -> + images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw -> + val url = if (raw.startsWith("//")) "https:$raw" else raw + runCatching { java.net.URI(url) }.getOrNull()?.takeIf { + it.scheme == "https" && it.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com") && it.userInfo == null && it.port == -1 + }?.toString() + } + } + val kind = access[id] + return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"), + tallImageUrl = image("TitledPoster", "Poster", "BoxArt"), + owned = kind == "owned", subscription = kind == "gamepass", + storeUrl = "https://www.xbox.com/games/store/$id", + publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"), + description = localized.optString("ProductDescription"), + sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L, + extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()), + ) + } + + fun objects(array: JSONArray?): List = if (array == null) emptyList() else + (0 until array.length()).mapNotNull { array.optJSONObject(it) } + + private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again.")) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt new file mode 100644 index 000000000..1594dee92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt @@ -0,0 +1,105 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.stores.* +import org.json.JSONObject +import java.io.File + +internal object XboxContent { + data class Offer(val item: CatalogItem, val installed: Boolean) + data class Plan(val base: InstalledStoreGame, val offers: List, val error: String? = null) + var plan by mutableStateOf(null) + private set + fun dismiss() { plan = null } + + fun manage(context: Context, base: InstalledStoreGame) { + val app = context.applicationContext + Thread({ + try { + val manifest = XboxManifest.read(base.folder) + check(manifest.storeId.matches(Regex("[A-Z0-9]{12}"))) { "The package has no valid Store ID." } + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox first.") + val market = base.sidecar.extra["market"] ?: Xodus.market() + val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market) + val products = XboxCatalog.rawProducts(listOf(manifest.storeId) + manifest.related, market) + val baseProduct = products.singleOrNull { it.optString("ProductId") == manifest.storeId } + val families = packages(baseProduct).map { it.optString("PackageFamilyName") }.filter { it.startsWith(manifest.name + "_") }.toSet() + val offers = products.filter { it.optString("ProductId") in manifest.related && it.optString("ProductType") == "Durable" }.mapNotNull { product -> + val id = product.getString("ProductId") + val access = licenses[id] ?: return@mapNotNull null + val packages = packages(product).filter { it.optString("MainPackageFamilyNameForDlc") in families } + .distinctBy { it.optString("ContentId") } + if (packages.size != 1) return@mapNotNull null + val pkg = packages.single() + val cid = pkg.optString("ContentId") + if (!cid.matches(Regex("[0-9a-fA-F-]{36}"))) return@mapNotNull null + val localized = XboxCatalog.objects(product.optJSONArray("LocalizedProperties")).firstOrNull() + val item = CatalogItem(Store.XBOX, id, localized?.optString("ProductTitle")?.ifBlank { id } ?: id, + imageUrl = base.sidecar.cover, owned = access == "owned", subscription = access == "gamepass", + sizeBytes = pkg.optLong("MaxDownloadSizeInBytes"), + extra = mapOf("baseId" to base.sidecar.id, "contentId" to cid, "market" to market, "family" to pkg.getString("PackageFamilyName"))) + Offer(item, File(base.folder, ".xbox-content/$id/.droiddeck-content.json").isFile) + } + StoresState.post { plan = Plan(base, offers) } + } catch (e: Exception) { StoresState.post { plan = Plan(base, emptyList(), e.message ?: "Xbox content could not be loaded.") } } + }, "xbox-content").start() + } + + private fun packages(product: JSONObject?): List = XboxCatalog.objects(product?.optJSONArray("DisplaySkuAvailabilities")) + .filter { it.optJSONObject("Sku")?.optJSONObject("Properties")?.optBoolean("IsTrial", true) == false } + .flatMap { XboxCatalog.objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) } + .filter { pkg -> pkg.optString("PackageFormat") == "MSIXVC" && + XboxCatalog.objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } } + + private val parents = java.util.concurrent.ConcurrentHashMap() + fun busy(base: String): Boolean = StoresState.latestDownloads.any { it.isActive && parents[it.id] == base } + + fun install(context: Context, item: CatalogItem) { + plan = null + val base = runCatching { base(context, item) }.getOrElse { error -> + StoresState.post { StoresState.problems[Store.XBOX] = error.message ?: "The base game is not available." } + return + } + if (XboxRuntime.isActive(base.sidecar.id)) { + StoresState.post { StoresState.problems[Store.XBOX] = "Close the Xbox game before changing its content." } + return + } + parents[item.id] = base.sidecar.id + val folder = File(base.folder, ".xbox-content/${item.id}") + if (folder.canonicalPath != base.folder.canonicalPath + "/.xbox-content/${item.id}") { + StoresState.post { StoresState.problems[Store.XBOX] = "The Xbox content folder contains a link outside this game." } + return + } + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, base.folder.parentFile!!.parentFile!!) + com.droiddeck.launcher.stores.download.DownloadQueue.enqueue(context, + com.droiddeck.launcher.stores.download.DownloadEntry(Store.XBOX, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes)) { + XboxInstaller(context.applicationContext, item, folder) + } + } + + fun base(context: Context, item: CatalogItem): InstalledStoreGame { + require(item.id.matches(Regex("[A-Z0-9]{12}"))) + val id = item.extra["baseId"] ?: error("Missing Xbox base game") + val folder = StoreInstallRoot.gameFolders(context).firstOrNull { StoreGameSidecar.read(it)?.let { s -> s.store == Store.XBOX && s.id == id && s.isInstalled } == true } + ?: error("The Xbox base game is not installed.") + return InstalledStoreGame(StoreGameSidecar.read(folder)!!, folder) + } + + fun complete(context: Context, item: CatalogItem, folder: File) { + val base = base(context, item) + val parent = XboxManifest.read(base.folder) + val content = XboxManifest.read(folder) + check(item.id in parent.related && content.storeId == item.id && content.dependency == parent.name && content.publisher == parent.publisher && item.extra["family"].orEmpty().startsWith(content.name + "_")) { + "The downloaded content does not belong to this Xbox game." + } + require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}") + val marker = File(folder, ".droiddeck-content.json") + val temporary = File(folder, marker.name + ".tmp") + temporary.writeText(JSONObject().put("productId", item.id).put("parent", base.sidecar.id) + .put("contentId", item.extra["contentId"]).put("version", content.version).toString()) + check(temporary.renameTo(marker)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt new file mode 100644 index 000000000..14a2a8a89 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.net.LocalServerSocket +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONArray +import org.json.JSONObject +import java.io.Closeable +import java.io.InputStream +import java.util.UUID + +internal class XboxCredentials(private val app: Context) : Closeable { + val name = "droiddeck-xbox-${UUID.randomUUID()}" + private val generation = storage(app) { epoch(app) } + private val server = LocalServerSocket(name) + @Volatile private var closed = false + + init { + Thread({ + while (!closed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.use { + it.soTimeout = 10_000 + val reply = runCatching { + check(it.peerCredentials.uid == android.os.Process.myUid()) + storage(app) { + check(epoch(app) == generation) + answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152))) + } + }.getOrElse { JSONObject().put("ok", false) } + runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) } + } + } + }, "xbox-credentials").start() + } + + private fun answer(request: JSONObject): JSONObject { + val key = request.getString("key") + require(key.isNotEmpty() && key.length <= 512) + val record = StoreAccounts.read(app, Store.XBOX) + check(!StoreAccounts.isUnavailable(Store.XBOX)) + val json = record ?: JSONObject() + val slots = json.optJSONObject("slots") ?: JSONObject().also { json.put("slots", it) } + val result = JSONObject().put("ok", true) + when (request.getString("op")) { + "get" -> result.put("value", slots.opt(key) ?: JSONObject.NULL) + "set" -> { + val bytes = request.getJSONArray("value") + require(bytes.length() <= 262_144) + for (i in 0 until bytes.length()) require(bytes.getInt(i) in 0..255) + slots.put(key, bytes) + StoreAccounts.write(app, Store.XBOX, json) + } + "remove" -> { slots.remove(key); StoreAccounts.write(app, Store.XBOX, json) } + else -> error("Invalid credential operation") + } + return result + } + + override fun close() { closed = true; runCatching { server.close() } } + + companion object { + val lock = Any() + private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("") + fun invalidate(app: Context) = storage(app) { + java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString()) + StoreAccounts.clear(app, Store.XBOX) + } + fun storage(app: Context, block: () -> T): T = synchronized(lock) { + java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file -> + file.channel.lock().use { block() } + } + } + fun readLine(input: InputStream, limit: Int): String { + val out = java.io.ByteArrayOutputStream() + while (out.size() <= limit) { + val byte = input.read() + if (byte == 10) return out.toString("UTF-8") + if (byte < 0) throw java.io.EOFException("Xbox backend disconnected") + out.write(byte) + } + error("Xbox backend response exceeded its limit") + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt new file mode 100644 index 000000000..44e9ceff3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt @@ -0,0 +1,104 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.* +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + private val paused = CountDownLatch(1) + @Volatile private var child: Xodus.Child? = null + @Volatile private var delete = false + + override fun run(handle: DownloadQueue.JobHandle): String? { + if (cancelled.get()) return null + check(StoreAccounts.signedInAs(app, Store.XBOX) != null) { "Sign in to Xbox before installing." } + val market = item.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market() + val selected = item.extra["contentId"].orEmpty() + require(selected.matches(Regex("[a-fA-F0-9-]{36}"))) { "Select a PC game package before installing." } + val content = !item.extra["baseId"].isNullOrBlank() + if (content) { + check(!XboxRuntime.isActive(item.extra["baseId"].orEmpty())) { "Close the base game before changing Xbox content." } + check(item.id in XboxManifest.read(XboxContent.base(app, item).folder).related) { "This content is not related to the base game." } + val base = XboxContent.base(app, item) + require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}") + File(folder, ".droiddeck-content.json").delete() + folder.mkdirs() + } else { + check(!XboxRuntime.isActive(item.id)) { "Close this Xbox game before updating it." } + StoreInstalls.markUpdating(folder) + StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl) + } + var complete = false + var sequence = -1L + try { + val source = if (content) selected else item.id + val selection = if (content) emptyList() else listOf("--selected-content-id", selected) + Xodus.start(app, listOf("streaming", source, folder.path, "--market", market, + "--progress-json", "--control-stdin") + selection).use { process -> + child = process + val diagnostics = Xodus.diagnostics(process) + if (cancelled.get()) process.process.destroy() + process.process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> + if (line.startsWith("NEXUS_PROGRESS ")) { + val progress = JSONObject(line.removePrefix("NEXUS_PROGRESS ")) + require(progress.getInt("schema") == 1 && progress.getLong("sequence") > sequence) + sequence = progress.getLong("sequence") + if (progress.optBoolean("paused")) paused.countDown() + when (progress.getString("phase")) { + "complete" -> complete = true + "downloading", "download", "transfer" -> handle.progress(progress.getLong("completedBytes"), progress.optLong("totalBytes")) + "finalizing", "installing" -> handle.stage(DownloadStage.INSTALL) + else -> handle.stage(DownloadStage.MANIFEST) + } + } + } } + check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." } + diagnostics.join(2000) + if (cancelled.get()) return null + check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." } + } + if (content) { + XboxContent.complete(app, item, folder) + return folder.path + } + val exe = executable(folder) + StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe, + installVersion = XboxManifest.read(folder).version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl, + extra = item.extra + mapOf("contentId" to selected, "market" to market))) + return folder.path + } finally { + child = null + if (delete && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder) + else if (delete) StoreInstalls.discard(app, folder, emptyList()) + } + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + delete = deleteFiles + val process = child + if (process == null) { + if (deleteFiles && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder) + else if (deleteFiles) StoreInstalls.discard(app, folder, emptyList()) + return + } + Thread({ + runCatching { + process.send(JSONObject().put("schema", 1).put("command", "pause")) + paused.await(3, TimeUnit.SECONDS) + } + process.close() + }, "xbox-download-stop").start() + } + + companion object { + internal fun executable(folder: File): String = XboxManifest.executable(folder) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt new file mode 100644 index 000000000..bf9e7ee70 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.json.JSONArray +import org.json.JSONObject + +internal object XboxLibraryCache { + data class Snapshot(val games: List, val savedAt: Long) { + fun needsRefresh(force: Boolean, now: Long = System.currentTimeMillis()): Boolean = + force || now - savedAt !in 0 until 15 * 60 * 1000L + } + + private fun prefs(app: Context) = app.getSharedPreferences("xbox", Context.MODE_PRIVATE) + fun read(app: Context, account: String, market: String): Snapshot? = + decode(prefs(app).getString("library", null), account, market) + + fun write(app: Context, account: String, market: String, games: List) { + check(prefs(app).edit().putString("library", encode(account, market, games)).commit()) + } + + fun clear(app: Context) { prefs(app).edit().remove("library").commit() } + + internal fun encode(account: String, market: String, games: List, now: Long = System.currentTimeMillis()): String = + JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now) + .put("games", JSONArray().apply { + for (game in games) put(JSONObject().put("id", game.id).put("title", game.title) + .put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description) + .put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes) + .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none")) + }).toString() + + internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching { + require(text != null && text.length <= 8_000_000) + val json = JSONObject(text) + require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) + val savedAt = json.getLong("savedAt") + require(savedAt > 0 && savedAt <= now + 60_000) + val rows = json.getJSONArray("games") + require(rows.length() <= 10_000) + val games = (0 until rows.length()).map { index -> + val row = rows.getJSONObject(index) + val id = row.getString("id") + val title = row.getString("title") + val access = row.getString("access") + require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass")) + CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() }, + tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() }, + description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"), + sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass", + storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access)) + } + require(games.map { it.id }.distinct().size == games.size) + Snapshot(games, savedAt) + }.getOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt new file mode 100644 index 000000000..2192767fc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -0,0 +1,182 @@ +package com.droiddeck.launcher.stores.xbox + +import android.annotation.SuppressLint +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class XboxLoginActivity : ComponentActivity() { + private lateinit var host: LinearLayout + private lateinit var status: TextView + private var view: WebView? = null + @Volatile private var child: Xodus.Child? = null + @Volatile private var session = 0L + @Volatile private var closed = false + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + host = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) } + status = TextView(this).apply { setTextColor(Color.WHITE); text = "Preparing Microsoft sign-in…"; setPadding(24, 16, 24, 16) } + host.addView(status) + host.addView(Button(this).apply { text = getString(com.droiddeck.launcher.R.string.stores_login_close); setOnClickListener { finish() } }) + setContentView(host) + if (android.os.Build.VERSION.SDK_INT < 28 || !WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) || + !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + status.text = "Update Android System WebView to sign in to Xbox." + return + } + CookieManager.getInstance().removeAllCookies { begin() } + } + + private fun begin() { + if (closed) return + Thread({ + if (closed) return@Thread + try { + Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process -> + child = process + if (closed) return@use + Xodus.diagnostics(process) + val timer = Thread({ + runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() } + }, "xbox-login-timeout").apply { start() } + var ready = false + var latest = 0L + val input = process.process.inputStream.buffered() + while (!closed) { + val line = try { XboxCredentials.readLine(input, 131_072) } catch (e: java.io.EOFException) { break } + if (!line.startsWith("NEXUS_AUTH_V1 ")) continue + val frame = JSONObject(line.removePrefix("NEXUS_AUTH_V1 ")) + when (frame.getString("type")) { + "ready" -> { + check(!ready && frame.getInt("version") == 1) + ready = true + process.send(JSONObject().put("type", "hello").put("version", 1)) + } + "open" -> { + val next = frame.getLong("session") + check(ready && next > latest && next <= 32 && trusted(frame.getString("url"))) + latest = next; session = next + runOnUiThread { if (!closed) open(frame) } + } + "close" -> { + check(frame.getLong("session") == session) + session = 0L + runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" } + } + "context" -> { + check(frame.getLong("session") == session) + val context = frame.getString("context") + check(context.length <= 4096) + val message = JSONObject().put("type", "callback").put("value", JSONObject() + .put("name", "CloudExperienceHost.getContext") + .put("args", org.json.JSONArray(listOf("CloudExperienceHost", "TokenBroker", "TokenBroker", "{\"PrivatePropertyBag\":1,\"PasswordlessConnect\":1,\"PreferAssociate\":1,\"ChromelessUI\":0}"))) + .put("context", context)) + val active = session + runOnUiThread { if (active == session && trusted(view?.url.orEmpty())) view?.evaluateJavascript( + "globalThis['CloudExperienceHost.Bridge.dispatchMessage'](${JSONObject.quote(message.toString())})", null) } + } + else -> error("Unsupported Xbox sign-in response") + } + } + check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L) + timer.interrupt() + } + child = null + if (closed) return@Thread + val account = Xodus.run(applicationContext, listOf("account", "--json")) + check(account.optBoolean("stored")) + XboxCredentials.storage(this) { + val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing") + record.put("username", account.getString("username")) + StoreAccounts.write(this, Store.XBOX, record) + } + runOnUiThread { finish() } + } catch (e: Exception) { + child = null + if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." } + } + }, "xbox-login").start() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun open(frame: JSONObject) { + disposeView() + val active = frame.getLong("session") + val web = WebView(this) + view = web + web.settings.apply { + javaScriptEnabled = true; domStorageEnabled = true + allowFileAccess = false; allowContentAccess = false + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; MSAppHost/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.26100" + } + WebViewCompat.addWebMessageListener(web, "droiddeckXbox", origins) { _, message, origin, mainFrame, _ -> + val payload = message.data + if (mainFrame && trusted(origin.toString()) && trusted(web.url.orEmpty()) && active == session && payload != null && payload.toByteArray().size <= 65_536) { + runCatching { child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) } + } + } + WebViewCompat.addDocumentStartJavaScript(web, + "if(window===window.top){Object.defineProperty(globalThis.external,'notify',{value:p=>{if(typeof p==='string')droiddeckXbox.postMessage(p)},configurable:false,writable:false})}", origins) + web.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = !trusted(request.url.toString()) + override fun onPageFinished(view: WebView, url: String) { + if (!trusted(url)) { finish(); return } + status.text = "Microsoft sign-in" + val uri = Uri.parse(url) + if (uri.host == "login.live.com" && uri.path == "/ppsecure/post.srf") { + view.evaluateJavascript("JSON.stringify(globalThis.ServerData ?? null)") { raw -> + val payload = runCatching { org.json.JSONArray("[$raw]").getString(0) }.getOrNull() + if (active == session && payload != null && payload != "null" && payload.length <= 65_536) runCatching { + child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) + } + } + } + } + } + host.addView(web, LinearLayout.LayoutParams(-1, 0, 1f)) + val headers = frame.getJSONObject("headers") + val values = headers.keys().asSequence().associateWith { key -> + require(key in allowedHeaders) + headers.getString(key).also { value -> require(value.length <= 4096 && value.all { it in ' '..'~' }) } + } + web.loadUrl(frame.getString("url"), values) + } + + private fun disposeView() { view?.let { host.removeView(it); it.stopLoading(); it.destroy() }; view = null } + override fun onDestroy() { + closed = true + val current = child; child = null + Thread({ runCatching { current?.close() } }, "xbox-login-close").start() + disposeView() + CookieManager.getInstance().removeAllCookies(null) + android.webkit.WebStorage.getInstance().deleteAllData() + super.onDestroy() + } + + companion object { + private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com") + private val origins = hosts.map { "https://$it" }.toSet() + private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp") + internal fun trusted(value: String): Boolean = runCatching { + val url = java.net.URI(value) + url.scheme == "https" && url.host in hosts && url.port == -1 && url.userInfo == null + }.getOrDefault(false) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt new file mode 100644 index 000000000..d738193ed --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.File +import org.xmlpull.v1.XmlPullParser + +internal object XboxManifest { + data class Info(val storeId: String, val name: String, val publisher: String, val version: String, + val dependency: String, val related: List, val executables: List) + + fun read(folder: File): Info { + val manifests = folder.listFiles().orEmpty().filter { it.name.equals("MicrosoftGame.config", true) && it.isFile } + require(manifests.size == 1) { "Missing or ambiguous MicrosoftGame.config." } + val manifest = manifests.single() + require(manifest.length() <= 1_048_576) + val parser = android.util.Xml.newPullParser().apply { setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false) } + val identities = ArrayList>() + val storeIds = ArrayList() + val dependencies = ArrayList() + val related = ArrayList() + val entries = ArrayList() + fun attribute(name: String) = parser.getAttributeValue(null, name).orEmpty() + var root = false + var elements = 0 + manifest.inputStream().use { input -> + parser.setInput(input, null) + while (true) { + when (parser.nextToken()) { + XmlPullParser.END_DOCUMENT -> break + XmlPullParser.DOCDECL -> error("Xbox game manifests cannot contain a DTD.") + XmlPullParser.START_TAG -> { + require(++elements <= 10_000 && parser.depth <= 64) { "Xbox game manifest is too complex." } + if (parser.depth == 1) { require(!root && parser.name == "Game"); root = true } + when (parser.name) { + "Identity" -> identities.add(listOf(attribute("Name"), attribute("Publisher"), attribute("Version"))) + "StoreId" -> storeIds.add(parser.nextText().trim()) + "MainPackageDependency" -> dependencies.add(attribute("Name")) + "RelatedProduct" -> related.add(parser.nextText().trim()) + "Executable" -> { + val path = attribute("Name").replace('\\', '/') + if (path.endsWith(".exe", true) && attribute("TargetDeviceFamily") in listOf("", "PC", "Windows.Desktop") && + !attribute("IsDevOnly").equals("true", true)) entries.add(path) + } + } + } + } + } + } + require(root && identities.size <= 1 && storeIds.size <= 1 && dependencies.size <= 1) { "Invalid Xbox game manifest." } + val ids = related.distinct() + require(ids.size <= 128 && ids.all { it.matches(Regex("[A-Z0-9]{12}")) }) + val identity = identities.singleOrNull() + return Info(storeIds.singleOrNull().orEmpty(), identity?.get(0).orEmpty(), identity?.get(1).orEmpty(), identity?.get(2).orEmpty(), + dependencies.singleOrNull().orEmpty(), ids, entries) + } + + fun executable(folder: File): String { + for (path in read(folder).executables) { + require(!path.startsWith('/') && !path.contains(':') && path.split('/').none { it == ".." || it.isEmpty() }) + var file = folder + for (part in path.split('/')) { + val matches = file.listFiles().orEmpty().filter { it.name.equals(part, true) } + require(matches.size == 1) { "Missing or ambiguous Xbox launch path." } + file = matches.single() + } + require(file.canonicalPath.startsWith(folder.canonicalPath + File.separator)) + if (file.isFile) return file.relativeTo(folder).invariantSeparatorsPath + } + error("The Xbox package has no supported PC launch executable.") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt new file mode 100644 index 000000000..be4d0611b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.* +import org.json.JSONObject +import java.io.File + +internal object XboxRuntime { + private var child: Xodus.Child? = null + private var owner: String? = null + private val activeGames = HashMap() + @Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0 + @Synchronized fun released(id: String) { + val count = activeGames.getOrDefault(id, 0) + if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1 + } + + @Synchronized fun prepare(app: Context, id: String): JSONObject { + check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" } + check(SessionState.running) { "No running DroidDeck session" } + check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" } + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing") + val socket = File(app.filesDir, "xbox-runtime/xodus.sock") + if (child?.process?.isAlive != true || owner != account) { + stop() + socket.delete() + val service = Xodus.start(app, emptyList(), service = true) + child = service; owner = account + Xodus.diagnostics(service) + Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start() + val deadline = android.os.SystemClock.elapsedRealtime() + 90_000 + while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100) + if (!socket.exists() || !service.process.isAlive || !SessionState.running) { + stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.") + } + } + activeGames[id] = activeGames.getOrDefault(id, 0) + 1 + return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket) + } + + @Synchronized fun stop() { + val process = child; child = null; owner = null; activeGames.clear() + runCatching { process?.close() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt new file mode 100644 index 000000000..668ed9b3d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -0,0 +1,106 @@ +package com.droiddeck.launcher.stores.xbox + +import android.content.Context +import android.os.Environment +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.Closeable +import java.io.File +import java.util.concurrent.TimeUnit + +internal object Xodus { + const val DIR = "/usr/local/lib/droiddeck-xbox" + fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US" + + class Child(val process: Process, private val credentials: XboxCredentials) : Closeable { + val credentialSocket: String get() = credentials.name + @Synchronized fun send(frame: JSONObject) { + process.outputStream.write((frame.toString() + "\n").toByteArray()) + process.outputStream.flush() + } + override fun close() { + process.destroy() + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly() + credentials.close() + } + } + + @Synchronized private fun stage(app: Context) { + val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/")) + val packaged = app.assets.open("linuxfs$DIR/version").bufferedReader().use { it.readText() } + if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return + fun copy(relative: String) { + val entries = app.assets.list("linuxfs$DIR/$relative").orEmpty() + if (entries.isNotEmpty()) { entries.forEach { copy(if (relative.isEmpty()) it else "$relative/$it") }; return } + if (relative == "version") return + val file = File(root, relative) + file.parentFile?.mkdirs() + val staged = File(file.path + ".staged") + app.assets.open("linuxfs$DIR/$relative").use { input -> staged.outputStream().use { input.copyTo(it) } } + staged.setExecutable(true, false) + check(staged.renameTo(file)) + } + copy("") + File(root, "version").writeText(packaged) + } + + fun start(app: Context, args: List, service: Boolean = false): Child { + check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." } + stage(app) + val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}" + check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." } + val credentials = XboxCredentials(app) + try { + val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() } + val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf( + "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off", + "XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary, + ) + args) + val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app)) + builder.environment().apply { + remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH") + put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path) + put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path) + if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + return Child(builder.start(), credentials) + } catch (e: Exception) { credentials.close(); throw e } + } + + fun run(app: Context, args: List, timeout: Long = 90): JSONObject { + start(app, args).use { child -> + val output = java.io.ByteArrayOutputStream() + var readError: Throwable? = null + val reader = Thread({ + try { + val buffer = ByteArray(8192) + child.process.inputStream.use { input -> + while (true) { + val count = input.read(buffer) + if (count < 0) break + check(output.size() + count <= 8_000_000) + output.write(buffer, 0, count) + } + } + } catch (t: Throwable) { readError = t; child.process.destroy() } + }, "xbox-result").apply { start() } + val diagnostic = diagnostics(child) + check(child.process.waitFor(timeout, TimeUnit.SECONDS)) { "Xbox request timed out. Try again." } + reader.join(3000); diagnostic.join(3000) + check(readError == null && !reader.isAlive) { "Xbox returned an invalid response." } + check(child.process.exitValue() == 0) { "Xbox request failed. Check your account and network connection." } + return JSONObject(output.toString("UTF-8")) + } + } + + fun diagnostics(child: Child): Thread = Thread({ + runCatching { + child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> + if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\."))) + com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line") + } } + } + }, "xbox-diagnostics").apply { start() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index 8a2222017..e913dc6af 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -29,6 +29,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8)) Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD)) Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) + Store.XBOX.id -> SourceColours(Color(0xFF17381B), Color(0xFFC6EDC2), Color(0xFF70C850)) Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) @@ -41,6 +42,7 @@ internal fun sourceLabel(source: String): String = when (source) { Store.GOG.id -> Store.GOG.label Store.EPIC.id -> Store.EPIC.label Store.AMAZON.id -> Store.AMAZON.label + Store.XBOX.id -> Store.XBOX.label Store.ITCH.id -> Store.ITCH.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index 151a487dc..8208cad30 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -97,7 +97,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder) download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location - item?.owned == true -> stringResource(R.string.stores_eyebrow_library) + item?.subscription == true -> "PC Game Pass" + item?.accessible == true -> stringResource(R.string.stores_eyebrow_library) else -> stringResource(R.string.stores_eyebrow_store) } val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: "" @@ -118,12 +119,20 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro when { installed != null -> { val gone = StoresState.uninstalling["${store.id}:$id"] - PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { + PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) { launchStoreGame(ctx, store, id, s, a) } ActionChip(stringResource(R.string.stores_in_steam), ok = true) + if (store == Store.XBOX) { + SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) { + com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) + } + SecondaryButton("Manage content", enabled = download == null) { + com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed) + } + } if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {} - else UninstallButton(title, installed, enabled = download == null) { + else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) { StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() } } } @@ -143,11 +152,11 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key) } } - item?.owned == true && StoresState.isUnfinished(item) -> { + item?.accessible == true && StoresState.isUnfinished(item) -> { InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } } - item?.owned == true -> InstallButton( + item?.accessible == true -> InstallButton( if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), leadOn, ) { StoresState.requestInstall(ctx, item) } @@ -169,10 +178,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty() .takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty() - val achievements = remember(store, id, item?.owned, installed) { + val achievements = remember(store, id, item?.accessible, installed) { when { StoreAchievements.forSource(store.id) == null -> null - item?.owned == true -> StoreAchievements.Game.of(item) + item?.accessible == true -> StoreAchievements.Game.of(item) installed != null -> StoreAchievements.Game.of(installed.sidecar) else -> null } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 3933450d4..1feb8e280 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -155,12 +155,41 @@ internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) T internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library" -private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean) +private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean) @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) @Composable internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val ctx = LocalContext.current + com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }, + title = { Text("Xbox content") }, + text = { Column { + if (content.error != null) Text(content.error) + else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.") + content.offers.forEach { offer -> + androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) { + Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install") + } + } + } }, + confirmButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }) { Text(stringResource(android.R.string.ok)) } }, + ) + } + com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }, + title = { Text("Choose PC content") }, + text = { Column { pending.choices.forEach { choice -> + androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) { + Text(choice.getString("title") + " (" + choice.getString("kind") + ")") + } + } } }, + confirmButton = {}, + dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } }, + ) + } val colors = MaterialTheme.colorScheme val narrow = LocalNarrowPane.current val padH = if (narrow) 14.dp else 22.dp @@ -175,6 +204,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier val pane = PaneKey( chip, if (store == null) "" else shownTab, store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true), + store == null || StoresState.accountsLoaded, ) val grids = remember { HashMap() } val grid = grids.getOrPut(pane) { LazyGridState() } @@ -362,6 +392,9 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier when { k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) } st == null -> {} + !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) { + androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp)) + } !k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) } else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) }) } @@ -541,10 +574,11 @@ private fun StoreChips( Box( Modifier.size(12.dp) .onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() } - .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot), - ) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) } + .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot) + .background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape), + ) }, - description = store.label + if (signedIn) "" else " " + stringResource(R.string.stores_chip_signed_out), + description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), onFocused = onFocusChip, ) { onStore(store, rootBounds[store.id]) } } @@ -737,7 +771,7 @@ private fun Storefront( val q = query.trim().lowercase() val foundKeys = remember(found) { found.map { it.key }.toSet() } fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys - fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned + fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible fun shelf(l: List?) = l?.filter(::shown) val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } } LaunchedEffect(store, q) { StoresState.search(ctx, store, q) } @@ -788,7 +822,7 @@ private fun Storefront( CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await() requests.forEach { (item, future) -> val bytes = future.getNow(null) ?: return@forEach - if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes + if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes } resolvedSizeSortKey = candidateKeys } @@ -993,6 +1027,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette { Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1) Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD) Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800) + Store.XBOX -> Color(0xFF70C850) to Color(0xFF107C10) Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) null -> return base } @@ -1259,7 +1294,7 @@ private fun GameCard( modifier = Modifier.fillMaxWidth() .onGloballyPositioned { c -> placed[0] = c - if (item.owned && item.sizeBytes <= 0) { + if (item.accessible && item.sizeBytes <= 0) { val b = c.boundsInWindow() if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item) } @@ -1352,9 +1387,9 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp)) } download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) - item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) - item.owned && compact -> Unit - item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } + item.accessible && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1) + item.accessible && compact -> Unit + item.accessible -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) } item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1) item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true) @@ -1430,9 +1465,16 @@ internal fun openUrl(ctx: android.content.Context, url: String) { internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) { val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id } - if (game == null) { - android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() + if (game != null) { + com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } return } - com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) } + val app = ctx.applicationContext + Thread({ + val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull() + StoresState.post { + if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show() + else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) } + } + }, "store-play").start() } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt index aed81f155..e0c657a14 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt @@ -112,6 +112,25 @@ class StoreAccountsCipherTest { assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString()) } + @Test fun xboxNeverFallsBackToPlaintextWhenKeystoreFails() { + StoreAccounts.keys = broken + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret"))) + } + assertFalse(file(Store.XBOX).exists()) + StoreAccounts.keys = fake + StoreAccounts.write(app, Store.XBOX, creds) + assertTrue(CredentialCipher.isEnvelope(JSONObject(file(Store.XBOX).readText()))) + } + + @Test fun xboxUsernameAndBackendSlotsAreEncryptedTogether() { + val xbox = JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret")) + StoreAccounts.write(app, Store.XBOX, xbox) + assertFalse(file(Store.XBOX).readText().contains("synthetic-secret")) + assertFalse(file(Store.XBOX).readText().contains("test@example.test")) + assertEquals("test@example.test", StoreAccounts.signedInAs(app, Store.XBOX)) + } + @Test fun signOutRemovesTheFile() { StoreAccounts.write(app, Store.GOG, creds) StoreAccounts.clear(app, Store.GOG) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt new file mode 100644 index 000000000..898e074f6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt @@ -0,0 +1,56 @@ +package com.droiddeck.launcher.stores + +import android.app.Application +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class StoreLaunchTest { + private val app get() = RuntimeEnvironment.getApplication() + + private fun install(state: String = StoreGameSidecar.STATE_INSTALLED): File { + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX), "Deep Rock Galactic") + File(folder, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("licensed image") } + StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe", state = state).write(folder) + return folder + } + + @Test fun aCompletedInstallCanLaunchBeforeTheFrontendLibraryRefreshes() { + val folder = install() + val game = StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")!! + val added = AddedGames.single(app, folder.path)!! + assertEquals("xbox", game.source) + assertEquals("9NHFVWX1V7QJ", game.storeId) + assertEquals(folder, game.gameFiles) + assertEquals(added.gameId, game.gameId) + assertTrue(added.guestExe.endsWith("/FSD/Binaries/WinGDK/FSD.exe")) + } + + @Test fun unfinishedPackagesCannotBecomePlayableThroughTheFallback() { + for (state in listOf(StoreGameSidecar.STATE_INSTALLING, StoreGameSidecar.STATE_UPDATING)) { + install(state) + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")) + } + } + + @Test fun anotherStoreOrProductCannotUseTheXboxInstall() { + install() + assertNull(StoreLaunch.installedGame(app, Store.GOG, "9NHFVWX1V7QJ")) + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "OTHERPRODUCT")) + } + + @Test fun aMissingExecutableDoesNotBecomeALaunchTarget() { + val folder = install() + File(folder, "FSD/Binaries/WinGDK/FSD.exe").delete() + assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt new file mode 100644 index 000000000..5eb1f2e3c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -0,0 +1,77 @@ +package com.droiddeck.launcher.stores.xbox + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxCatalogTest { + private val now = Instant.parse("2026-10-19T12:00:00Z") + private val game = "9AAAAAAAAAAA" + private val parent = "9BBBBBBBBBBB" + private val plan = "CFQ7TTC0KGQ8" + private fun row(id: String, parent: String? = null) = JSONObject().put("productId", id).put("status", "Active") + .put("isTrial", false).put("special", false).put("sharingSource", "None") + .put("endDate", "2099-01-01T00:00:00Z").put("satisfiedByProductId", parent ?: JSONObject.NULL) + private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test") + .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList())) + private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) + + @Test fun purchasedAndSubscriptionAccessRemainDistinct() { + assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan))) + assertFalse(access(row(plan)).containsKey(plan)) + } + + @Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() { + val checked = "2026-10-19T19:02:38.173609086+00:00" + val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00")) + .put("checkedAt", checked) + assertEquals(mapOf(game to "owned"), XboxCatalog.access(response, "test@example.test", "US", + Instant.parse("2026-10-19T19:02:39Z"))) + } + + @Test fun expiredTrialSharedAndUnknownFlagsDoNotGrantAccess() { + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to "2020-01-01T00:00:00Z")) { + assertTrue(access(row(game).put(key, value)).isEmpty()) + } + } + + @Test fun malformedAndConflictingParentsCannotGrantDependentAccess() { + assertTrue(access(row(game, parent), row(parent).put("isTrial", JSONObject.NULL)).isEmpty()) + assertTrue(access(row(game, plan), row(plan).put("endDate", "invalid")).isEmpty()) + assertTrue(access(row(game), row(game, plan)).isEmpty()) + assertTrue(access(row(game, parent), row(parent, game)).isEmpty()) + } + + @Test fun mismatchedAccountAndStaleResultsAreRejected() { + for (json in listOf(envelope(row(game)).put("account", "other@example.test"), + envelope(row(game)).put("checkedAt", "2020-01-01T00:00:00Z"))) { + assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun installedSubscriptionDoesNotBecomeOwnershipWithoutACachedLibrary() { + val sidecar = com.droiddeck.launcher.stores.StoreGameSidecar(com.droiddeck.launcher.stores.Store.XBOX, + game, "A game", "Game.exe", extra = mapOf("access" to "gamepass")) + val card = com.droiddeck.launcher.stores.installedCards(com.droiddeck.launcher.stores.Store.XBOX, + listOf(com.droiddeck.launcher.stores.InstalledStoreGame(sidecar, java.io.File("/synthetic-game"))), emptyList()).single() + assertFalse(card.owned); assertTrue(card.subscription) + } + + @Test fun consoleAndUwpOffersAreFilteredOut() { + fun product(platform: String, format: String) = JSONObject().put("ProductId", game).put("ProductType", "Game") + .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A PC game"))) + .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject() + .put("Packages", JSONArray().put(JSONObject().put("PackageFormat", format).put("PlatformDependencies", + JSONArray().put(JSONObject().put("PlatformName", platform))))))))) + assertNull(XboxCatalog.product(product("Xbox.One", "MSIXVC"), mapOf(game to "owned"))) + assertNull(XboxCatalog.product(product("Windows.Desktop", "APPX"), mapOf(game to "owned"))) + val pc = XboxCatalog.product(product("Windows.Desktop", "MSIXVC"), mapOf(game to "gamepass"))!! + assertEquals(game, XboxCatalog.product(product("Windows.Desktop", "MSIXVC").put("ProductId", game.lowercase()), mapOf(game to "owned"))!!.id) + assertTrue(pc.accessible); assertTrue(pc.subscription); assertFalse(pc.owned) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt new file mode 100644 index 000000000..8d545d20c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.stores.xbox + +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import org.junit.Assert.* +import org.junit.Test + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxLibraryCacheTest { + private val account = "test@example.test" + private val now = 1_792_440_000_000L + private fun game(id: String, subscription: Boolean) = CatalogItem(Store.XBOX, id, "A game", "https://example.test/hero", + tallImageUrl = "https://example.test/poster", description = "Description", developer = "Developer", publisher = "Publisher", + sizeBytes = 12345, owned = !subscription, subscription = subscription, storeUrl = "https://www.xbox.com/games/store/$id", + extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned")) + + @Test fun restartRestoresMetadataWithoutTurningSubscriptionsIntoPurchases() { + val games = listOf(game("9AAAAAAAAAAA", false), game("9BBBBBBBBBBB", true)) + val text = XboxLibraryCache.encode(account, "US", games, now) + val cached = XboxLibraryCache.decode(text, account, "US", now)!! + assertEquals(games, cached.games) + assertFalse(cached.games[1].owned) + assertTrue(cached.games[1].subscription) + } + + @Test fun anotherAccountMarketOrInvalidEnvelopeCannotRestoreTheLibrary() { + val text = XboxLibraryCache.encode(account, "US", listOf(game("9AAAAAAAAAAA", false)), now) + assertNull(XboxLibraryCache.decode(text, "other@example.test", "US", now)) + assertNull(XboxLibraryCache.decode(text, account, "GB", now)) + assertNull(XboxLibraryCache.decode(text.replace("\"schema\":1", "\"schema\":2"), account, "US", now)) + assertNull(XboxLibraryCache.decode("invalid", account, "US", now)) + assertNull(XboxLibraryCache.decode(text, account, "US", now - 120_000)) + } + + @Test fun freshCacheAvoidsNetworkAndForceOrStalenessRequestsARefresh() { + val cached = XboxLibraryCache.Snapshot(emptyList(), now) + assertFalse(cached.needsRefresh(false, now + 899_999)) + assertTrue(cached.needsRefresh(false, now + 900_000)) + assertTrue(cached.needsRefresh(true, now)) + val old = XboxLibraryCache.decode(XboxLibraryCache.encode(account, "US", emptyList(), now), account, "US", now + 86_400_000)!! + assertTrue(old.needsRefresh(false, now + 86_400_000)) + assertTrue(old.games.isEmpty()) + } + + @Test fun clearingTheCacheRemovesThePersistedAccountLibrary() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + XboxLibraryCache.write(app, account, "US", listOf(game("9AAAAAAAAAAA", false))) + assertNotNull(XboxLibraryCache.read(app, account, "US")) + XboxLibraryCache.clear(app) + assertNull(XboxLibraryCache.read(app, account, "US")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt new file mode 100644 index 000000000..99ab0a0eb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxManifestTest { + @get:Rule val temporary = TemporaryFolder() + @Test fun resolvesTheManifestExecutableWithWindowsLetterCasing() { + val root = temporary.newFolder() + java.io.File(root, "Bin").mkdir() + java.io.File(root, "Bin/Game.exe").writeText("encrypted") + java.io.File(root, "MicrosoftGame.Config").writeText("") + assertEquals("Bin/Game.exe", XboxInstaller.executable(root)) + } + @Test fun rejectsTraversalAndExternalEntities() { + for (xml in listOf("", + "]>&external;", + "", + "]>")) { + val root = temporary.newFolder() + java.io.File(root, "MicrosoftGame.config").writeText(xml) + assertThrows(Exception::class.java) { XboxInstaller.executable(root) } + } + } + @Test fun androidManifestParserPreservesIdentityAndSelectsTheRetailPcExecutable() { + val root = temporary.newFolder() + java.io.File(root, "Retail.exe").writeText("encrypted") + java.io.File(root, "MicrosoftGame.Config").writeText(""" + + + + + + 9AAAAAAAAAAA9BBBBBBBBBBB + """) + val info = XboxManifest.read(root) + assertEquals("9AAAAAAAAAAA", info.storeId) + assertEquals("Test.Game", info.name) + assertEquals("CN=A & B", info.publisher) + assertEquals("1.2.3.0", info.version) + assertEquals("Test.Base", info.dependency) + assertEquals(listOf("9BBBBBBBBBBB"), info.related) + assertEquals(listOf("Retail.exe"), info.executables) + assertEquals("Retail.exe", XboxManifest.executable(root)) + } + @Test fun signInBridgeRejectsUntrustedAndNonHttpsOrigins() { + assertTrue(XboxLoginActivity.trusted("https://login.live.com/ppsecure/InlineLogin.srf")) + for (url in listOf("http://login.live.com/", "https://login.live.com.evil.test/", "https://evil.test@login.live.com/", "https://login.live.com:8443/")) + assertFalse(XboxLoginActivity.trusted(url)) + } +} diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md new file mode 100644 index 000000000..e65ede543 --- /dev/null +++ b/docs/development/xbox-store.md @@ -0,0 +1,106 @@ +# Xbox / PC Game Pass on Thor + +Branch: `feat/xbox-store`. This integrates PC Microsoft Store MSIXVC packages, +not Xbox console emulation or UWP/APPX packages. + +## Testing on Thor + +1. Open DroidDeck, select Stores, then Xbox and Sign in. Use the Microsoft account + that owns the game or has PC Game Pass access. An isolated Microsoft sign-in + window opens; complete its verification and return to DroidDeck. On later + starts, the saved account should restore without another sign-in prompt; + initial account loading uses a neutral progress indicator. +2. Refresh the Xbox library. Check a known owned PC game and, if applicable, a + Game Pass game. Subscription access is labeled PC Game Pass and does not + become permanent ownership. Console-only and unsupported package types are + filtered out. After the first successful sync, closing/reopening DroidDeck + should restore the saved library without waiting for Microsoft. Metadata is + scoped to account and market; on later starts, snapshots older than 15 minutes + refresh quietly in the background. Refresh checks immediately. + Downloading and launching still perform Microsoft licensing. +3. Start with a small licensed title such as LIMBO. Search `LIMBO` if needed; + the verified US Product ID is `9PNG83MDD6SH`. Install through the normal store + download queue. If an offer contains several PC base packages, choose the + intended game. A package plan is not a license: Microsoft licensing must + succeed before files can be installed. +4. Pause during transfer, resume, then repeat after closing/reopening DroidDeck. + It should retain partial files, refresh authenticated URLs and resume only + verified checkpoints. An error or cancelled process must never create an + installed/Play entry. Cancel removes a new installation's partial files. +5. Press Play as soon as installation completes, including while the main library + list is still refreshing. It should resolve the completed installation instead + of showing the "Steam picks the game up when it next starts" toast. + A launch taking over 20 seconds should not produce a duplicate Steam launch + or a "Game already running" dialog while licensing is still preparing it. + Steam/session startup uses the selected ARM64 Proton. + First launch prepares that game's prefix. Check that a window appears, the + game reaches its menu, controller buttons/axes work, and audio is audible. + Record the selected Proton, game/version, and any startup failure. +6. For a game with Xbox identity/cloud saves, check the gamertag, save something, + quit normally, wait for synchronization, then relaunch. Verify the save and, + where available, compare it on another legitimate Xbox PC installation. + In-game identity/cloud/multiplayer success is title-specific. +7. Close the game, then try Update / verify from its Xbox detail page. It uses + fresh licensed streaming and the existing download queue. Interrupted updates + remain resumable; do not judge an interrupted package as a complete install. +8. If you own related PC DLC, open Manage content. Only licensed, unambiguous + MSIXVC content associated with this game's package family is offered. Install + it, then relaunch and check that the game recognizes it. An account with no + such content should get an empty-content message. Base-game uninstall also + removes its managed content. +9. Sign out from Xbox. Installed files should remain, account library data should + clear, and Play/download licensing should require sign-in again. + +Do not uninstall DroidDeck or clear application data to troubleshoot this build. +The signed APK is installed in place to retain existing Steam and store data. + +## Evidence and limits + +Verified during development: + +- Native AArch64 CLI execution on Thor's existing Linux runtime. +- Abstract-socket credential transport with a synthetic account on Thor; this + does not establish a real Microsoft sign-in. +- Real Microsoft sign-in completed on Thor through the isolated Android host. + Its credential file is an Android Keystore AES-GCM envelope. +- Signed-in library refresh on Thor returns 13 compatible PC titles. RFC 3339 + offset timestamps from the backend are parsed by Android's offset-date parser. +- Two force-stop/reopen tests restored all 13 titles from the saved library + without a native Xbox account request. Cache tests cover account/market + isolation, explicit and stale refreshes, sign-out removal and subscription + metadata remaining distinct from purchases. +- Two further cold-start checks observed no sign-in prompt while the saved + account loaded; both restored all 13 titles and the installed launcher. +- Licensed Minecraft Launcher package download and manifest-selected executable + registration on Thor (`Minecraft.exe`, package version `2.6.2.0`). An initial + Android DOM feature failure was fixed with Android's DTD-rejecting pull parser; + retry reused the downloaded files. This launcher install does not validate + gameplay. +- Public LIMBO package resolution on Thor: a Windows.Desktop MSIXVC package. +- Native AMD64 Windows module loading through Thor's ARM64 Proton/FEX, including + the `InitializeApiImplEx2` Xbox runtime export. The original builtin markers + caused error 126; the reproducible native-marker adaptation resolves that. +- Minecraft Launcher's licensed executable was materialized by Xodus, started + through ARM64 Proton/FEX, completed its self-update, and rendered its full + Windows launcher with a Microsoft account displayed on Thor. The account + service remained running with the executable. Minecraft gameplay and in-game + Xbox services remain unverified. +- Deep Rock Galactic's GDK executable also loads through Xodus/ARM64 Proton. + Its default launch crashes in D3D12 ray-tracing pipeline creation. Disabling + ray tracing in Unreal reaches the rendered title screen on Thor; this workaround + is applied to `FSD-WinGDK-Shipping.exe`. Gameplay and online services remain + unverified. +- Account-free tests cover purchase/subscription separation, stale/wrong-account + responses, malformed parent licenses, manifest traversal/XXE rejection, + fail-closed encrypted credential writes, download checkpoint behavior, + private temporary launch views, bridge authentication, and ordinary store + launch regressions. + +Game rendering/input/audio, in-game account identity, cloud synchronization, +DLC activation and multiplayer require the account/game tests above. DLL +loading alone is not evidence of a playable game. +Native-module tests use a disposable prefix under +`/data/local/tmp/droiddeck-xbox-probe`; existing game prefixes are not used. + +Build recipes, pinned source revisions, hashes, notices and runtime architecture +are documented in [tools/xbox](../../tools/xbox/README.md). diff --git a/tools/build_local.sh b/tools/build_local.sh index c4c0e07dc..e03c49bef 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -351,6 +351,13 @@ fi cd "${repo_root}" python3 tools/quest/build-driver.py "${NDK}" +xbox_out="${linuxfs_dir}/usr/local/lib/droiddeck-xbox" +xbox_key=$(inputs_hash "native-arm64-ubuntu24.04" tools/xbox) +if ! restore_step xbox "${xbox_key}" "${xbox_out}"; then + bash tools/xbox/build-local.sh + save_step xbox "${xbox_key}" "${xbox_out}" +fi + comet_out="${linuxfs_dir}/usr/local/lib/droiddeck-comet" comet_key=$(inputs_hash "${image_id}" tools/comet) if ! restore_step comet "${comet_key}" "${comet_out}"; then diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 30c8a2555..f83e93ae3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -98,6 +98,12 @@ def godot_sentry(game): return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off" +@fix +def deep_rock_gdk_raytracing(game): + if game.stem == "fsd-wingdk-shipping" and not any(game.has_arg(arg) for arg in ("-raytracing", "-noraytracing")): + return {}, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"], "Deep Rock Galactic GDK: ray tracing off (D3D12 pipeline creation fails on Thor)" + + @fix def arkham_city_x87(game): """Batman: Arkham City (BatmanAC.exe, also started through BmLauncher.exe beside it) leans on x87 @@ -310,6 +316,14 @@ def main(): if mic_enabled == "0": env["BANNER_AUDIO_DIRECT_MIC"] = "0" env = force_ssbs(command, env) + xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox"))) + try: + xbox_result = xbox["launch"](command, env) + except (OSError, ValueError, KeyError, RuntimeError) as error: + print("droiddeck-game-env: Xbox launch could not start (%s)" % error, file=sys.stderr) + return 1 + if xbox_result is not None: + return xbox_result sync = load_sync() launch, launch_env = select_sync(command, env, sync) if launch[0] != command[0]: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index fdadc61ed..1b2ae2f48 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -596,6 +596,7 @@ case $mode in steam_hold_launch() { local game=$1 shortcut= waited=0 status=0 _bl_focused() { xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p'; } + _bl_starting() { pgrep -f "reaper SteamLaunch AppId=$(((game >> 32) & 0xffffffff))( |$)" >/dev/null 2>&1; } _bl_hand_off() { printf '%s\n' "$game" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game" } @@ -610,16 +611,22 @@ case $mode in _bl_hand_off [ -n "$shortcut" ] || return 0 # Steam can drop a shortcut launch it gets before the shortcut's overview exists (large libraries). - # Hand it over again only if the game never took focus, so a launch that worked is not started twice. + # A launch can be running before it creates its first window. while [ "$(_bl_focused)" = 769 ] && [ "$waited" -lt 20 ]; do sleep 1 waited=$((waited + 1)) done [ "$(_bl_focused)" = 769 ] || return 0 + if _bl_starting; then + echo "== steam: the requested game is already starting; not launching again" + return 0 + fi echo "== steam: the game is not in front after about 20 s; waiting for the shortcut overview" /usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$game" 45 || status=$? if [ "$status" -eq 3 ]; then echo "== steam: not a shortcut CGameID; not launching again" + elif _bl_starting; then + echo "== steam: the requested game started while waiting; not launching again" elif [ "$(_bl_focused)" = 769 ]; then echo "== steam: launching the requested game again $game (overview status $status)" _bl_hand_off diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox new file mode 100755 index 000000000..beec0a65b --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox @@ -0,0 +1,183 @@ +#!/usr/bin/python3 +"""Licensed Xbox launches through Xodus and the game's selected ARM64 Proton.""" +import hmac +import json +import os +from pathlib import Path +import runpy +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import threading + +BIN = Path(__file__).resolve().parent +RUNTIME = Path('/usr/local/lib/droiddeck-xbox') +DLLS = ('xgameruntime.dll', 'winhttp.dll', 'windows.web.dll', 'twinapi.appcore.dll', 'libnghttp2.dll') + + +def bridge(upstream): + key = secrets.token_hex(32).encode('ascii') + server = socket.socket() + server.bind(('127.0.0.1', 0)) + server.listen(16) + clients = set() + lock = threading.Lock() + stopped = threading.Event() + + def forward(source, target): + try: + while not stopped.is_set(): + data = source.recv(65536) + if not data: + break + target.sendall(data) + except OSError: + pass + finally: + try: + target.shutdown(socket.SHUT_WR) + except OSError: + pass + + def connect(client): + remote = None + try: + client.settimeout(10) + supplied = b'' + while len(supplied) < len(key): + part = client.recv(len(key) - len(supplied)) + if not part: + return + supplied += part + if not hmac.compare_digest(supplied, key): + return + remote = socket.socket(socket.AF_UNIX) + remote.connect(upstream) + client.settimeout(None) + with lock: + clients.add(remote) + writer = threading.Thread(target=forward, args=(client, remote), daemon=True) + writer.start() + forward(remote, client) + writer.join(2) + except OSError: + pass + finally: + for sock in (client, remote): + if sock is not None: + with lock: + clients.discard(sock) + sock.close() + + def accept(): + while not stopped.is_set(): + try: + client, _ = server.accept() + except OSError: + break + with lock: + if len(clients) >= 64: + client.close() + continue + clients.add(client) + threading.Thread(target=connect, args=(client,), daemon=True).start() + + threading.Thread(target=accept, daemon=True).start() + + def close(): + stopped.set() + server.close() + with lock: + for client in clients: + try: + client.shutdown(socket.SHUT_RDWR) + except OSError: + pass + client.close() + clients.clear() + + return server.getsockname()[1], key.decode('ascii'), close + + +def wine(argv): + with open(os.environ['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file: + command = json.load(file) + if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1: + raise ValueError('Invalid Xbox Proton invocation') + env = dict(os.environ) + env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None) + env.pop('DROIDDECK_XBOX_COMMAND_FILE', None) + prefix = Path(os.environ['STEAM_COMPAT_DATA_PATH']) / 'pfx' + prefix.parent.mkdir(parents=True, exist_ok=True) + if not (prefix / 'system.reg').is_file(): + result = subprocess.run([command[0], 'run', 'cmd', '/c', 'exit', '0'], stdout=sys.stderr, env=env) + if result.returncode: + return result.returncode + system = prefix / 'drive_c/windows/system32' + system.mkdir(parents=True, exist_ok=True) + for name in DLLS: + target = system / name + if target.is_symlink(): + target.unlink() + shutil.copyfile(RUNTIME / 'windows' / name, target) + overrides = ';'.join(name[:-4] + '=n' for name in DLLS if name != 'libnghttp2.dll') + env['WINEDLLOVERRIDES'] = overrides + ';' + env.get('WINEDLLOVERRIDES', '') + return subprocess.call([*command[:2], argv[0], *command[3:]], env=env) + + +def launch(command, env): + if len(command) < 3 or command[1] != 'waitforexitandrun' or env.get('DROIDDECK_XBOX_ACTIVE') == '1': + return None + store = runpy.run_path(str(BIN / 'droiddeck-store-launch')) + found = store['find_store_game'](command[2:]) + if found is None or found[1].get('store') != 'xbox': + return None + folder, sidecar = found + for name in DLLS: + if not (RUNTIME / 'windows' / name).is_file(): + raise RuntimeError('Xbox Windows runtime is missing; update the Linux runtime') + launch_dir = env.get('BL_LAUNCH_DIR') + if not launch_dir: + raise RuntimeError('No Xbox account channel; start the game from DroidDeck') + reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=95) + if not reply or not reply.get('ok'): + raise RuntimeError('Xbox account service is unavailable; sign in again and retry') + port, key, close = bridge(reply['socket']) + try: + with tempfile.TemporaryDirectory(prefix='droiddeck-xbox-') as temporary: + context = Path(temporary) / 'command.json' + context.write_text(json.dumps(command), encoding='utf-8') + context.chmod(0o600) + owned_env = dict(env, DROIDDECK_XBOX_ACTIVE='1', DROIDDECK_XBOX_COMMAND_FILE=str(context), + DROIDDECK_XBOX_CREDENTIAL_SOCKET=reply['credentials'], + XODUS_SOCKET=reply['socket'], XODUS_BRIDGE_PORT=str(port), XODUS_BRIDGE_KEY=key, + XODUS_LOG='off', RUST_LOG='off', XODUS_SAVE_SYNC='auto' if sidecar.get('cloud', True) else 'off') + content = folder / '.xbox-content' + roots = [] + if content.is_dir(): + for entry in content.iterdir(): + try: + marker = json.loads((entry / '.droiddeck-content.json').read_text()) + if marker.get('parent') == sidecar['id'] and marker.get('productId') == entry.name and not entry.is_symlink(): + roots.append('Z:' + str(entry).replace('/', '\\')) + except (OSError, ValueError, TypeError): + pass + if roots: + owned_env['XODUS_CONTENT_ROOTS'] = ';'.join(roots) + market = sidecar.get('extra', {}).get('market', 'US') + return subprocess.call([str(RUNTIME / 'xodus-cli'), 'run', str(folder), str(BIN / 'droiddeck-xbox'), + '--exe', sidecar['exe'], '--materialize-chain', '--market', market], env=owned_env) + finally: + close() + store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'id': sidecar['id']}) + + +if __name__ == '__main__': + try: + sys.exit(wine(sys.argv[1:])) + except (OSError, ValueError, KeyError, RuntimeError): + print('droiddeck-xbox: Xbox launch failed; check your account, runtime and package.', file=sys.stderr) + sys.exit(1) diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index a539e46c4..fefef3eab 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -15,7 +15,7 @@ MANIFEST = Path('app/build/runtime-inputs.json') SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst', 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', - 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/build_local.sh', + 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/xbox', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', @@ -89,6 +89,9 @@ def required(): ['mangoapp', 'libfmt.so.10', 'libspdlog.so.1.13', 'libglfw.so.3', 'libtraceevent.so.1', 'libtracefs.so.1']] paths += ['assets/linuxfs/usr/local/lib/droiddeck-comet/' + name for name in ['comet', 'GalaxyCommunication.exe', 'update-permissions.exe', 'LICENSE', 'version']] + paths += ['assets/linuxfs/usr/local/lib/droiddeck-xbox/' + name for name in + ['xodus-cli', 'xodus-service', 'version', 'backend.lock.json', 'runtime.lock.json', 'LICENSE.xodus', 'LICENSE.nexus', 'LICENSE.LGPL-2.1', 'LICENSE.nghttp2', + 'windows/xgameruntime.dll', 'windows/winhttp.dll', 'windows/windows.web.dll', 'windows/twinapi.appcore.dll', 'windows/libnghttp2.dll']] for driver in ['linux-wine11', 'linux-wine11-systhread']: paths += ['assets/directaudio/' + driver + '/' + name for name in ['aarch64-unix/winedirectaudio.so', 'aarch64-windows/winedirectaudio.drv', diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 6757d46d6..eaef4c6d0 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -179,6 +179,17 @@ class GameEnvironmentTest(unittest.TestCase): (other / "Other.exe").touch() self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], [])) + def test_deep_rock_gdk_avoids_the_raytracing_pipeline_crash(self): + game = "/game/FSD/Binaries/WinGDK/FSD-WinGDK-Shipping.exe" + env, extra, notes = MODULE["engine_fixes"](game, []) + self.assertEqual(extra, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"]) + self.assertEqual(env, {}) + self.assertTrue(notes) + for choice in ("-raytracing", "-noraytracing"): + self.assertEqual(MODULE["engine_fixes"](game, [choice]), ({}, [], [])) + self.assertEqual(MODULE["engine_fixes"]("/game/FSD-Win64-Shipping.exe", []), ({}, [], [])) + self.assertEqual(MODULE["engine_fixes"]("/game/Other-WinGDK-Shipping.exe", []), ({}, [], [])) + def test_arkham_city_gets_full_x87_precision_over_the_preset(self): with tempfile.TemporaryDirectory() as tmp: city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32" diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py index 2c80a2546..162037eba 100644 --- a/tools/tests/test_steam_cold_launch.py +++ b/tools/tests/test_steam_cold_launch.py @@ -1,4 +1,4 @@ -"""Requested Steam games are handed over as soon as the interface is up; a shortcut is handed over again only if it never took focus.""" +"""Retry a dropped shortcut without duplicating a launch that is still preparing its first window.""" import os from pathlib import Path import subprocess @@ -48,14 +48,21 @@ class SteamColdLaunchTest(unittest.TestCase): sleep = self.bin / "sleep" sleep.write_text("#!/bin/sh\nprintf '%s\n' \"$@\" >> \"$SLEEP_ARGS\"\n") sleep.chmod(0o755) + pgrep = self.bin / "pgrep" + pgrep.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$@\" > \"$HOME/pgrep-args\"\n" + "n=$(cat \"$HOME/pgrep-count\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$HOME/pgrep-count\"\n" + "[ \"$n\" -ge \"${RUNNING_AFTER:-999}\" ]\n") + pgrep.chmod(0o755) self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent)) - def run_launch(self, target, focus="769", helper_status=0): + def run_launch(self, target, focus="769", helper_status=0, running_after=999): env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status), - FOCUS=focus, FOCUS_COUNT=str(self.focus_count)) + FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after)) script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n" url = target if target.startswith("steam://") else "steam://rungameid/" + target result = subprocess.run(["bash", "-c", script, "cold-launch", url], @@ -85,6 +92,20 @@ class SteamColdLaunchTest(unittest.TestCase): self.assertEqual(2, len(self.hand_offs(result)), result.stdout) self.assertIn("overview status 1", result.stdout) + def test_a_game_still_starting_without_a_window_is_not_launched_twice(self): + result = self.run_launch(SHORTCUT, running_after=1) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertFalse(self.agent_args.exists()) + self.assertEqual(["-f", "reaper SteamLaunch AppId=3551182975( |$)"], + (self.root / "pgrep-args").read_text().splitlines()) + self.assertIn("already starting", result.stdout) + + def test_a_game_starting_during_the_overview_wait_is_not_launched_twice(self): + result = self.run_launch(SHORTCUT, running_after=2) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertTrue(self.agent_args.exists()) + self.assertIn("started while waiting", result.stdout) + def test_a_game_that_takes_focus_during_the_overview_wait_is_not_started_twice(self): focus = " ".join(["769"] * 23) + " 4242" result = self.run_launch(SHORTCUT, focus=focus, helper_status=0) diff --git a/tools/tests/test_xbox_launch.py b/tools/tests/test_xbox_launch.py new file mode 100644 index 000000000..7419dd62c --- /dev/null +++ b/tools/tests/test_xbox_launch.py @@ -0,0 +1,73 @@ +import json +import os +from pathlib import Path +import runpy +import socket +import tempfile +import threading +import unittest +from unittest.mock import patch + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +xbox = runpy.run_path(str(BIN / 'droiddeck-xbox')) + + +class XboxLaunchTest(unittest.TestCase): + def test_other_stores_keep_their_existing_launch(self): + with tempfile.TemporaryDirectory() as root: + folder = Path(root) + (folder / '.droiddeck-store.json').write_text(json.dumps({'store': 'gog', 'id': '1'})) + self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', str(folder / 'Game.exe')], {})) + + def test_bridge_authenticates_before_forwarding(self): + with tempfile.TemporaryDirectory() as root: + path = str(Path(root) / 'service.sock') + upstream = socket.socket(socket.AF_UNIX) + upstream.bind(path); upstream.listen(1); upstream.settimeout(3) + received = [] + def serve(): + connection, _ = upstream.accept() + with connection: + received.append(connection.recv(8)) + connection.sendall(b'reply') + worker = threading.Thread(target=serve); worker.start() + port, key, close = xbox['bridge'](path) + try: + with socket.create_connection(('127.0.0.1', port), timeout=2) as denied: + denied.sendall(b'x' * 64) + self.assertEqual(denied.recv(1), b'') + with socket.create_connection(('127.0.0.1', port), timeout=2) as client: + client.sendall(key[:20].encode()) + client.sendall(key[20:].encode() + b'request') + self.assertEqual(client.recv(5), b'reply') + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertEqual(received, [b'request']) + finally: + close(); upstream.close() + + def test_wine_wrapper_preserves_selected_proton_args_and_prefix_isolation(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + prefix = root / 'compat/pfx' + prefix.mkdir(parents=True) + runtime = root / 'runtime/windows'; runtime.mkdir(parents=True) + for name in xbox['DLLS']: + (runtime / name).write_bytes(b'MZ-synthetic') + command = root / 'command.json' + command.write_text(json.dumps(['/selected/proton', 'waitforexitandrun', '/encrypted/Game.exe', '--custom', 'two words'])) + env = {'STEAM_COMPAT_DATA_PATH': str(prefix.parent), 'DROIDDECK_XBOX_COMMAND_FILE': str(command), + 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'private-account-channel', 'XODUS_BRIDGE_KEY': 'runtime-key'} + with patch.dict(os.environ, env), patch.dict(xbox['wine'].__globals__, RUNTIME=runtime.parent), patch('subprocess.run') as initialize, patch('subprocess.call', return_value=7) as call: + initialize.return_value.returncode = 0 + self.assertEqual(xbox['wine'](['/private-view/Game.exe']), 7) + argv = call.call_args.args[0] + self.assertEqual(argv, ['/selected/proton', 'waitforexitandrun', '/private-view/Game.exe', '--custom', 'two words']) + self.assertIn('xgameruntime=n', call.call_args.kwargs['env']['WINEDLLOVERRIDES']) + self.assertEqual((prefix / 'drive_c/windows/system32/xgameruntime.dll').read_bytes(), b'MZ-synthetic') + initialize.assert_called_once() + for child in (initialize, call): + child_env = child.call_args.kwargs['env'] + self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', child_env) + self.assertNotIn('DROIDDECK_XBOX_COMMAND_FILE', child_env) + self.assertEqual(child_env['XODUS_BRIDGE_KEY'], 'runtime-key') diff --git a/tools/xbox/LICENSE.LGPL-2.1 b/tools/xbox/LICENSE.LGPL-2.1 new file mode 100644 index 000000000..732811e4f --- /dev/null +++ b/tools/xbox/LICENSE.LGPL-2.1 @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/tools/xbox/LICENSE.nexus b/tools/xbox/LICENSE.nexus new file mode 100644 index 000000000..6c103f8ac --- /dev/null +++ b/tools/xbox/LICENSE.nexus @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Xodus Nexus contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/xbox/LICENSE.nghttp2 b/tools/xbox/LICENSE.nghttp2 new file mode 100644 index 000000000..80201792e --- /dev/null +++ b/tools/xbox/LICENSE.nghttp2 @@ -0,0 +1,23 @@ +The MIT License + +Copyright (c) 2012, 2014, 2015, 2016 Tatsuhiro Tsujikawa +Copyright (c) 2012, 2014, 2015, 2016 nghttp2 contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tools/xbox/LICENSE.xodus b/tools/xbox/LICENSE.xodus new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/tools/xbox/LICENSE.xodus @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/tools/xbox/README.md b/tools/xbox/README.md new file mode 100644 index 000000000..bb4e8ec3f --- /dev/null +++ b/tools/xbox/README.md @@ -0,0 +1,79 @@ +# Xbox backend for DroidDeck + +DroidDeck uses the Nexus backend protocol with a native Linux AArch64 CLI and +service. It does not install the Nexus desktop launcher or x86 Linux Wine. +`backend.lock.json` pins Xodus, the Nexus commit, Rust, and the ordered patch +checksums. `runtime.lock.json` pins the Nexus Windows runtime release and each +extracted PE DLL. `prepare.py` verifies both source revisions and all Nexus patch +checksums before applying the two local transport/launch patches. + +Build on AArch64 Ubuntu 24.04 with Rust from the lock, a C/C++ compiler, OpenSSL +development headers, pkg-config, protoc, CMake, Git, Python 3 and xz: + +```sh +bash tools/xbox/build.sh +``` + +On macOS, `bash tools/xbox/build-local.sh` uses an AArch64 Ubuntu container. +`tools/build_local.sh` includes this step and caches it by recipe inputs. CI +builds the same recipe on `ubuntu-24.04-arm` and transfers the result to the APK +job. Cache hits still download that build's Xbox artifact before APK assembly. +The build runs account-free CLI, credential and streaming tests. It retains the +patched backend source archive and the checksum-verified runtime source archive +in the build cache and publishes both in CI's `xbox-corresponding-source` +artifact; distribute corresponding sources with published binaries. + +`droiddeck-credentials.patch` replaces Linux Secret Service with a bounded, +UID-checked Android abstract socket. The Android host encrypts every persistent +write using its existing Keystore cipher, verifies backend read-backs, and +rejects writes when Keystore is unavailable. No token values enter process +arguments, external storage, application logs or a plaintext credential file. +Account-generation invalidation prevents a signed-out child's later writes from +restoring credentials. Each process gets its own unguessable socket name. +The Proton initialization and game processes do not inherit the raw credential +channel or command-file environment variables. The authenticated GDK bridge +remains available to the game. Like the other stores, games share DroidDeck's +Linux environment and Android UID; this is not a sandbox against malicious games. + +Microsoft sign-in uses Nexus's private stdio protocol in an isolated Android +WebView process. Its page bridge is restricted to the allowed Microsoft/Xbox +HTTPS origins and the main frame; it has no credential-store or launcher API. +Cookies and page storage are cleared for each attempt. This protocol requires +an embedded host for CloudExperienceHost messages; opening its URL in an ordinary +Android browser does not complete it. Passkey behavior needs device testing. + +`droiddeck-launch-view.patch` materializes all licensed encrypted images, +including DLLs, in a private temporary directory. Original package files remain +encrypted, and the temporary view is removed after the Wine child exits. Wine +receives normal files with no Xodus FD-map requirement. This costs temporary +storage proportional to the encrypted images. The app keeps the account service +alive for the session; an authenticated loopback bridge carries GDK requests. +Wine's builtin PE marker makes `WINEDLLOVERRIDES=n` reject the Nexus modules. +The extractor verifies their original hashes, then changes only that 16-byte DOS +stub marker to `DroidDeck native`; code, imports and exports stay unchanged. This +lets ARM64EC Wine load the AMD64 modules through its normal native DLL path. +Only each Xbox game's prefix receives these Windows modules. Existing +Steam games and other store prefixes retain their selected runtimes. + +Cloud saves and in-game identity use the reused XGameRuntime/service interfaces; +those are game-dependent. Entitlements distinguish purchases from Game Pass +access, and the backend performs a fresh license check for downloads and launch. +The store lists PC MSIXVC games only. It does not emulate Xbox consoles or add +support for UWP/APPX packages. Update / verify refreshes an installed package through the normal licensed +streaming path. Manage content resolves manifest-related licensed PC DLC into +separate content roots and checks its parent identity after download. These +flows still require game/account validation; runtime API presence does not +establish that they work on Thor. + +Upstream contribution restrictions are respected: these are launcher, storage, +build and file-lifecycle adapters in DroidDeck, not submitted reverse-engineering +contributions to Xodus. + +Sources and licenses: + +- Xodus: https://github.com/xodus-gaming/xodus (GPL-3.0, `LICENSE.xodus`). +- Nexus patches: https://github.com/domsvoboda/xodus-nexus (MIT, `LICENSE.nexus`). +- XGameRuntime/Wine modules: the exact source archive is identified in + `runtime.lock.json` (LGPL-2.1, `LICENSE.LGPL-2.1`). +- `libnghttp2.dll`: nghttp2, MIT; its license is retained in the runtime source + archive and is packaged as `LICENSE.nghttp2`. diff --git a/tools/xbox/backend.lock.json b/tools/xbox/backend.lock.json new file mode 100644 index 000000000..f1568f94d --- /dev/null +++ b/tools/xbox/backend.lock.json @@ -0,0 +1,109 @@ +{ + "schema": 1, + "version": "0.2.8", + "repository": "https://github.com/xodus-gaming/xodus.git", + "commit": "53ea781dcd073e31d7ea68e894134e9c959bff04", + "rust": "1.99.0", + "cargoLockSha256": "2259b343e00dadc263018beb7664b12e9d58a0b73bff502ad566f5f8619ce28a", + "patchId": "linux-run-v24", + "compatibility": "linux-run-v18", + "patches": [ + { + "file": "xodus-linux-run.patch", + "sha256": "68f260ec2c6e9264c47ed1a650868511f5412a756725d4000a865804f4712e8b" + }, + { + "file": "xodus-offline-mirror.patch", + "sha256": "c865fc2559c43a5b3eb2bc841035c076eae5c42a90fe488f90514384aa916986" + }, + { + "file": "xodus-account-status.patch", + "sha256": "e2a718d16957733d140891fcdd26cc8cb3db0636307e6ff6e9ffbfa41a2d393c" + }, + { + "file": "xodus-streaming-cache-flush.patch", + "sha256": "4dd7a129ab9b3aac74d46f926efe26f163741d50fe2853a07f292240ab522b05" + }, + { + "file": "xodus-launch-chain.patch", + "sha256": "22dfbfc5cec911091351bb2d648f4b16bd274aa276ee4524be738a357c8b219a" + }, + { + "file": "xodus-memfd-path.patch", + "sha256": "905cfda813f0a434a2c13fd2db973385f0311cbec5c86bc3549737a7838f49fd" + }, + { + "file": "xodus-real-launch-images.patch", + "sha256": "95fc642e3adfa50414648e40df6ee94262964f5eea0f3762a34765f56d7824b7" + }, + { + "file": "xodus-store-license.patch", + "sha256": "99a89821e15ba144126515b5e8ec70b0673caf694d9295f16cd6661564b7c579" + }, + { + "file": "xodus-store-updates.patch", + "sha256": "554e3849122c8cd3c64c7f5904ac06931a088bc8fb4c522ec2c55b972b72ea8e" + }, + { + "file": "xodus-streaming-progress.patch", + "sha256": "b5e22d8eb595502776c4d6f2a5451403647e31754fc6358bc4f338cab72642a0" + }, + { + "file": "xodus-case-insensitive-paths.patch", + "sha256": "53dbfd6f734db100fff958e4a311155cad2cb374db456f73544ad76e3799ac8d" + }, + { + "file": "xodus-account-profile.patch", + "sha256": "135166ff0cf9f95af92dad6fd1c958b6c57e939fbb5b398b5a603b730541554a" + }, + { + "file": "xodus-subscription-login.patch", + "sha256": "6c3f32c905039948b3ff95a0ceaba9b2a09a9417606653bce9b741c8bba43eb0" + }, + { + "file": "xodus-external-auth.patch", + "sha256": "0a22d5a84d328780e6f13ac07f8e1db6359ec70139ad4262e4c1ee988973e20e" + }, + { + "file": "xodus-keyring-activation.patch", + "sha256": "8c71928e0b365f4770532ef968322450f1ebab3a1199e7a10d894119c48c925e" + }, + { + "file": "xodus-package-selection.patch", + "sha256": "7507cb0535cd914d3607e7940d5e17b949d106d0b89c139b84372a1ee1732c54" + }, + { + "file": "xodus-package-metadata.patch", + "sha256": "43fccfbb2ea2a9809e7f0b3d9e2f506213e6245d3bbd4c1c631149a67bb2d661" + }, + { + "file": "xodus-license-sku.patch", + "sha256": "b7c93b6ead7120c5b2af3616e80c0dadf56326a8bc81a2526a5e1f2700f58e20" + }, + { + "file": "xodus-keyring-credentials.patch", + "sha256": "8b354f95b864ffd7eadb02f0d54eb1361bb92fde14ff8696fabfea5c43d03811" + }, + { + "file": "xodus-library-resilience.patch", + "sha256": "0136157efe8a1419f91243c496656c4d043422e126c40e4a552e769c53de3545" + }, + { + "file": "xodus-game-activity.patch", + "sha256": "89bfd422f951515dd393eb479901ff9613441c627d139918c233a19ec21f1e06" + }, + { + "file": "xodus-achievements.patch", + "sha256": "55e4b56b943f491da4c9836a5d6655417ed63ab6383576a8e0f153ec986039b0" + }, + { + "file": "xodus-session-recovery.patch", + "sha256": "fa67d768214247a5955716b9691616b45330ebda504703bf372fdda2252b0831" + }, + { + "file": "xodus-content-response.patch", + "sha256": "e9997951526cfa08e3dd8c5fc4c383bf630e7ab8992d9e211acee40fdc6422a4" + } + ], + "nexusCommit": "396e7fb8fe0e5f00d956091195c614b8a0732ab1" +} diff --git a/tools/xbox/build-local.sh b/tools/xbox/build-local.sh new file mode 100755 index 000000000..6a3eb90ce --- /dev/null +++ b/tools/xbox/build-local.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +cache=${DROIDDECK_XBOX_CACHE:-"${HOME}/.cache/droiddeck-xbox-build"} +mkdir -p "$cache" +docker run --rm --platform linux/arm64 -v "$repo:/src" -v "$cache:/work" -w /src \ + -e CARGO_HOME=/work/cargo -e RUSTUP_HOME=/work/rustup -e XBOX_BUILD_DIR=/work/build ubuntu:24.04 bash -lc ' + set -euo pipefail + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + build-essential pkg-config libssl-dev protobuf-compiler cmake curl git ca-certificates xz-utils python3 >/dev/null + export PATH=/work/cargo/bin:$PATH + if ! command -v rustup >/dev/null; then + version=$(python3 -c '\''import json;print(json.load(open("tools/xbox/backend.lock.json"))["rust"])'\'') + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "$version" + fi + bash tools/xbox/build.sh +' diff --git a/tools/xbox/build.sh b/tools/xbox/build.sh new file mode 100755 index 000000000..f5598aee1 --- /dev/null +++ b/tools/xbox/build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +cache=${XBOX_BUILD_DIR:-"$repo/app/build/xbox"} +out=${1:-"$repo/app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox"} +mkdir -p "$cache" "$out" +source_dir=$(python3 "$repo/tools/xbox/prepare.py" "$cache") +rust_version=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["rust"])' "$repo/tools/xbox/backend.lock.json") +export RUSTUP_TOOLCHAIN=$rust_version +export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"$cache/target"} +rustup target add aarch64-unknown-linux-gnu +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER:-aarch64-linux-gnu-gcc} +if [[ $(uname -m) = aarch64 ]]; then export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=cc; fi +cd "$source_dir" +cargo build --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus-cli -p xodus-service +if [[ $(uname -m) = aarch64 ]]; then + cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus-cli + cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p xodus credential_ + cargo test --locked --release --no-default-features --target aarch64-unknown-linux-gnu -p msixvc streaming_ +fi +for binary in xodus-cli xodus-service; do + install -m755 "$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/$binary" "$out/$binary" +done +python3 "$repo/tools/xbox/runtime.py" "$cache/runtime" "$out/windows" +install -m644 "$repo/tools/xbox/LICENSE.xodus" "$out/LICENSE.xodus" +install -m644 "$repo/tools/xbox/LICENSE.nexus" "$out/LICENSE.nexus" +install -m644 "$repo/tools/xbox/LICENSE.LGPL-2.1" "$out/LICENSE.LGPL-2.1" +install -m644 "$repo/tools/xbox/LICENSE.nghttp2" "$out/LICENSE.nghttp2" +cp "$repo/tools/xbox/"*.lock.json "$out/" +python3 - "$out" "$repo/tools/xbox/droiddeck-credentials.patch" <<'PY' +import hashlib,json,pathlib,sys +out=pathlib.Path(sys.argv[1]) +files={str(p.relative_to(out)):hashlib.sha256(p.read_bytes()).hexdigest() for p in out.rglob('*') if p.is_file() and p.name!='version'} +files['credentialPatch']=hashlib.sha256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest() +(out/'version').write_text(hashlib.sha256(json.dumps(files,sort_keys=True).encode()).hexdigest()+'\n') +PY +tar -cJf "$cache/xodus-droiddeck-source.tar.xz" --exclude=.git --exclude=target -C "$source_dir" . -C "$repo" tools/xbox diff --git a/tools/xbox/droiddeck-credentials.patch b/tools/xbox/droiddeck-credentials.patch new file mode 100644 index 000000000..bf92276e3 --- /dev/null +++ b/tools/xbox/droiddeck-credentials.patch @@ -0,0 +1,154 @@ +--- a/crates/xodus/src/secrets.rs ++++ b/crates/xodus/src/secrets.rs +@@ -1,73 +1,14 @@ +-pub static SERVICE_NAME: &str = "Xodus Service"; ++use crate::tokens::{backend::KeychainBackend, store::{TokenBackend, TokenStoreError}}; + + pub fn init_secrets() -> Result<(), keyring_core::Error> { +- #[cfg(feature = "key-chain-file")] +- { +- let store = keyring_core::sample::Store::new_with_backing( +- secrets_backing_file() +- .to_str() +- .expect("Invalid secrets backing path"), +- )?; +- keyring_core::set_default_store(store); +- } +- +- #[cfg(not(feature = "key-chain-file"))] +- { +- #[cfg(target_os = "linux")] +- { +- activate_desktop_keyring(); +- keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?); +- } +- +- #[cfg(target_os = "macos")] +- { +- keyring_core::set_default_store(apple_native_keyring_store::keychain::Store::new()?); +- } +- +- #[cfg(not(any(target_os = "linux", target_os = "macos")))] +- { +- let store = keyring_core::sample::Store::new_with_configuration( +- &std::collections::HashMap::from([("persist", "true")]), +- )?; +- keyring_core::set_default_store(store); +- } +- } +- ++ if std::env::var_os("DROIDDECK_XBOX_CREDENTIAL_SOCKET").is_none() { return Err(keyring_core::Error::NoEntry); } + Ok(()) + } +- +-// Some KDE installations advertise ksecretd only under its compatibility +-// activation name. Respect an existing/default Secret Service provider first. +-// This starts a packaged user service, never installs or configures a keyring. +-#[cfg(all(target_os = "linux", not(feature = "key-chain-file")))] +-fn activate_desktop_keyring() { +- use dbus::blocking::Connection; +- use std::time::Duration; +- let Ok(connection) = Connection::new_session() else { return; }; +- let bus = connection.with_proxy("org.freedesktop.DBus", "/org/freedesktop/DBus", Duration::from_secs(3)); +- let Ok((owned,)): Result<(bool,), _> = bus.method_call("org.freedesktop.DBus", "NameHasOwner", ("org.freedesktop.secrets",)) else { return; }; +- if owned { return; } +- let Ok((names,)): Result<(Vec,), _> = bus.method_call("org.freedesktop.DBus", "ListActivatableNames", ()) else { return; }; +- if names.iter().any(|name| name == "org.freedesktop.secrets") { return; } +- if names.iter().any(|name| name == "org.kde.secretservicecompat") { +- // On failure, Store::new below still returns the genuine keyring error. +- let _: Result<(u32,), _> = bus.method_call("org.freedesktop.DBus", "StartServiceByName", ("org.kde.secretservicecompat", 0u32)); +- } ++pub struct Entry(String); ++pub fn get_entry(user: &str) -> Result { Ok(Entry(user.into())) } ++impl Entry { ++ pub fn get_secret(&self) -> Result, TokenStoreError> { KeychainBackend.get(&self.0)?.ok_or(TokenStoreError::NotFound) } ++ pub fn set_secret(&self, value: &[u8]) -> Result<(), TokenStoreError> { KeychainBackend.set(&self.0, value) } ++ pub fn delete_credential(&self) -> Result<(), TokenStoreError> { KeychainBackend.remove(&self.0) } + } +- +-pub fn get_entry(user: &str) -> Result { +- keyring_core::Entry::new(SERVICE_NAME, user) +-} +- +-pub fn destroy_secrets() { +- keyring_core::unset_default_store(); +-} +- +-#[cfg(feature = "key-chain-file")] +-fn secrets_backing_file() -> std::path::PathBuf { +- std::env::var_os("HOME") +- .or_else(|| std::env::var_os("USERPROFILE")) +- .map(std::path::PathBuf::from) +- .unwrap_or_else(std::env::temp_dir) +- .join(".xodus-keyring.ron") +-} ++pub fn destroy_secrets() {} +--- a/crates/xodus/src/tokens/backend/keychain.rs ++++ b/crates/xodus/src/tokens/backend/keychain.rs +@@ -1,22 +1,43 @@ + use crate::tokens::store::{TokenBackend, TokenStoreError}; ++use serde_json::{Value, json}; ++use std::io::{BufRead, Read, Write}; ++use std::os::linux::net::SocketAddrExt; ++use std::os::unix::net::{SocketAddr, UnixStream}; ++use std::time::Duration; + + pub struct KeychainBackend; + ++fn request(value: Value) -> Result { ++ let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET") ++ .map_err(|_| std::io::Error::other("Android credential host is unavailable"))?; ++ let address = SocketAddr::from_abstract_name(name)?; ++ let mut stream = UnixStream::connect_addr(&address)?; ++ stream.set_read_timeout(Some(Duration::from_secs(10)))?; ++ stream.set_write_timeout(Some(Duration::from_secs(10)))?; ++ serde_json::to_writer(&mut stream, &value)?; ++ stream.write_all(b"\n")?; ++ let mut bytes = Vec::new(); ++ std::io::BufReader::new(stream).take(2_097_153).read_until(b'\n', &mut bytes)?; ++ if bytes.len() > 2_097_152 || bytes.last() != Some(&b'\n') { ++ return Err(std::io::Error::other("Invalid Android credential response").into()); ++ } ++ let response: Value = serde_json::from_slice(&bytes)?; ++ if response["ok"] != true { return Err(std::io::Error::other("Android credential storage unavailable").into()); } ++ Ok(response) ++} ++ + impl TokenBackend for KeychainBackend { + fn get(&self, key: &str) -> Result>, TokenStoreError> { +- let entry = crate::secrets::get_entry(key)?; +- match entry.get_secret() { +- Ok(bytes) => Ok(Some(bytes)), +- Err(keyring_core::Error::NoEntry) => Ok(None), +- Err(e) => Err(e.into()), +- } ++ let response = request(json!({"op":"get", "key":key}))?; ++ if response["value"].is_null() { return Ok(None); } ++ Ok(Some(serde_json::from_value(response["value"].clone())?)) + } +- + fn set(&self, key: &str, value: &[u8]) -> Result<(), TokenStoreError> { +- Ok(crate::secrets::get_entry(key)?.set_secret(value)?) ++ request(json!({"op":"set", "key":key, "value":value}))?; ++ Ok(()) + } +- + fn remove(&self, key: &str) -> Result<(), TokenStoreError> { +- Ok(crate::secrets::get_entry(key)?.delete_credential()?) ++ request(json!({"op":"remove", "key":key}))?; ++ Ok(()) + } + } +--- a/crates/xodus/Cargo.toml ++++ b/crates/xodus/Cargo.toml +@@ -43,8 +43,8 @@ + smbios-lib = "0.9.4" + + [target.'cfg(target_os = "linux")'.dependencies] +-dbus = "=0.9.12" +-dbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-openssl"]} ++dbus = { version = "=0.9.12", optional = true } ++dbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-openssl"], optional = true} + + [target.'cfg(target_os = "macos")'.dependencies] + apple-native-keyring-store = { version = "1.0.2", features = ["keychain"]} diff --git a/tools/xbox/droiddeck-launch-view.patch b/tools/xbox/droiddeck-launch-view.patch new file mode 100644 index 000000000..ce6fa4600 --- /dev/null +++ b/tools/xbox/droiddeck-launch-view.patch @@ -0,0 +1,86 @@ +--- a/crates/xodus-cli/src/commands/run.rs ++++ b/crates/xodus-cli/src/commands/run.rs +@@ -77,6 +77,7 @@ + launch_image: &Path, + materialize_chain: bool, + ) -> (String, String) { ++ if materialize_chain { return (String::new(), String::new()); } + let source_nt = source.to_string_lossy().replace('/', "\\"); + let source_nt = source_nt.trim_end_matches('\\'); + let mirror_unix = mirror_nt_prefix.map(|prefix| prefix.replace('\\', "/")); +@@ -200,9 +201,7 @@ + let entry_path = checked_relative_path(entry_suffix)?; + let mut decrypted = HashMap::new(); + for (name, fd) in fds { +- if name.to_ascii_lowercase().ends_with(".exe") || checked_relative_path(name)? == entry_path { +- decrypted.insert(checked_relative_path(name)?, *fd); +- } ++ decrypted.insert(checked_relative_path(name)?, *fd); + } + if !decrypted.contains_key(&entry_path) { + return Err(std::io::Error::new( +@@ -222,11 +221,8 @@ + } + } + let decrypted_files: HashSet = decrypted.keys().cloned().collect(); +- let parent = source_root.parent().unwrap_or_else(|| Path::new("/tmp")); +- let mirror = Builder::new() +- .prefix(".xodus-chain-") +- .tempdir_in(parent) +- .or_else(|_| Builder::new().prefix("xodus-chain-").tempdir())?; ++ let mirror = Builder::new().prefix("droiddeck-xbox-") ++ .permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700)).tempdir()?; + populate_launch_view( + source_root, + mirror.path(), +@@ -667,21 +663,15 @@ + use std::os::fd::AsRawFd; + + #[test] +- fn full_launch_view_uses_real_executables_and_preserves_encrypted_dll_maps() { ++ fn full_launch_view_needs_no_inherited_descriptor_maps() { + let entry = String::from("\\Launcher.exe"); + let child = String::from("\\Bin\\Child.ExE"); + let dll = String::from("\\Bin\\Encrypted.dll"); + let files = [(&entry, 10), (&child, 11), (&dll, 12)]; + let (nt, unix) = launch_file_maps(&files, Path::new("/package"), Some("\\view"), + &entry, 10, Path::new("/view/Launcher.exe"), true); +- assert!(nt.contains("10:\\??\\Z:\\package\\Launcher.exe")); +- assert!(unix.contains("10:/package/Launcher.exe")); +- assert!(!nt.contains("\\view\\Launcher.exe")); +- assert!(!nt.contains("\\view\\Bin\\Child.ExE")); +- assert!(!unix.contains("/view/Launcher.exe")); +- assert!(!unix.contains("/view/Bin/Child.ExE")); +- assert!(nt.contains("12:\\??\\Z:\\view\\Bin\\Encrypted.dll")); +- assert!(unix.contains("12:/view/Bin/Encrypted.dll")); ++ assert!(nt.is_empty()); ++ assert!(unix.is_empty()); + } + + #[test] +@@ -778,6 +768,25 @@ + .is_symlink()); + assert_eq!(std::fs::read(source.join("Dungeons.exe")).unwrap(), b"encrypted launcher"); + assert_eq!(std::fs::read(nested.join("Shipping.exe")).unwrap(), b"encrypted child"); ++ } ++ ++ #[test] ++ fn launch_view_materializes_encrypted_dlls_in_private_storage() { ++ let root = tempfile::tempdir().unwrap(); ++ std::fs::write(root.path().join("Game.exe"), b"encrypted").unwrap(); ++ std::fs::write(root.path().join("Secret.dll"), b"encrypted-dll").unwrap(); ++ let mut image = tempfile::tempfile().unwrap(); ++ image.write_all(&fake_pe()).unwrap(); ++ let exe = String::from("Game.exe"); ++ let dll = String::from("Secret.dll"); ++ let files = [(&exe, image.as_raw_fd()), (&dll, image.as_raw_fd())]; ++ let (view, _, count, _) = create_launch_chain_mirror(root.path(), &exe, &files).unwrap(); ++ assert_eq!(count, 2); ++ assert_eq!(std::fs::read(view.path().join("Secret.dll")).unwrap(), fake_pe()); ++ assert!(!view.path().join("Secret.dll").is_symlink()); ++ assert_eq!(std::fs::read(root.path().join("Secret.dll")).unwrap(), b"encrypted-dll"); ++ use std::os::unix::fs::PermissionsExt; ++ assert_eq!(std::fs::metadata(view.path()).unwrap().permissions().mode() & 0o077, 0); + } + + #[test] diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py new file mode 100755 index 000000000..ff7bb15c6 --- /dev/null +++ b/tools/xbox/prepare.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Reproduce the pinned Nexus source and DroidDeck credential transport.""" +import hashlib +import json +from pathlib import Path +import subprocess +import sys + +recipe = Path(__file__).resolve().parent +cache = Path(sys.argv[1]).resolve() +lock = json.loads((recipe / 'backend.lock.json').read_text()) +identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + (recipe / 'droiddeck-credentials.patch').read_bytes() + (recipe / 'droiddeck-launch-view.patch').read_bytes()).hexdigest() +source = cache / ('source-' + identity[:16]) +if not (source / '.prepared').is_file(): + if source.exists(): + raise RuntimeError('Incomplete Xbox source directory; inspect it before retrying: ' + str(source)) + source.mkdir(parents=True) + nexus = cache / ('nexus-' + lock['nexusCommit']) + for directory, remote, commit in ((source, lock['repository'], lock['commit']), + (nexus, 'https://github.com/domsvoboda/xodus-nexus.git', lock['nexusCommit'])): + directory.mkdir(parents=True, exist_ok=True) + if not (directory / '.git').is_dir(): + subprocess.run(['git', 'init', '-q', str(directory)], check=True) + subprocess.run(['git', '-C', str(directory), 'fetch', '-q', '--depth', '1', remote, commit], check=True) + subprocess.run(['git', '-C', str(directory), 'checkout', '-q', '--detach', 'FETCH_HEAD'], check=True) + assert subprocess.check_output(['git', '-C', str(directory), 'rev-parse', 'HEAD'], text=True).strip() == commit + assert hashlib.sha256((source / 'Cargo.lock').read_bytes()).hexdigest() == lock['cargoLockSha256'] + for patch in lock['patches']: + file = nexus / 'assets' / patch['file'] + assert hashlib.sha256(file.read_bytes()).hexdigest() == patch['sha256'] + subprocess.run(['git', '-C', str(source), 'apply', str(file)], check=True) + subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-credentials.patch')], check=True) + subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-launch-view.patch')], check=True) + (source / '.prepared').write_text(identity + '\n') +assert (source / '.prepared').read_text().strip() == identity +print(source) diff --git a/tools/xbox/runtime-probe.c b/tools/xbox/runtime-probe.c new file mode 100644 index 000000000..ad92fc0d5 --- /dev/null +++ b/tools/xbox/runtime-probe.c @@ -0,0 +1,23 @@ +#include +#include + +int main(void) +{ + setvbuf(stdout, NULL, _IONBF, 0); + const char *modules[] = { "xgameruntime.dll", "winhttp.dll", "windows.web.dll", "twinapi.appcore.dll" }; + int failed = 0; + for (unsigned i = 0; i < sizeof(modules) / sizeof(modules[0]); i++) + { + printf("loading %s\n", modules[i]); + HMODULE module = LoadLibraryA(modules[i]); + printf("%s loaded=%u error=%lu\n", modules[i], module != NULL, module ? 0ul : GetLastError()); + if (!module) failed = 1; + if (module && i == 0) + { + FARPROC entry = GetProcAddress(module, "InitializeApiImplEx2"); + printf("InitializeApiImplEx2 present=%u\n", entry != NULL); + if (!entry) failed = 1; + } + } + return failed; +} diff --git a/tools/xbox/runtime.lock.json b/tools/xbox/runtime.lock.json new file mode 100644 index 000000000..d8e3de855 --- /dev/null +++ b/tools/xbox/runtime.lock.json @@ -0,0 +1,17 @@ +{ + "repository": "domsvoboda/xodus-nexus", + "tag": "runtime-11.0-20261018-gf291f544", + "archive": "xodus-nexus-runtime-11.0-20261018-gf291f544-x86_64.tar.xz", + "sha512": "8e16634dc4d75ead4b7f48712624e107cfdc29a31a2da1b32624af25a6e3e8938636a6701e076a9dee08eb0c5b36b549342598313971f5fceb5de1e483c43578", + "source": { + "name": "xodus-nexus-runtime-11.0-20261018-gf291f544-source.tar.xz", + "sha256": "58b5d35d23b61f73f771b733fde43ea75096fe8b60f2ddc660949af3d8d87baf" + }, + "dlls": { + "twinapi.appcore.dll": "c203bbd707d521c68719b42a0a81db2b4838cc41be83fc76bfd87bf33e507336", + "windows.web.dll": "1a510c73ca31475b163692337d3cf37fef3381078df9729662095fc92b2e4e83", + "libnghttp2.dll": "2b171eba9296b207fe6016560e5381bc6e7405f3ad5ee5cd4add316358204ec0", + "xgameruntime.dll": "060120e0ec6bccfbab516128315a0658b11d3e316f62a917c653daf1a1dd3e05", + "winhttp.dll": "81e1da7a731d83fbd24dbe2fa2ecb8f155bd477a9f9b6f4cc3932063cc326c27" + } +} diff --git a/tools/xbox/runtime.py b/tools/xbox/runtime.py new file mode 100755 index 000000000..cbe3589ff --- /dev/null +++ b/tools/xbox/runtime.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Extract only pinned Windows GDK modules; Linux Wine stays ARM64.""" +import hashlib +import json +from pathlib import Path +import shutil +import sys +import tarfile +import urllib.request + +recipe = Path(__file__).resolve().parent +lock = json.loads((recipe / 'runtime.lock.json').read_text()) +cache = Path(sys.argv[1]); output = Path(sys.argv[2]) +cache.mkdir(parents=True, exist_ok=True) +output.mkdir(parents=True, exist_ok=True) +url = 'https://github.com/' + lock['repository'] + '/releases/download/' + lock['tag'] + '/' + +def download(name, expected, algorithm): + target = cache / name + def matches(): + if not target.is_file(): + return False + digest = hashlib.new(algorithm) + with target.open('rb') as file: + for block in iter(lambda: file.read(1024 * 1024), b''): + digest.update(block) + return digest.hexdigest() == expected + if not matches(): + part = target.with_suffix(target.suffix + '.part') + with urllib.request.urlopen(url + name, timeout=90) as response, part.open('wb') as file: + shutil.copyfileobj(response, file) + part.replace(target) + if not matches(): + raise RuntimeError('Xbox runtime checksum mismatch: ' + name) + return target + +archive = download(lock['archive'], lock['sha512'], 'sha512') +# Keep corresponding source available beside the build cache for redistribution. +source = lock['source'] +download(source['name'], source['sha256'], 'sha256') +remaining = set(lock['dlls']) +with tarfile.open(archive) as tar: + for member in tar: + name = Path(member.name).name + if '/files/lib/wine/x86_64-windows/' not in member.name or name not in remaining: + continue + if not member.isfile() or member.size > 32 * 1024 * 1024: + raise RuntimeError('Invalid Xbox Windows runtime module') + data = tar.extractfile(member).read() + if hashlib.sha256(data).hexdigest() != lock['dlls'][name] or data[:2] != b'MZ': + raise RuntimeError('Xbox Windows runtime module checksum mismatch') + # Wine rejects builtin-tagged PE files when WINEDLLOVERRIDES selects native. + # These standalone Windows modules must use the native x64 loader on ARM64EC. + if data[64:80] == b'Wine builtin DLL': + data = data[:64] + b'DroidDeck native' + data[80:] + (output / name).write_bytes(data) + remaining.remove(name) +if remaining: + raise RuntimeError('Missing Xbox runtime modules') From 7eae6ca3e7dd17b44f61cdc184e4f7906568ca6e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:10:54 -0400 Subject: [PATCH 027/156] Restrict Xbox credential IPC and address launch review findings (#1164) * Restrict Xbox credentials to host backends and lease game launches * Verify mapped PRoot backend images and retry lease heartbeat timeouts * Bound Xbox backend shutdown and isolate service socket lifetimes * Keep unique Xbox service sockets within Unix address limits * Own licensed launch directories and recover abandoned views safely * Serialize Xbox account revocation with sign-in commits and launch authorization --- .github/workflows/build.yml | 36 ++++- .../droiddeck/launcher/stores/StoreLaunch.kt | 4 + .../launcher/stores/StoreLaunchRequests.kt | 5 +- .../droiddeck/launcher/stores/StoresState.kt | 6 +- .../launcher/stores/xbox/XboxBackend.kt | 15 +- .../launcher/stores/xbox/XboxCatalog.kt | 7 +- .../launcher/stores/xbox/XboxCredentials.kt | 89 ++++++++++-- .../launcher/stores/xbox/XboxLaunchLeases.kt | 44 ++++++ .../launcher/stores/xbox/XboxLaunchViews.kt | 40 ++++++ .../launcher/stores/xbox/XboxLoginActivity.kt | 7 +- .../launcher/stores/xbox/XboxRuntime.kt | 131 ++++++++++++++---- .../droiddeck/launcher/stores/xbox/Xodus.kt | 46 ++++-- .../com/droiddeck/launcher/ui/StoresDetail.kt | 3 +- .../stores/xbox/XboxAccountLifecycleTest.kt | 60 ++++++++ .../stores/xbox/XboxBackendMappingTest.kt | 23 +++ .../launcher/stores/xbox/XboxCatalogTest.kt | 11 +- .../stores/xbox/XboxLaunchLeasesTest.kt | 92 ++++++++++++ .../stores/xbox/XboxLaunchViewsTest.kt | 53 +++++++ .../stores/xbox/XboxServiceSocketTest.kt | 21 +++ docs/development/xbox-store.md | 18 ++- .../overlay/usr/local/bin/droiddeck-game-env | 6 +- .../overlay/usr/local/bin/droiddeck-xbox | 42 ++++-- tools/tests/test_game_environment.py | 11 ++ tools/tests/test_xbox_build_source.py | 48 +++++++ tools/tests/test_xbox_launch.py | 58 ++++++++ tools/xbox/README.md | 23 ++- tools/xbox/droiddeck-launch-view.patch | 20 +++ 27 files changed, 834 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt create mode 100644 tools/tests/test_xbox_build_source.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46fca6d7c..306e459b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,10 +34,28 @@ jobs: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04-arm timeout-minutes: 30 + outputs: + base-sha: ${{ steps.merge.outputs.base-sha }} + tree-sha: ${{ steps.source.outputs.tree-sha }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Pin and merge the APK source + id: merge + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + git fetch --no-tags origin main + echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + git config user.name "ci" + git config user.email "ci@users.noreply.github.com" + git merge --no-ff --no-edit origin/main + - name: Record backend source tree + id: source + run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" - uses: actions/cache@v4 id: xbox-cache with: @@ -80,6 +98,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Check co-authors if: github.event_name == 'pull_request' @@ -125,14 +144,17 @@ jobs: - name: Merge onto main, or say what conflicts if: github.event_name == 'pull_request' + env: + BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }} run: | set -uo pipefail git config user.name "ci" git config user.email "ci@users.noreply.github.com" git fetch --no-tags origin main - BASE=$(git rev-parse --short origin/main) + git fetch --no-tags origin "$BUILD_BASE_SHA" + BASE=$(git rev-parse --short FETCH_HEAD) HEAD=$(git rev-parse --short HEAD) - if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then + if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then { echo "### Mergeable" echo @@ -179,6 +201,11 @@ jobs: echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')" exit 1 + - name: Verify backend and APK source match + env: + XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }} + run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE" + - uses: actions/setup-java@v4 with: distribution: temurin @@ -553,6 +580,11 @@ jobs: path: app/src/main/rust/stores/Cargo.lock if-no-files-found: ignore + - name: Install Python test dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq python3-yaml + - name: Steam import and game environment tests run: python3 -m unittest discover -s tools/tests -p 'test_*.py' diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index e17aac578..68560621f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -83,6 +83,10 @@ object StoreLaunch { } fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) { + if (store == Store.XBOX && !com.droiddeck.launcher.stores.xbox.XboxRuntime.authorize(context, id)) { + StoresState.problems[Store.XBOX] = "Xbox account is unavailable. Sign in again and retry." + return + } if (store != Store.EPIC) { onReady(); return } val app = context.applicationContext Thread({ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index 909c0b7fc..b4b9f7669 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -37,7 +37,7 @@ object StoreLaunchRequests { } fun stop() { - Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start() + com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() synchronized(lock) { observer?.stopWatching() observer = null @@ -52,9 +52,10 @@ object StoreLaunchRequests { } val reply = when (request.optString("op")) { "xbox-released" -> { - com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id")) + com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease")) JSONObject().put("ok", true) } + "xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease"))) "xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) } .getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") } "gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 4aa57757c..dbeb94e23 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -168,10 +168,12 @@ object StoresState { fun signOut(context: Context, store: Store) { val app = context.applicationContext background("stores-signout") { - backends[store]?.signOut(app) + val backend = backends[store] + backend?.signOut(app) if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) } - else StoreAccounts.clear(app, store) + else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store) main.post { + if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store) logLine("${store.label}: signed out") } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index 2eaa59080..c8f1d27d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -19,12 +19,15 @@ object XboxBackend : StoreBackend { context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - @Synchronized override fun signOut(context: Context) { - generation++ - XboxCredentials.invalidate(context) - XboxLibraryCache.clear(context) - XboxRuntime.stop() - access = emptyMap() + override fun signOut(context: Context) { + XboxCredentials.invalidate(context) { + synchronized(this) { + generation++ + access = emptyMap() + } + XboxLibraryCache.clear(context) + XboxRuntime.stop() + } StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index bb3fab4a2..276919ec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -28,15 +28,16 @@ internal object XboxCatalog { (row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId"))) if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row } + fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") && + row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now) fun kind(id: String, visited: Set = emptySet()): String? { if (id in blocked || id in visited || visited.size >= 16) return null val row = rows[id] ?: return null - if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") || - row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null + if (!active(row)) return null val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } if (parent == null) return if (id in plans) null else "owned" if (parent in blocked) return null - if (parent in plans) return "gamepass" + if (parent in plans) return if (rows[parent]?.let(::active) == true) "gamepass" else null return kind(parent, visited + id) } return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt index 14a2a8a89..40cd28225 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -10,11 +10,68 @@ import java.io.Closeable import java.io.InputStream import java.util.UUID -internal class XboxCredentials(private val app: Context) : Closeable { +internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable { val name = "droiddeck-xbox-${UUID.randomUUID()}" - private val generation = storage(app) { epoch(app) } + private val generation = lifecycle(app, expectedGeneration) { it } private val server = LocalServerSocket(name) @Volatile private var closed = false + private val bound = java.util.concurrent.CountDownLatch(1) + private var backend: Process? = null + private var binaryHash = byteArrayOf() + private lateinit var binary: java.io.File + private val peers = java.util.concurrent.ConcurrentHashMap() + + fun bind(process: Process, file: java.io.File, digest: ByteArray) { + backend = process + binary = file.canonicalFile + binaryHash = digest + bound.countDown() + } + + private fun rejected(reason: String): Boolean { + android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason") + return false + } + + private fun authorized(pid: Int): Boolean { + if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound") + val process = backend ?: return rejected("missing-child") + if (!process.isAlive) return rejected("exited-child") + val root = com.droiddeck.launcher.core.HostProcess.pidOf(process) + if (root <= 0 || pid <= 0) return rejected("missing-process-id") + val identity = identity(pid, root) ?: return rejected("foreign-backend") + if (peers[pid] == identity) return true + val digest = java.security.MessageDigest.getInstance("SHA-256") + binary.inputStream().use { input -> + val buffer = ByteArray(65536) + while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } + } + if (!java.security.MessageDigest.isEqual(binaryHash, digest.digest())) return rejected("different-executable") + peers[pid] = identity + return true + } + + private fun identity(pid: Int, root: Int): String? { + if (root <= 0 || pid <= 0) return null + val status = java.io.File("/proc/$pid/status").readLines() + val tracer = status.first { it.startsWith("TracerPid:") }.substringAfter(':').trim().toInt() + val tracerGroup = java.io.File("/proc/$tracer/status").readLines() + .first { it.startsWith("Tgid:") }.substringAfter(':').trim().toInt() + if (tracerGroup != root) return null + val executable = android.system.Os.stat(binary.path) + if (!mapsBackend(java.io.File("/proc/$pid/maps").readText(), binary.path, executable.st_ino)) + return null + return java.io.File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19] + ":${executable.st_dev}:${executable.st_ino}" + } + + fun signalBackend(signal: Int) { + val process = backend ?: return + if (!process.isAlive) return + val root = com.droiddeck.launcher.core.HostProcess.pidOf(process) + peers.forEach { (pid, expected) -> runCatching { + if (identity(pid, root) == expected) android.system.Os.kill(pid, signal) + } } + } init { Thread({ @@ -23,12 +80,17 @@ internal class XboxCredentials(private val app: Context) : Closeable { socket.use { it.soTimeout = 10_000 val reply = runCatching { - check(it.peerCredentials.uid == android.os.Process.myUid()) + val peer = it.peerCredentials + check(peer.uid == android.os.Process.myUid() && authorized(peer.pid)) + val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152)) storage(app) { - check(epoch(app) == generation) - answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152))) + check(!closed && epoch(app) == generation && authorized(peer.pid)) + answer(request) } - }.getOrElse { JSONObject().put("ok", false) } + }.getOrElse { + android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}") + JSONObject().put("ok", false) + } runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) } } } @@ -58,14 +120,25 @@ internal class XboxCredentials(private val app: Context) : Closeable { return result } - override fun close() { closed = true; runCatching { server.close() } } + override fun close() { closed = true; bound.countDown(); runCatching { server.close() } } companion object { val lock = Any() + internal fun mapsBackend(maps: String, path: String, inode: Long): Boolean = maps.lineSequence().any { line -> + val fields = line.trim().split(Regex("\\s+"), limit = 6) + fields.size == 6 && 'x' in fields[1] && fields[4].toLongOrNull() == inode && + runCatching { java.io.File(fields[5]).canonicalPath == path }.getOrDefault(false) + } private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("") - fun invalidate(app: Context) = storage(app) { + fun lifecycle(app: Context, expected: String? = null, block: (String) -> T): T = storage(app) { + val current = epoch(app) + check(expected == null || expected == current) { "Xbox account changed during the request" } + block(current) + } + fun invalidate(app: Context, revoked: () -> Unit = {}) = storage(app) { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString()) StoreAccounts.clear(app, Store.XBOX) + revoked() } fun storage(app: Context, block: () -> T): T = synchronized(lock) { java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file -> diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt new file mode 100644 index 000000000..67f0c5b84 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt @@ -0,0 +1,44 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.Closeable +import java.util.UUID + +internal class XboxLaunchLeases(private val now: () -> Long) { + private data class Permit(val account: String, val expires: Long) + private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null) + private val permits = HashMap() + private val leases = HashMap() + + @Synchronized fun authorize(id: String, account: String) { permits[id] = Permit(account, now() + 180_000) } + @Synchronized fun active(id: String) = leases.values.any { it.id == id } + @Synchronized fun begin(id: String, account: String, session: String): String { + val permit = permits.remove(id) + check(permit != null && permit.account == account && permit.expires > now()) { "Start this Xbox game from DroidDeck." } + check(!active(id)) { "This Xbox game is already starting or running." } + val token = UUID.randomUUID().toString() + leases[token] = Lease(id, account, session, now() + 180_000) + return token + } + @Synchronized fun attach(token: String, child: Closeable): Boolean { + val lease = leases[token] ?: return false + lease.child = child + return true + } + @Synchronized fun renew(token: String, id: String, account: String?, session: String?): Boolean { + val lease = leases[token] ?: return false + if (lease.id != id || lease.account != account || lease.session != session || lease.expires <= now()) return false + lease.expires = now() + 60_000 + return true + } + @Synchronized fun release(token: String): Closeable? = leases.remove(token)?.child + @Synchronized fun stale(account: String?, session: String?, suspended: Boolean): List { + if (suspended) leases.values.filter { it.account == account && it.session == session } + .forEach { it.expires = maxOf(it.expires, now() + 60_000) } + val expired = leases.filterValues { it.account != account || it.session != session || (!suspended && it.expires <= now()) }.keys + return expired.mapNotNull { leases.remove(it)?.child } + } + @Synchronized fun clear(): List { + permits.clear() + return leases.values.mapNotNull { it.child }.also { leases.clear() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt new file mode 100644 index 000000000..9316ab27e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt @@ -0,0 +1,40 @@ +package com.droiddeck.launcher.stores.xbox + +import java.io.File +import java.nio.file.Files +import java.nio.file.FileVisitResult +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes + +internal class XboxLaunchViews { + private val recovered = HashSet() + + @Synchronized fun create(files: File): File { + val parent = File(files, "xbox-runtime/views").canonicalFile + check(parent == File(files.canonicalFile, "xbox-runtime/views")) { "Xbox launch storage contains a redirected path" } + check(parent.mkdirs() || parent.isDirectory) + if (parent.path !in recovered) { + parent.listFiles()?.filter { it.name.startsWith("launch-") }?.forEach(::remove) + recovered.add(parent.path) + } + return Files.createTempDirectory(parent.toPath(), "launch-").toFile() + } + + companion object { + fun remove(directory: File) { + if (!Files.exists(directory.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return + Files.walkFileTree(directory.toPath(), object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.deleteIfExists(file) + return FileVisitResult.CONTINUE + } + override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult { + if (error != null) throw error + Files.deleteIfExists(dir) + return FileVisitResult.CONTINUE + } + }) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt index 2192767fc..01e19158f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -48,7 +48,8 @@ class XboxLoginActivity : ComponentActivity() { Thread({ if (closed) return@Thread try { - Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process -> + val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it } + Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process -> child = process if (closed) return@use Xodus.diagnostics(process) @@ -99,9 +100,9 @@ class XboxLoginActivity : ComponentActivity() { } child = null if (closed) return@Thread - val account = Xodus.run(applicationContext, listOf("account", "--json")) + val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration) check(account.optBoolean("stored")) - XboxCredentials.storage(this) { + XboxCredentials.lifecycle(this, accountGeneration) { val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing") record.put("username", account.getString("username")) StoreAccounts.write(this, Store.XBOX, record) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt index be4d0611b..b75d8fd54 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -7,40 +7,117 @@ import org.json.JSONObject import java.io.File internal object XboxRuntime { + private val state = Any() + private val startup = Any() private var child: Xodus.Child? = null private var owner: String? = null - private val activeGames = HashMap() - @Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0 - @Synchronized fun released(id: String) { - val count = activeGames.getOrDefault(id, 0) - if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1 - } + private var serviceSocket: File? = null + private var generation = 0L + private val views = XboxLaunchViews() + private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() } + fun isActive(id: String): Boolean = launches.active(id) + fun authorize(app: Context, id: String): Boolean = runCatching { + XboxCredentials.lifecycle(app) { + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: return@lifecycle false + launches.authorize(id, account) + true + } + }.getOrDefault(false) + fun released(token: String) { runCatching { launches.release(token)?.close() } } + fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id, + StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running }) - @Synchronized fun prepare(app: Context, id: String): JSONObject { + private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String) + + fun prepare(app: Context, id: String): JSONObject { check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" } check(SessionState.running) { "No running DroidDeck session" } - check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" } - val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing") - val socket = File(app.filesDir, "xbox-runtime/xodus.sock") - if (child?.process?.isAlive != true || owner != account) { - stop() - socket.delete() - val service = Xodus.start(app, emptyList(), service = true) - child = service; owner = account - Xodus.diagnostics(service) - Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start() - val deadline = android.os.SystemClock.elapsedRealtime() + 90_000 - while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100) - if (!socket.exists() || !service.process.isAlive || !SessionState.running) { - stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.") - } + val session = SessionState.sessionId ?: error("No running DroidDeck session") + val game = StoreInstallRoot.gameFolders(app).mapNotNull { folder -> + StoreGameSidecar.read(folder)?.takeIf { it.store == Store.XBOX && it.id == id && it.isInstalled }?.let { folder to it } + }.firstOrNull() ?: error("Xbox game is not installed") + val (account, token, current, credentials) = XboxCredentials.lifecycle(app) { epoch -> + val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing") + synchronized(state) { Authorization(account, launches.begin(id, account, session), generation, epoch) } } - activeGames[id] = activeGames.getOrDefault(id, 0) + 1 - return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket) + Thread({ + while (isActive(id)) { + Thread.sleep(10_000) + launches.stale(StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running }, + SessionState.suspended).forEach { runCatching { it.close() } } + } + }, "xbox-launch-expiry").start() + try { + val socket = service(app, account, current, credentials) + check(synchronized(state) { generation == current }) { "Xbox launch was cancelled" } + val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market() + val directory = views.create(app.filesDir) + val process = try { + Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe, + "--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials) + } catch (e: Exception) { XboxLaunchViews.remove(directory); throw e } + if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") } + Xodus.diagnostics(process) + val result = java.util.concurrent.FutureTask { + val input = process.process.inputStream.buffered() + var line: String + do { line = XboxCredentials.readLine(input, 131_072) } while (!line.startsWith("DROIDDECK_LAUNCH_V1 ")) + JSONObject(line.removePrefix("DROIDDECK_LAUNCH_V1 ")) + } + Thread(result, "xbox-launch-view").start() + val view = result.get(90, java.util.concurrent.TimeUnit.SECONDS) + check(renew(app, id, token) && synchronized(state) { generation == current }) { "Xbox launch was cancelled" } + return JSONObject().put("ok", true).put("socket", socket.path).put("lease", token) + .put("exe", view.getString("exe")).put("playfab", view.opt("playfab")) + } catch (e: Exception) { released(token); throw e } } - @Synchronized fun stop() { - val process = child; child = null; owner = null; activeGames.clear() - runCatching { process?.close() } + internal fun newServiceSocket(files: File): File = + File(files, "xbox-runtime/s-${java.util.UUID.randomUUID().toString().replace("-", "")}/xodus.sock") + + private fun service(app: Context, account: String, current: Long, credentials: String): File = synchronized(startup) { + val existing = synchronized(state) { + check(generation == current) { "Xbox launch was cancelled" } + serviceSocket?.takeIf { child?.process?.isAlive == true && owner == account } + } + val socket = existing ?: newServiceSocket(app.filesDir) + if (existing == null) { + val old = synchronized(state) { child.also { child = null; owner = null; serviceSocket = null } } + old?.close() + socket.delete() + val service = Xodus.start(app, emptyList(), service = true, runtimeDirectory = socket.parentFile!!, credentialGeneration = credentials) + val accepted = synchronized(state) { + (generation == current).also { if (it) { child = service; owner = account; serviceSocket = socket } } + } + if (!accepted) { service.close(); error("Xbox launch was cancelled") } + try { + Xodus.diagnostics(service) + Thread({ runCatching { service.process.inputStream.use { input -> + val buffer = ByteArray(8192) + while (input.read(buffer) >= 0) { Thread.yield() } + } } }, "xbox-service-output").start() + val deadline = android.os.SystemClock.elapsedRealtime() + 90_000 + while (!socket.exists() && service.process.isAlive && SessionState.running && + synchronized(state) { generation == current } && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100) + check(socket.exists() && service.process.isAlive && SessionState.running) { + "Xbox account service did not become ready. Check your sign-in and network connection." + } + } catch (e: Exception) { + synchronized(state) { if (child === service) { child = null; owner = null; serviceSocket = null } } + service.close() + throw e + } + } + socket + } + + fun stop() { + val processes = synchronized(state) { + generation++ + val all = launches.clear() + listOfNotNull(child) + child = null; owner = null; serviceSocket = null + all + } + Thread({ processes.forEach { runCatching { it.close() } } }, "xbox-runtime-stop").start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 668ed9b3d..1029140b3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -13,16 +13,32 @@ internal object Xodus { const val DIR = "/usr/local/lib/droiddeck-xbox" fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US" - class Child(val process: Process, private val credentials: XboxCredentials) : Closeable { - val credentialSocket: String get() = credentials.name + class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable { @Synchronized fun send(frame: JSONObject) { process.outputStream.write((frame.toString() + "\n").toByteArray()) process.outputStream.flush() } override fun close() { - process.destroy() - if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly() - credentials.close() + try { + credentials.close() + runCatching { process.outputStream.close() } + if (!process.waitFor(2, TimeUnit.SECONDS)) { + credentials.signalBackend(android.system.OsConstants.SIGINT) + if (!process.waitFor(2, TimeUnit.SECONDS)) { + credentials.signalBackend(android.system.OsConstants.SIGKILL) + if (!process.waitFor(1, TimeUnit.SECONDS)) { + val pid = com.droiddeck.launcher.core.HostProcess.pidOf(process) + if (pid > 0) android.os.Process.killProcess(pid) + } + } + } + } finally { + runtimeDirectory?.let { directory -> + File(directory, "xodus.sock").delete() + directory.delete() + } + launchDirectory?.let(XboxLaunchViews::remove) + } } } @@ -45,19 +61,20 @@ internal object Xodus { File(root, "version").writeText(packaged) } - fun start(app: Context, args: List, service: Boolean = false): Child { + fun start(app: Context, args: List, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): Child { check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." } stage(app) val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}" check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." } - val credentials = XboxCredentials(app) + val credentials = XboxCredentials(app, credentialGeneration) try { - val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() } + val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() } val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf( "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off", "XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary, ) + args) val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app)) + if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path builder.environment().apply { remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH") put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path) @@ -65,12 +82,19 @@ internal object Xodus { if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1") LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } } - return Child(builder.start(), credentials) + val digest = java.security.MessageDigest.getInstance("SHA-256") + app.assets.open("linuxfs$binary").use { input -> + val buffer = ByteArray(65536) + while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) } + } + val process = builder.start() + credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest()) + return Child(process, credentials, runtimeDirectory, launchDirectory) } catch (e: Exception) { credentials.close(); throw e } } - fun run(app: Context, args: List, timeout: Long = 90): JSONObject { - start(app, args).use { child -> + fun run(app: Context, args: List, timeout: Long = 90, credentialGeneration: String? = null): JSONObject { + start(app, args, credentialGeneration = credentialGeneration).use { child -> val output = java.io.ByteArrayOutputStream() var readError: Throwable? = null val reader = Thread({ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index 8208cad30..3baa0054a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -125,7 +125,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro ActionChip(stringResource(R.string.stores_in_steam), ok = true) if (store == Store.XBOX) { SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) { - com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) + runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) } + .onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." } } SecondaryButton("Manage content", enabled = download == null) { com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt new file mode 100644 index 000000000..ea8f45877 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.stores.xbox + +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class) +@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class) +class XboxAccountLifecycleTest { + private val app = ApplicationProvider.getApplicationContext() + + @Test fun staleSignInCommitsAndBackendStartsCannotAcquireANewerAccount() { + val original = XboxCredentials.lifecycle(app) { it } + XboxCredentials.invalidate(app) + var committed = false + assertThrows(IllegalStateException::class.java) { + XboxCredentials.lifecycle(app, original) { committed = true } + } + assertFalse(committed) + assertThrows(IllegalStateException::class.java) { XboxCredentials(app, original) } + XboxCredentials.lifecycle(app) { current -> assertNotEquals(original, current) } + } + + @Test fun accountCommitAndLaunchAuthorizationWaitUntilRevocationFinishes() { + val revoked = CountDownLatch(1) + val releaseStop = CountDownLatch(1) + val attempted = CountDownLatch(1) + val committed = AtomicBoolean(false) + val authorized = CountDownLatch(1) + val signOut = Thread { + XboxCredentials.invalidate(app) { + revoked.countDown() + check(releaseStop.await(5, TimeUnit.SECONDS)) + } + }.apply { start() } + assertTrue(revoked.await(5, TimeUnit.SECONDS)) + val signIn = Thread { + attempted.countDown() + XboxCredentials.lifecycle(app) { + committed.set(true) + authorized.countDown() + } + }.apply { start() } + try { + assertTrue(attempted.await(5, TimeUnit.SECONDS)) + assertFalse(authorized.await(100, TimeUnit.MILLISECONDS)) + assertFalse(committed.get()) + } finally { + releaseStop.countDown() + signOut.join(5000) + signIn.join(5000) + } + assertTrue(committed.get()) + assertFalse(signOut.isAlive) + assertFalse(signIn.isAlive) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt new file mode 100644 index 000000000..ca0a3fe62 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt @@ -0,0 +1,23 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test + +class XboxBackendMappingTest { + private val path = "/runtime/xodus-cli" + private fun mapping(permissions: String = "r-xp", inode: Long = 42, name: String = path) = + "7a0000-7b0000 $permissions 00001000 00:31 $inode $name\n" + + @Test fun prootLoaderDoesNotHideTheVerifiedBackendImage() { + val maps = mapping(name = "/android/libproot-loader.so", inode = 1) + mapping() + assertTrue(XboxCredentials.mapsBackend(maps, path, 42)) + } + @Test fun loaderAloneOrAnotherGuestExecutableCannotAuthenticate() { + assertFalse(XboxCredentials.mapsBackend(mapping(name = "/android/libproot-loader.so"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(name = "/runtime/other-cli"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(inode = 43), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(permissions = "r--p"), path, 42)) + assertFalse(XboxCredentials.mapsBackend(mapping(name = "$path (deleted)"), path, 42)) + assertFalse(XboxCredentials.mapsBackend("malformed\n", path, 42)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index 5eb1f2e3c..683696f14 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -21,10 +21,19 @@ class XboxCatalogTest { private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) @Test fun purchasedAndSubscriptionAccessRemainDistinct() { - assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan))) + assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(plan))) assertFalse(access(row(plan)).containsKey(plan)) } + @Test fun subscriptionRequiresAnActivePlanRow() { + assertTrue(access(row(game, plan)).isEmpty()) + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to now.toString())) { + assertTrue(access(row(game, plan), row(plan).put(key, value)).isEmpty()) + } + assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan))) + } + @Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() { val checked = "2026-10-19T19:02:38.173609086+00:00" val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00")) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt new file mode 100644 index 000000000..51bc4f321 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt @@ -0,0 +1,92 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.io.Closeable + +class XboxLaunchLeasesTest { + private var time = 0L + private val leases = XboxLaunchLeases { time } + private var closed = 0 + private fun launch(account: String = "account", session: String = "session"): String { + leases.authorize("game", account) + return leases.begin("game", account, session).also { leases.attach(it, Closeable { closed++ }) } + } + private fun reap(account: String? = "account", session: String? = "session", suspended: Boolean = false) { + leases.stale(account, session, suspended).forEach { it.close() } + } + + @Test fun guestPreparationRequiresAnUnexpiredAccountPermit() { + assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } + leases.authorize("game", "account") + assertThrows(IllegalStateException::class.java) { leases.begin("game", "other", "session") } + leases.authorize("game", "account") + time = 180_000 + assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } + } + @Test fun releaseIsIdempotentAndCannotDecrementANewLaunch() { + val old = launch() + leases.release(old)?.close() + leases.release(old)?.close() + assertEquals(1, closed) + assertFalse(leases.active("game")) + val current = launch() + leases.release(old)?.close() + assertTrue(leases.active("game")) + leases.release(current)?.close() + assertEquals(2, closed) + } + @Test fun renewalCannotCrossAccountSessionOrGameBoundaries() { + val token = launch() + assertFalse(leases.renew(token, "other", "account", "session")) + assertFalse(leases.renew(token, "game", "other", "session")) + assertFalse(leases.renew(token, "game", "account", "other")) + assertTrue(leases.renew(token, "game", "account", "session")) + time = 60_000 + assertFalse(leases.renew(token, "game", "account", "session")) + reap() + assertEquals(1, closed) + assertFalse(leases.active("game")) + } + @Test fun abandonedPreparationAndSessionEndCleanUp() { + launch() + time = 180_000 + reap() + assertEquals(1, closed) + launch() + reap(session = null) + assertEquals(2, closed) + } + @Test fun accountSwitchInvalidatesOldLaunchWithoutAffectingItsReplacement() { + val old = launch() + reap(account = "next") + launch("next") + leases.release(old)?.close() + assertEquals(1, closed) + assertTrue(leases.active("game")) + } + @Test fun lateBackendCannotAttachAfterStop() { + leases.authorize("game", "account") + val token = leases.begin("game", "account", "session") + leases.clear().forEach { it.close() } + assertFalse(leases.attach(token, Closeable { closed++ })) + assertFalse(leases.active("game")) + assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } + } + @Test fun suspensionKeepsLiveLeasesUntilResume() { + val token = launch() + time = 200_000 + reap(suspended = true) + time += 20_000 + assertTrue(leases.renew(token, "game", "account", "session")) + reap() + assertEquals(0, closed) + } + @Test fun activityQueriesDoNotWaitForBackendClose() { + val token = launch() + val detached = leases.release(token) + val gate = java.util.concurrent.CountDownLatch(1) + val worker = Thread { gate.await(); detached?.close() }.apply { start() } + try { assertFalse(leases.active("game")); launch("next") } finally { gate.countDown(); worker.join(1000) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt new file mode 100644 index 000000000..f14d294c0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.nio.file.Files + +class XboxLaunchViewsTest { + @Test fun cleanupRemovesLicensedCopiesWithoutFollowingGameLinks() { + val root = Files.createTempDirectory("xbox-view-test").toFile() + try { + val game = root.resolve("installed").apply { mkdirs() } + game.resolve("Game.exe").writeText("encrypted original") + val view = XboxLaunchViews().create(root) + view.resolve("Game.exe").writeText("licensed copy") + Files.createSymbolicLink(view.resolve("originals").toPath(), game.toPath()) + XboxLaunchViews.remove(view) + XboxLaunchViews.remove(view) + assertFalse(view.exists()) + assertEquals("encrypted original", game.resolve("Game.exe").readText()) + } finally { root.deleteRecursively() } + } + + @Test fun recoveryRejectsARedirectedStorageRoot() { + val root = Files.createTempDirectory("xbox-view-links").toFile() + try { + val outside = root.resolve("installed").apply { mkdirs() } + val game = outside.resolve("launch-game").apply { mkdirs() }.resolve("Game.exe") + game.writeText("preserve") + val runtime = root.resolve("xbox-runtime").apply { mkdirs() } + Files.createSymbolicLink(runtime.resolve("views").toPath(), outside.toPath()) + assertThrows(IllegalStateException::class.java) { XboxLaunchViews().create(root) } + assertEquals("preserve", game.readText()) + } finally { root.deleteRecursively() } + } + + @Test fun recoveryRemovesAbandonedViewsAndPreservesNewLiveViews() { + val root = Files.createTempDirectory("xbox-view-recovery").toFile() + try { + val old = XboxLaunchViews().create(root) + old.resolve("Game.exe").writeText("abandoned copy") + val evidence = old.parentFile!!.resolve("diagnostics").apply { mkdirs() }.resolve("report") + evidence.writeText("preserve") + val fresh = XboxLaunchViews() + val first = fresh.create(root) + first.resolve("Game.exe").writeText("running copy") + val second = fresh.create(root) + assertFalse(old.exists()) + assertEquals("running copy", first.resolve("Game.exe").readText()) + assertTrue(second.isDirectory) + assertEquals("preserve", evidence.readText()) + } finally { root.deleteRecursively() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt new file mode 100644 index 000000000..f2e7efbef --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt @@ -0,0 +1,21 @@ +package com.droiddeck.launcher.stores.xbox + +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +class XboxServiceSocketTest { + @Test fun serviceSocketsFitUnixAddressesForEveryPackageAndRemainDistinct() { + val variants = generateSequence(File(requireNotNull(System.getProperty("user.dir")))) { it.parentFile } + .map { File(it, "tools/release/variants.txt") }.first { it.isFile } + val packages = variants.readLines().filter { it.isNotBlank() && !it.startsWith('#') } + .map { it.trim().split(Regex("\\s+"))[1] } + for (pkg in packages) for (user in listOf(0, 99999)) { + val files = File("/data/user/$user/$pkg/files") + val first = XboxRuntime.newServiceSocket(files) + val second = XboxRuntime.newServiceSocket(files) + assertTrue(first.path, first.path.toByteArray(Charsets.UTF_8).size < 108) + assertNotEquals(first, second) + } + } +} diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md index e65ede543..6a7565559 100644 --- a/docs/development/xbox-store.md +++ b/docs/development/xbox-store.md @@ -1,6 +1,6 @@ # Xbox / PC Game Pass on Thor -Branch: `feat/xbox-store`. This integrates PC Microsoft Store MSIXVC packages, +This integrates PC Microsoft Store MSIXVC packages, not Xbox console emulation or UWP/APPX packages. ## Testing on Thor @@ -32,7 +32,12 @@ not Xbox console emulation or UWP/APPX packages. of showing the "Steam picks the game up when it next starts" toast. A launch taking over 20 seconds should not produce a duplicate Steam launch or a "Game already running" dialog while licensing is still preparing it. - Steam/session startup uses the selected ARM64 Proton. + Steam/session startup uses the selected ARM64 Proton. Launch through + DroidDeck's Play action: it authorizes one account-bound preparation. Direct + Steam launches without that authorization fail with a retry-in-DroidDeck error. + During preparation, opening the Xbox detail/content UI must remain responsive. + Update / verify must report a problem if the game starts after its button was + enabled, rather than crashing the app. First launch prepares that game's prefix. Check that a window appears, the game reaches its menu, controller buttons/axes work, and audio is audible. Record the selected Proton, game/version, and any startup failure. @@ -48,7 +53,14 @@ not Xbox console emulation or UWP/APPX packages. it, then relaunch and check that the game recognizes it. An account with no such content should get an empty-content message. Base-game uninstall also removes its managed content. -9. Sign out from Xbox. Installed files should remain, account library data should +9. Stop a game and launch it again. A delayed release from the first launch must + not clear the second launch's activity. Suspend/resume while playing; the + launch lease should survive suspension. Stop the session during preparation + and verify both backend processes exit and the host-owned launch directory + under `files/xbox-runtime/views` is removed. A fresh app process recovers + abandoned `launch-*` directories there before preparing another game; + cleanup must preserve installed game files reached through view symlinks. +10. Sign out from Xbox. Installed files should remain, account library data should clear, and Play/download licensing should require sign-in again. Do not uninstall DroidDeck or clear application data to troubleshoot this build. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index f83e93ae3..a7fa3a083 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -316,7 +316,11 @@ def main(): if mic_enabled == "0": env["BANNER_AUDIO_DIRECT_MIC"] = "0" env = force_ssbs(command, env) - xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox"))) + try: + xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox"))) + except Exception as error: + print("droiddeck-game-env: Xbox support unavailable (%s); stock launch" % error, file=sys.stderr) + xbox = {"launch": lambda command, env: None} try: xbox_result = xbox["launch"](command, env) except (OSError, ValueError, KeyError, RuntimeError) as error: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox index beec0a65b..c07a6d833 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox @@ -102,15 +102,16 @@ def bridge(upstream): return server.getsockname()[1], key.decode('ascii'), close -def wine(argv): - with open(os.environ['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file: +def wine(argv, source_env=None): + source_env = os.environ if source_env is None else source_env + with open(source_env['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file: command = json.load(file) if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1: raise ValueError('Invalid Xbox Proton invocation') - env = dict(os.environ) + env = dict(source_env) env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None) env.pop('DROIDDECK_XBOX_COMMAND_FILE', None) - prefix = Path(os.environ['STEAM_COMPAT_DATA_PATH']) / 'pfx' + prefix = Path(source_env['STEAM_COMPAT_DATA_PATH']) / 'pfx' prefix.parent.mkdir(parents=True, exist_ok=True) if not (prefix / 'system.reg').is_file(): result = subprocess.run([command[0], 'run', 'cmd', '/c', 'exit', '0'], stdout=sys.stderr, env=env) @@ -128,10 +129,21 @@ def wine(argv): return subprocess.call([*command[:2], argv[0], *command[3:]], env=env) +def renew_lease(ask, channel, product, lease, stopped): + while not stopped.wait(15): + response = ask(channel, {'op': 'xbox-renew', 'id': product, 'lease': lease}, timeout=10) + if response is not None and response.get('ok') is False: + return + + def launch(command, env): if len(command) < 3 or command[1] != 'waitforexitandrun' or env.get('DROIDDECK_XBOX_ACTIVE') == '1': return None - store = runpy.run_path(str(BIN / 'droiddeck-store-launch')) + try: + store = runpy.run_path(str(BIN / 'droiddeck-store-launch')) + except Exception as error: + print('droiddeck-xbox: store detection unavailable (%s); stock launch' % error, file=sys.stderr) + return None found = store['find_store_game'](command[2:]) if found is None or found[1].get('store') != 'xbox': return None @@ -142,19 +154,26 @@ def launch(command, env): launch_dir = env.get('BL_LAUNCH_DIR') if not launch_dir: raise RuntimeError('No Xbox account channel; start the game from DroidDeck') - reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=95) + reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=190) if not reply or not reply.get('ok'): raise RuntimeError('Xbox account service is unavailable; sign in again and retry') - port, key, close = bridge(reply['socket']) + stopped = threading.Event() + close = lambda: None try: + port, key, close = bridge(reply['socket']) + + threading.Thread(target=renew_lease, args=(store['ask'], Path(launch_dir) / 'stores', + sidecar['id'], reply['lease'], stopped), daemon=True).start() with tempfile.TemporaryDirectory(prefix='droiddeck-xbox-') as temporary: context = Path(temporary) / 'command.json' context.write_text(json.dumps(command), encoding='utf-8') context.chmod(0o600) owned_env = dict(env, DROIDDECK_XBOX_ACTIVE='1', DROIDDECK_XBOX_COMMAND_FILE=str(context), - DROIDDECK_XBOX_CREDENTIAL_SOCKET=reply['credentials'], XODUS_SOCKET=reply['socket'], XODUS_BRIDGE_PORT=str(port), XODUS_BRIDGE_KEY=key, XODUS_LOG='off', RUST_LOG='off', XODUS_SAVE_SYNC='auto' if sidecar.get('cloud', True) else 'off') + owned_env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None) + if reply.get('playfab'): + owned_env['XODUS_XSTS_PLAYFAB'] = reply['playfab'] content = folder / '.xbox-content' roots = [] if content.is_dir(): @@ -167,12 +186,11 @@ def launch(command, env): pass if roots: owned_env['XODUS_CONTENT_ROOTS'] = ';'.join(roots) - market = sidecar.get('extra', {}).get('market', 'US') - return subprocess.call([str(RUNTIME / 'xodus-cli'), 'run', str(folder), str(BIN / 'droiddeck-xbox'), - '--exe', sidecar['exe'], '--materialize-chain', '--market', market], env=owned_env) + return wine([reply['exe']], owned_env) finally: + stopped.set() close() - store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'id': sidecar['id']}) + store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'lease': reply['lease']}) if __name__ == '__main__': diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index eaef4c6d0..5b2914419 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -43,6 +43,17 @@ class ReadConfigMappingTest(unittest.TestCase): class GameEnvironmentTest(unittest.TestCase): + def test_optional_xbox_loader_failure_keeps_stock_launch(self): + from unittest.mock import patch + for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')): + calls = [] + with patch.dict(os.environ, {}, clear=True), patch('sys.argv', ['droiddeck-game-env', '/stock', 'run']), \ + patch('runpy.run_path', side_effect=error), patch('os.execvpe', side_effect=lambda *args: calls.append(args)), \ + patch.dict(MODULE['main'].__globals__, load_sync=lambda: None, select_sync=lambda command, env, sync: (command, env)): + MODULE['main']() + self.assertEqual(calls[0][0:2], ('/stock', ['/stock', 'run'])) + + def test_directaudio_capture_follows_the_session_microphone_setting(self): with tempfile.TemporaryDirectory() as tmp: home = Path(tmp) diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py new file mode 100644 index 000000000..8177a1cbc --- /dev/null +++ b/tools/tests/test_xbox_build_source.py @@ -0,0 +1,48 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class XboxBuildSourceTest(unittest.TestCase): + def test_main_advancing_between_jobs_does_not_change_packaged_source(self): + try: + import yaml + except ImportError: + self.fail('PyYAML is required to execute workflow steps') + workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / '.github/workflows/build.yml').read_text()) + jobs = workflow['jobs'] + step = lambda job, name: next(s['run'] for s in jobs[job]['steps'] if s.get('name') == name) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + env = dict(os.environ, GIT_AUTHOR_NAME='test', GIT_COMMITTER_NAME='test', + GIT_AUTHOR_EMAIL='test@example.test', GIT_COMMITTER_EMAIL='test@example.test', + GITHUB_STEP_SUMMARY=str(root / 'summary')) + def git(*args, cwd=root): + return subprocess.check_output(['git', *args], cwd=cwd, env=env, stderr=subprocess.DEVNULL, text=True).strip() + git('init', '-q', '-b', 'main') + (root / 'backend').write_text('base') + git('add', '.'); git('commit', '-qm', 'base') + git('switch', '-qc', 'feature') + (root / 'app').write_text('feature') + git('add', '.'); git('commit', '-qm', 'feature') + head = git('rev-parse', 'HEAD') + git('switch', '-q', 'main') + (root / 'backend').write_text('merged backend') + git('commit', '-qam', 'backend changed after branch') + for name in ('xbox', 'apk'): + git('clone', '-q', str(root), str(root / name)) + git('checkout', '-q', head, cwd=root / name) + output = root / 'output' + env['GITHUB_OUTPUT'] = str(output) + subprocess.run(['bash', '-c', step('xbox', 'Pin and merge the APK source')], cwd=root / 'xbox', env=env, check=True, capture_output=True) + base = output.read_text().strip().split('=', 1)[1] + tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'xbox') + (root / 'backend').write_text('newer main, after backend build') + git('commit', '-qam', 'main advanced again') + env.update(BUILD_BASE_SHA=base, XBOX_SOURCE_TREE=tree) + subprocess.run(['bash', '-c', step('build', 'Merge onto main, or say what conflicts')], cwd=root / 'apk', env=env, check=True, capture_output=True) + subprocess.run(['bash', '-c', step('build', 'Verify backend and APK source match')], cwd=root / 'apk', env=env, check=True, capture_output=True) + self.assertEqual((root / 'apk/backend').read_text(), 'merged backend') + self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'apk'), tree) diff --git a/tools/tests/test_xbox_launch.py b/tools/tests/test_xbox_launch.py index 7419dd62c..fb7bfdc0d 100644 --- a/tools/tests/test_xbox_launch.py +++ b/tools/tests/test_xbox_launch.py @@ -19,6 +19,64 @@ class XboxLaunchTest(unittest.TestCase): (folder / '.droiddeck-store.json').write_text(json.dumps({'store': 'gog', 'id': '1'})) self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', str(folder / 'Game.exe')], {})) + def test_renewal_retries_a_timeout_and_stops_after_rejection(self): + stopped = unittest.mock.Mock() + stopped.wait.side_effect = [False, False, False, AssertionError('renewed a rejected lease')] + ask = unittest.mock.Mock(side_effect=[None, {'ok': True}, {'ok': False}]) + xbox['renew_lease'](ask, Path('/channel'), 'GAME', 'lease-1', stopped) + self.assertEqual(ask.call_count, 3) + + def test_stopping_a_launch_stops_renewal_without_another_request(self): + stopped = threading.Event() + stopped.set() + ask = unittest.mock.Mock() + xbox['renew_lease'](ask, Path('/channel'), 'GAME', 'lease-1', stopped) + ask.assert_not_called() + + def test_missing_store_detector_keeps_stock_launch(self): + for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')): + with self.subTest(error=error), patch('runpy.run_path', side_effect=error): + self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', '/Game.exe'], {})) + + def test_launch_uses_prepared_image_and_never_receives_account_credentials(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + runtime = root / 'runtime/windows' + runtime.mkdir(parents=True) + for name in xbox['DLLS']: + (runtime / name).write_bytes(b'MZ') + store = {'find_store_game': lambda argv: (root, {'store': 'xbox', 'id': 'GAME'}), + 'ask': unittest.mock.Mock(return_value={'ok': True, 'socket': '/service', 'lease': 'lease-1', + 'exe': '/private/Game.exe', 'playfab': 'game-token'})} + close = unittest.mock.Mock() + with patch('runpy.run_path', return_value=store), patch.dict(xbox['launch'].__globals__, RUNTIME=runtime.parent), \ + patch.dict(xbox['launch'].__globals__, bridge=lambda path: (1234, 'key', close)), \ + patch.dict(xbox['launch'].__globals__, wine=unittest.mock.Mock(return_value=7)): + launch = xbox['launch'] + self.assertEqual(launch(['/proton', 'waitforexitandrun', '/encrypted/Game.exe'], + {'BL_LAUNCH_DIR': '/launch', 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'stale'}), 7) + wine = launch.__globals__['wine'] + self.assertEqual(wine.call_args.args[0], ['/private/Game.exe']) + env = wine.call_args.args[1] + self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', env) + self.assertEqual(env['XODUS_XSTS_PLAYFAB'], 'game-token') + close.assert_called_once() + self.assertEqual(store['ask'].call_args.args[1], {'op': 'xbox-released', 'lease': 'lease-1'}) + + def test_bridge_start_failure_still_releases_the_lease(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root) + runtime = root / 'windows' + runtime.mkdir() + for name in xbox['DLLS']: + (runtime / name).touch() + ask = unittest.mock.Mock(return_value={'ok': True, 'socket': '/service', 'lease': 'lease-1'}) + with patch('runpy.run_path', return_value={'find_store_game': lambda argv: (root, {'store': 'xbox', 'id': 'GAME'}), 'ask': ask}), \ + patch.dict(xbox['launch'].__globals__, RUNTIME=root, bridge=unittest.mock.Mock(side_effect=OSError('bind failed'))): + with self.assertRaises(OSError): + xbox['launch'](['/proton', 'waitforexitandrun', '/Game.exe'], {'BL_LAUNCH_DIR': '/launch'}) + self.assertEqual(ask.call_args.args[1], {'op': 'xbox-released', 'lease': 'lease-1'}) + def test_bridge_authenticates_before_forwarding(self): with tempfile.TemporaryDirectory() as root: path = str(Path(root) / 'service.sock') diff --git a/tools/xbox/README.md b/tools/xbox/README.md index bb4e8ec3f..7f700fd2a 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -24,16 +24,33 @@ in the build cache and publishes both in CI's `xbox-corresponding-source` artifact; distribute corresponding sources with published binaries. `droiddeck-credentials.patch` replaces Linux Secret Service with a bounded, -UID-checked Android abstract socket. The Android host encrypts every persistent +Android abstract socket restricted to the backend process started by the host. The Android host encrypts every persistent write using its existing Keystore cipher, verifies backend read-backs, and rejects writes when Keystore is unavailable. No token values enter process arguments, external storage, application logs or a plaintext credential file. Account-generation invalidation prevents a signed-out child's later writes from -restoring credentials. Each process gets its own unguessable socket name. +restoring credentials. Each backend gets its own socket. The host checks the peer UID, the PRoot tracer +owned by its live child, and an executable mapping of the backend file with a matching inode and +SHA-256 against the APK asset. PRoot's loader can occupy `/proc//exe`, so +authentication checks the mapped backend image instead. A socket name alone grants no credential operations, including to another +copy of Xodus started by the guest. + +Licensed launch-view preparation runs in an Android-owned Xodus child, which +never starts Wine or a game. An in-memory, single-use Play authorization is +required before the guest can request preparation. The reply contains a random +launch lease, a materialized executable path, a game-service socket and the +existing PlayFab game token; it contains no account credential endpoint. +Leases bind product, account and session, expire without heartbeats, and release +idempotently. Cancellation, session stop and account changes revoke leases; +closing the child's stdin lets it remove its private view. Suspension preserves +live leases. Launch Xbox games through DroidDeck's Play action; an unsolicited +launch from inside Steam has no Play authorization. The Proton initialization and game processes do not inherit the raw credential channel or command-file environment variables. The authenticated GDK bridge remains available to the game. Like the other stores, games share DroidDeck's -Linux environment and Android UID; this is not a sandbox against malicious games. +Linux environment and Android UID. The process checks close the direct +credential-socket route; they do not isolate writable runtime files, prevent +same-UID process tampering, or provide a sandbox against malicious games. Microsoft sign-in uses Nexus's private stdio protocol in an isolated Android WebView process. Its page bridge is restricted to the allowed Microsoft/Xbox diff --git a/tools/xbox/droiddeck-launch-view.patch b/tools/xbox/droiddeck-launch-view.patch index ce6fa4600..4f8836a18 100644 --- a/tools/xbox/droiddeck-launch-view.patch +++ b/tools/xbox/droiddeck-launch-view.patch @@ -84,3 +84,23 @@ } #[test] +--- a/crates/xodus-cli/src/commands/run.rs ++++ b/crates/xodus-cli/src/commands/run.rs +@@ -618 +618,17 @@ ++ if wine == "droiddeck:prepare" { ++ use std::io::Write; ++ if !materialize_chain { return ExitCode::FAILURE; } ++ let ready = serde_json::json!({ ++ "exe": stub_path, ++ "playfab": playfab_auth_header(client, tokens).await, ++ }); ++ println!("DROIDDECK_LAUNCH_V1 {}", ready); ++ let _ = std::io::stdout().flush(); ++ let mut release = String::new(); ++ let _ = std::io::stdin().read_line(&mut release); ++ drop(launch_mirror); ++ cleanup().await; ++ return ExitCode::SUCCESS; ++ } ++ + let mut command = Command::new(wine); From 80a62cd0f078f5437607324f9dba062c0b21a351 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:46:35 -0400 Subject: [PATCH 028/156] Restore Xbox launches from Steam (#1170) --- .../droiddeck/launcher/stores/StoreLaunch.kt | 4 ---- .../launcher/stores/xbox/XboxLaunchLeases.kt | 6 ------ .../launcher/stores/xbox/XboxRuntime.kt | 7 ------- .../stores/xbox/XboxLaunchLeasesTest.kt | 19 +++++++++---------- docs/development/xbox-store.md | 7 ++++--- tools/xbox/README.md | 12 ++++++------ 6 files changed, 19 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index 68560621f..e17aac578 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -83,10 +83,6 @@ object StoreLaunch { } fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) { - if (store == Store.XBOX && !com.droiddeck.launcher.stores.xbox.XboxRuntime.authorize(context, id)) { - StoresState.problems[Store.XBOX] = "Xbox account is unavailable. Sign in again and retry." - return - } if (store != Store.EPIC) { onReady(); return } val app = context.applicationContext Thread({ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt index 67f0c5b84..6caba8450 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt @@ -4,16 +4,11 @@ import java.io.Closeable import java.util.UUID internal class XboxLaunchLeases(private val now: () -> Long) { - private data class Permit(val account: String, val expires: Long) private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null) - private val permits = HashMap() private val leases = HashMap() - @Synchronized fun authorize(id: String, account: String) { permits[id] = Permit(account, now() + 180_000) } @Synchronized fun active(id: String) = leases.values.any { it.id == id } @Synchronized fun begin(id: String, account: String, session: String): String { - val permit = permits.remove(id) - check(permit != null && permit.account == account && permit.expires > now()) { "Start this Xbox game from DroidDeck." } check(!active(id)) { "This Xbox game is already starting or running." } val token = UUID.randomUUID().toString() leases[token] = Lease(id, account, session, now() + 180_000) @@ -38,7 +33,6 @@ internal class XboxLaunchLeases(private val now: () -> Long) { return expired.mapNotNull { leases.remove(it)?.child } } @Synchronized fun clear(): List { - permits.clear() return leases.values.mapNotNull { it.child }.also { leases.clear() } } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt index b75d8fd54..28d6c12d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -16,13 +16,6 @@ internal object XboxRuntime { private val views = XboxLaunchViews() private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() } fun isActive(id: String): Boolean = launches.active(id) - fun authorize(app: Context, id: String): Boolean = runCatching { - XboxCredentials.lifecycle(app) { - val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: return@lifecycle false - launches.authorize(id, account) - true - } - }.getOrDefault(false) fun released(token: String) { runCatching { launches.release(token)?.close() } } fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id, StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running }) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt index 51bc4f321..d5a5d553b 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt @@ -9,19 +9,16 @@ class XboxLaunchLeasesTest { private val leases = XboxLaunchLeases { time } private var closed = 0 private fun launch(account: String = "account", session: String = "session"): String { - leases.authorize("game", account) return leases.begin("game", account, session).also { leases.attach(it, Closeable { closed++ }) } } private fun reap(account: String? = "account", session: String? = "session", suspended: Boolean = false) { leases.stale(account, session, suspended).forEach { it.close() } } - @Test fun guestPreparationRequiresAnUnexpiredAccountPermit() { - assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } - leases.authorize("game", "account") - assertThrows(IllegalStateException::class.java) { leases.begin("game", "other", "session") } - leases.authorize("game", "account") - time = 180_000 + @Test fun steamLaunchCanCreateASessionBoundLease() { + val token = leases.begin("game", "account", "session") + assertTrue(leases.active("game")) + assertFalse(leases.renew(token, "game", "account", "other")) assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } } @Test fun releaseIsIdempotentAndCannotDecrementANewLaunch() { @@ -65,13 +62,15 @@ class XboxLaunchLeasesTest { assertEquals(1, closed) assertTrue(leases.active("game")) } - @Test fun lateBackendCannotAttachAfterStop() { - leases.authorize("game", "account") + @Test fun stopRemovesLeaseAndRejectsLateAttachment() { val token = leases.begin("game", "account", "session") leases.clear().forEach { it.close() } assertFalse(leases.attach(token, Closeable { closed++ })) assertFalse(leases.active("game")) - assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") } + val next = leases.begin("game", "account", "new-session") + assertTrue(leases.active("game")) + assertFalse(leases.attach(token, Closeable { closed++ })) + assertTrue(leases.attach(next, Closeable { closed++ })) } @Test fun suspensionKeepsLiveLeasesUntilResume() { val token = launch() diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md index 6a7565559..47ccb769e 100644 --- a/docs/development/xbox-store.md +++ b/docs/development/xbox-store.md @@ -32,9 +32,10 @@ not Xbox console emulation or UWP/APPX packages. of showing the "Steam picks the game up when it next starts" toast. A launch taking over 20 seconds should not produce a duplicate Steam launch or a "Game already running" dialog while licensing is still preparing it. - Steam/session startup uses the selected ARM64 Proton. Launch through - DroidDeck's Play action: it authorizes one account-bound preparation. Direct - Steam launches without that authorization fail with a retry-in-DroidDeck error. + Steam/session startup uses the selected ARM64 Proton. Launch the installed + Xbox game from either DroidDeck or Steam; both paths should prepare the same + account-bound game service and licensed view while keeping account credentials + inside the Android-owned backend. During preparation, opening the Xbox detail/content UI must remain responsive. Update / verify must report a problem if the game starts after its button was enabled, rather than crashing the app. diff --git a/tools/xbox/README.md b/tools/xbox/README.md index 7f700fd2a..b06825748 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -36,15 +36,15 @@ authentication checks the mapped backend image instead. A socket name alone gran copy of Xodus started by the guest. Licensed launch-view preparation runs in an Android-owned Xodus child, which -never starts Wine or a game. An in-memory, single-use Play authorization is -required before the guest can request preparation. The reply contains a random -launch lease, a materialized executable path, a game-service socket and the -existing PlayFab game token; it contains no account credential endpoint. +never starts Wine or a game. Steam and DroidDeck launch paths can request +preparation for an installed Xbox title during a live session with an Xbox +account. The reply contains a random launch lease, a materialized executable +path, a game-service socket and the existing PlayFab game token; it contains no +account credential endpoint. Leases bind product, account and session, expire without heartbeats, and release idempotently. Cancellation, session stop and account changes revoke leases; closing the child's stdin lets it remove its private view. Suspension preserves -live leases. Launch Xbox games through DroidDeck's Play action; an unsolicited -launch from inside Steam has no Play authorization. +live leases. The Proton initialization and game processes do not inherit the raw credential channel or command-file environment variables. The authenticated GDK bridge remains available to the game. Like the other stores, games share DroidDeck's From af4f0a7edb73f1063119b93baac92d85d905e88e Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 19 Oct 2026 21:27:06 -0400 Subject: [PATCH 029/156] fix: user apps open at the Desktop's resolution (#1172) A user app (script, AppImage or Flatpak) started from its shortcut runs in a Run session, which took the Steam resolution (720p by default). It ignored the resolution chosen under Open Desktop and was letterboxed at 1280x720 on screens that are not 16:9. User apps now use the Desktop's resolution once one has been chosen. Until then, and for every other Run program, nothing changes. --- .../java/com/droiddeck/launcher/SessionActivity.kt | 9 ++++++--- .../java/com/droiddeck/launcher/runtime/UserApps.kt | 3 +++ .../com/droiddeck/launcher/session/SessionPrefs.kt | 5 +++++ .../launcher/session/SessionScalingPrefsTest.kt | 13 +++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8ad1af889..8f4de1167 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1561,7 +1561,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun followChoice(): Boolean { - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) == com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN } @@ -1600,8 +1600,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels) } + private fun displayMode(): String = SessionPrefs.displayMode(this, + intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM)) + private fun outputSize(): Pair { - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this) val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && @@ -2963,7 +2966,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun glassesOutputSize(): Pair? { if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height - val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = displayMode() val display = com.droiddeck.launcher.session.SessionDisplay val choice = SessionPrefs.resolutionChoice(this, mode, panel) val size = display.resolveChoice(panel, choice) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 445a56348..3bd000d7d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -71,6 +71,9 @@ object UserApps { private fun menuEntry(context: Context, id: String) = File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop") + fun launches(program: String?): Boolean = + program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER + fun list(context: Context): List { val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir -> App( diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 4c4cd9808..f808be94d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -878,6 +878,11 @@ object SessionPrefs { fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode + fun displayMode(context: Context, mode: String, program: String?): String = + if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) && + resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP + else prefMode(mode) + fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false) fun setPipAutoEnter(context: Context, enabled: Boolean) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt index 62d8e4769..03a324391 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt @@ -40,6 +40,19 @@ class SessionScalingPrefsTest { assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080)) } + @Test fun userAppsTakeTheDesktopResolutionOnceItIsChosen() { + val context = RuntimeEnvironment.getApplication() + val script = com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER + assertEquals("steam", SessionPrefs.displayMode(context, "run", script)) + SessionPrefs.setResolutionChoice(context, "lxqt", SessionDisplay.MATCH_SCREEN) + assertEquals("lxqt", SessionPrefs.displayMode(context, "run", script)) + assertEquals("lxqt", SessionPrefs.displayMode(context, "run", com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)) + val choice = SessionPrefs.resolutionChoice(context, SessionPrefs.displayMode(context, "run", script), 1240 to 1080) + assertEquals(1240 to 1080, SessionDisplay.resolveChoice(1240 to 1080, choice)) + assertEquals("steam", SessionPrefs.displayMode(context, "run", "/usr/bin/retroarch")) + assertEquals("steam", SessionPrefs.displayMode(context, "steam", script)) + } + @Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() { val context = RuntimeEnvironment.getApplication() assertEquals(SessionDisplay.DEFAULT_RESOLUTION, SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) From cfc6dfb05f05bbbcafa475e631b378081f235bcd Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 19 Oct 2026 21:27:18 -0400 Subject: [PATCH 030/156] fix: let x86 apps under FEX use the device's fonts (Epic launcher CJK text shows boxes) (#1168) fix: let x86 apps under FEX use the device's fonts for CJK text --- .../overlay/usr/local/bin/droiddeck-epic | 1 + .../linuxfs/overlay/usr/local/bin/droiddeck-fex | 13 +++++++++++++ tools/tests/test_droiddeck_fex.py | 17 +++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic b/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic index 45181cc3b..652664145 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-epic @@ -1232,6 +1232,7 @@ class Session: seed_settings() install_shims() install_preload(files) + fex_module()["install_fonts"](files) clear_playing() sync_installations() forget_sign_in() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index a8627ca3a..a2d858b8b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -61,6 +61,8 @@ SCAN_LIMIT = 4000 MODES = ("auto", "on", "off") STEAM_VERBS = ("run", "waitforexitandrun") ESCAPE = re.compile(rb"\\([0-7]{3})") +DEVICE_FONTS = "/usr/local/share/fonts/android" +FONT_RULES = "/etc/fonts/conf.d/66-droiddeck-device-fonts.conf" def say(message): @@ -352,6 +354,16 @@ def write_config(fex, root): write_if_changed(os.path.join(CONFIG_DIR, "Config.json"), json.dumps(config, indent=2) + "\n") +def install_fonts(root): + target = os.path.join(root, FONT_RULES.lstrip("/")) + rules = read_text(FONT_RULES) or "" + if not os.path.isdir(DEVICE_FONTS) or "" not in rules: + with contextlib.suppress(FileNotFoundError): + os.unlink(target) + return + write_if_changed(target, rules.replace("", "\n%s" % DEVICE_FONTS, 1)) + + def link_steam_sdk(): for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): link = os.path.join(HOME, ".steam", sdk) @@ -454,6 +466,7 @@ def prepare(app=SNIPER_APPID): if materialize(platform): say("prepared %s as the x86 runtime" % os.path.basename(platform)) install_preloads(root) + install_fonts(root) provider = None if app == SNIPER_APPID: provider = graphics_platform() diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index 79a8a38bf..3825946b1 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -45,6 +45,8 @@ class FexTestCase(unittest.TestCase): "WANTED": str(self.home / ".config/droiddeck/fex-wanted"), "PRELOADS": str(self.tmp / "preloads"), "HOST_PRELOAD": str(self.tmp / "ld.so.preload"), + "DEVICE_FONTS": str(self.tmp / "android-fonts"), + "FONT_RULES": str(self.tmp / "66-droiddeck-device-fonts.conf"), } patcher = mock.patch.dict(G, patches) patcher.start() @@ -261,6 +263,21 @@ class PrepareTest(FexTestCase): self.assertEqual((root / "lib/i386-linux-gnu/droiddeck/libfaultreport.so").read_bytes(), b"report32") self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libfaultreport.so\n") + def test_device_fonts_reach_the_x86_runtime(self): + root = self.tmp / "root" + conf = root / str(self.tmp / "66-droiddeck-device-fonts.conf").lstrip("/") + rules = '\n\n\n' + self.write(self.tmp / "66-droiddeck-device-fonts.conf", rules) + FEX["install_fonts"](str(root)) + self.assertFalse(conf.exists()) + (self.tmp / "android-fonts").mkdir() + FEX["install_fonts"](str(root)) + self.assertEqual(conf.read_text(), '\n%s\n\n\n' + % (self.tmp / "android-fonts")) + (self.tmp / "66-droiddeck-device-fonts.conf").unlink() + FEX["install_fonts"](str(root)) + self.assertFalse(conf.exists()) + def test_config_turns_thunks_on_only_when_complete(self): FEX["write_config"]({"host_thunks": "/h", "guest_thunks": "/g", "thunks_db": "/db"}, "/root") config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text()) From da916e7ed7dca6f81505ba0ec7e191ce84ce7001 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 19 Oct 2026 21:27:25 -0400 Subject: [PATCH 031/156] fix: give x86 programs under FEX the runtime's OpenSSL 3 (libcrypto.so.3 not found) (#1167) fix: give x86 programs under FEX the runtime's OpenSSL 3 --- README.md | 2 +- tools/linuxfs/overlay/usr/local/bin/droiddeck-fex | 3 ++- tools/tests/test_droiddeck_fex.py | 7 +++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1660171eb..f07237abe 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index a2d858b8b..c5931ab17 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -34,7 +34,7 @@ RUNTIMES = {SNIPER_APPID: ("sniper_platform_", "Steam Linux Runtime 3.0 (sniper" SLR4_APPID: ("steamrt4_platform_", "Steam Linux Runtime 4.0 (steamrt4")} MARKER = ".droiddeck-fex" LAYOUT = 1 -ROOTFS_LAYOUT = 1 +ROOTFS_LAYOUT = 2 ARCH_DIRS = ("x86_64-linux-gnu", "i386-linux-gnu") INTERPRETERS = ("ld-linux-x86-64.so.2", "ld-linux.so.2") PROVIDED = frozenset(( @@ -42,6 +42,7 @@ PROVIDED = frozenset(( "librt.so.1", "libutil.so.1", "libresolv.so.2", "libanl.so.1", "libBrokenLocale.so.1", "libnsl.so.1", "libnss_compat.so.2", "libnss_dns.so.2", "libnss_files.so.2", "libnss_hesiod.so.2", "libthread_db.so.1", "libmemusage.so", "libpcprofile.so", "libc_malloc_debug.so.0", "gconv", "libstdc++.so.6", "libgcc_s.so.1", + "libssl.so.3", "libcrypto.so.3", )) GLIBC_BUILD = re.compile(r"^(?:ld|lib[A-Za-z_]+)-2\.[0-9]+\.so$|^libSegFault\.so$") GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu")) diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index 3825946b1..79a653020 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -129,6 +129,9 @@ class FexTestCase(unittest.TestCase): self.write(provided / "gconv/NEW.so", b"new gconv") self.write(platform / "files/lib/i386-linux-gnu/libc.so.6", b"GLIBC_2.41") self.write(platform / "files/lib/i386-linux-gnu/ld-linux.so.2", b"new ld32") + for arch in ("x86_64", "i386"): + for name in ("libssl.so.3", "libcrypto.so.3"): + self.write(platform / "files/lib" / (arch + "-linux-gnu") / name, arch.encode() + name.encode()) with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out: out.write("#mtree\n. type=dir\n") self.write(self.steam / "steamapps/appmanifest_4183110.acf", '"AppState" { "installdir" "SteamLinuxRuntime_4" }') @@ -350,6 +353,10 @@ class PrepareTest(FexTestCase): self.assertEqual((x86 / "ld-linux-x86-64.so.2").read_bytes(), b"new ld") self.assertEqual((x86 / "gconv/NEW.so").read_bytes(), b"new gconv") self.assertEqual((root / "lib/i386-linux-gnu/libc.so.6").read_bytes(), b"GLIBC_2.41") + self.assertEqual((x86 / "libssl.so.3").read_bytes(), b"x86_64libssl.so.3") + self.assertEqual((x86 / "libcrypto.so.3").read_bytes(), b"x86_64libcrypto.so.3") + self.assertEqual((root / "lib/i386-linux-gnu/libssl.so.3").read_bytes(), b"i386libssl.so.3") + self.assertEqual((root / "lib/i386-linux-gnu/libcrypto.so.3").read_bytes(), b"i386libcrypto.so.3") self.assertEqual((x86 / "libSDL2-2.0.so.0").read_bytes(), b"sdl") self.assertEqual(os.readlink(x86 / "libfoo.so.1"), "libfoo.so.1.2") self.assertEqual((x86 / "libfoo.so.1").read_bytes(), b"foo") From c2a37402379d05d6b9a2bf1571d0d1398d79d47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:27:50 +0300 Subject: [PATCH 032/156] fix: uppercase section headings in the app language (#1148) * fix: uppercase section headings in the app language * fix: localize store section headings --- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 10 ++++- .../com/droiddeck/launcher/ui/StoresPage.kt | 2 +- .../launcher/ui/HeadingLocaleTest.kt | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index c6296300e..e0a8ff0b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -59,16 +59,22 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.util.Locale internal val Shape14 = RoundedCornerShape(14.dp) +@Composable +internal fun headingUppercase(text: String): String = + text.uppercase(LocalConfiguration.current.locales[0] ?: Locale.ROOT) + @Composable internal fun Eyebrow(t: String) { val pal = LocalPalette.current @@ -76,7 +82,7 @@ internal fun Eyebrow(t: String) { LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal)) - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) + Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal) } } @@ -88,7 +94,7 @@ internal fun Eyebrow(t: String) { internal fun SectionTitle(t: String, detail: String?) { val colors = MaterialTheme.colorScheme Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) { - Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) + Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant) if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground) Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 1feb8e280..d63e65646 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -961,7 +961,7 @@ private suspend fun CompletableFuture.await(): T = @Composable private fun SectionLabel(title: String) { Text( - title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp), ) } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt new file mode 100644 index 000000000..9b32b5036 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/HeadingLocaleTest.kt @@ -0,0 +1,44 @@ +package com.droiddeck.launcher.ui + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.assertCountEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class HeadingLocaleTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun headingsFollowTheAppLanguageAndRecomposeWhenItChanges() { + val appLocale = mutableStateOf(Locale.forLanguageTag("tr")) + compose.setContent { + val configuration = Configuration(LocalConfiguration.current).apply { + setLocales(LocaleList(appLocale.value)) + } + CompositionLocalProvider(LocalConfiguration provides configuration) { + DroidDeckTheme { + Column { + Eyebrow("Bileşenler Iİıi") + SectionTitle("Bileşenler Iİıi", null) + } + } + } + } + compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2) + compose.runOnIdle { appLocale.value = Locale.ENGLISH } + compose.onAllNodesWithText("BILEŞENLER IİII").assertCountEquals(2) + compose.runOnIdle { appLocale.value = Locale.forLanguageTag("tr") } + compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2) + } +} From 2d6393df242513daf624f1de1afa1739e771087a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:28:06 +0300 Subject: [PATCH 033/156] fix: keep touchpad two-finger scroll from jumping the cursor or starting a drag (#1142) Co-authored-by: ovurrsl --- .../launcher/input/TouchpadGestures.kt | 20 ++-- .../launcher/input/TouchpadGesturesTest.kt | 92 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt index 2dc403a43..47f0e4b64 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt @@ -38,6 +38,7 @@ class TouchpadGestures( private var moverLastX = 0f private var moverLastY = 0f private var holdDrag = false + private var scrollLocked = false private var clicked = 0 private val longPress = Runnable { held = true @@ -67,6 +68,7 @@ class TouchpadGestures( dragging = false held = false holdDrag = false + scrollLocked = false anchorId = -1 moverId = -1 lastTapAt = 0L @@ -84,7 +86,7 @@ class TouchpadGestures( lastX = event.x; lastY = event.y downAt = event.eventTime travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false - anchorId = event.getPointerId(0); moverId = -1; holdDrag = false + anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) { dragging = true listener.onButton(PointerGestures.BTN_LEFT, true, x, y) @@ -117,10 +119,12 @@ class TouchpadGestures( val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId) if (ai >= 0 && mi >= 0) { val mx = event.getX(mi); val my = event.getY(mi) - if (!holdDrag && !scrolled) { + if (!holdDrag && !scrolled && !scrollLocked) { val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY) val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY) - if (moverMoved > slopPx && anchorMoved < slopPx) { + if (anchorMoved >= slopPx) { + scrollLocked = true + } else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) { holdDrag = true secondWasTap = false if (!dragging) { @@ -186,9 +190,9 @@ class TouchpadGestures( if (gone == moverId) moverId = -1 if (gone == anchorId) { anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0) - event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let { - lastX = event.getX(it); lastY = event.getY(it) - } + } + event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let { + lastX = event.getX(it); lastY = event.getY(it) } if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) { click(PointerGestures.BTN_RIGHT) @@ -212,7 +216,7 @@ class TouchpadGestures( } } dragging = false; held = false; twoFingers = false - holdDrag = false; anchorId = -1; moverId = -1 + holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1 return true } } @@ -225,5 +229,7 @@ class TouchpadGestures( private const val CLICK_MS = 50L private const val DOUBLE_TAP_MS = 300L private const val SCROLL_STEP_PX = 36f + private const val HOLD_DRAG_MOVER_SLOPS = 2f + private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt index ddbf4d7f9..a036849b1 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchpadGesturesTest.kt @@ -5,6 +5,8 @@ import android.os.Looper import android.view.MotionEvent import java.time.Duration import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -109,6 +111,96 @@ class TouchpadGesturesTest { assertEquals(listOf("left down", "left up"), events) } + @Test fun liftingTheSecondFingerAfterAScrollKeepsTheCursorStill() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + assertTrue(events.contains("wheel -8")) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sx = touchpad.x + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 601f)) + assertEquals(sx, touchpad.x, 0.01f) + assertEquals(sy, touchpad.y, 0.01f) + assertFalse(events.contains("move")) + } + + @Test fun trackingResumesNormallyAfterTheScroll() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 621f)) + val delta = touchpad.y - sy + assertTrue(delta in 20f..65f) + assertTrue(events.contains("move")) + } + + @Test fun liftingTheAnchorFirstAfterAScrollKeepsTheCursorStill() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 600f), 2 to (600f to 610f)) + advance(10) + val sx = touchpad.x + val sy = touchpad.y + touch(MotionEvent.ACTION_MOVE, 0, 2 to (601f to 611f)) + assertEquals(sx, touchpad.x, 0.01f) + assertEquals(sy, touchpad.y, 0.01f) + assertFalse(events.contains("move")) + } + + @Test fun twoFingerScrollWithLeadingFingerDoesNotPressLeft() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 303f), 2 to (600f to 310f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 340f), 2 to (600f to 350f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 380f), 2 to (600f to 390f)) + assertFalse(events.contains("left down")) + assertTrue(events.any { it.startsWith("wheel ") }) + } + + @Test fun holdDragStillWorks() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (620f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (640f to 300f)) + assertEquals("left down", events.firstOrNull()) + assertTrue(events.contains("move")) + touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 300f), 2 to (640f to 300f)) + assertTrue(events.contains("left up")) + } + + @Test fun anchorMovedLocksOutHoldDrag() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) + advance(10) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (600f to 302f)) + advance(10) + touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (640f to 302f)) + assertFalse(events.contains("left down")) + } + private fun tap() { touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f)) advance(80) From 421ffc75069e949f563a1ec2ad3f58d40fb0e051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:28:22 +0300 Subject: [PATCH 034/156] fix: keep ModeHeader search from stealing focus when recomposed (#1141) Co-authored-by: ovurrsl --- .../com/droiddeck/launcher/ui/StoresPage.kt | 7 +- .../launcher/ui/ModeHeaderFocusTest.kt | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index d63e65646..723f05a17 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -996,6 +996,7 @@ internal fun ModeHeader( val colors = MaterialTheme.colorScheme val pal = LocalPalette.current var searching by remember { mutableStateOf(query.isNotEmpty()) } + var focusOnOpen by remember { mutableStateOf(false) } val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen") Column(Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) { @@ -1010,11 +1011,11 @@ internal fun ModeHeader( Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) { AdbTextField( value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, - placeholder = placeholder, compact = true, focusOnShow = true, - onFocusChange = { focused -> if (!focused && query.isEmpty()) searching = false }, + placeholder = placeholder, compact = true, focusOnShow = focusOnOpen, + onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } }, ) } - } else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true } + } else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true } } trailing() } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt new file mode 100644 index 000000000..eb35cd29d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/ModeHeaderFocusTest.kt @@ -0,0 +1,97 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w960dp-h540dp-land", application = android.app.Application::class) +class ModeHeaderFocusTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun reenteringCompositionWithNonEmptyQueryDoesNotStealFocus() { + lateinit var root: View + var show by mutableStateOf(true) + var query by mutableStateOf("celeste") + compose.setContent { + DroidDeckTheme { + val view = LocalView.current + SideEffect { root = view.rootView } + if (show) { + ModeHeader( + title = "Library", + query = query, + onQuery = { query = it }, + placeholder = "Search", + trailing = {}, + ) + } + } + } + compose.waitForIdle() + val initialField = editText(root)!! + compose.runOnIdle { + assertEquals("celeste", initialField.text.toString()) + assertFalse("Pre-populated search field must not auto-focus on mount", initialField.isFocused) + show = false + } + compose.waitForIdle() + compose.runOnIdle { show = true } + compose.waitForIdle() + val remountedField = editText(root)!! + compose.runOnIdle { + assertEquals("celeste", remountedField.text.toString()) + assertFalse("Remounted search field must not steal focus when scrolled back into view", remountedField.isFocused) + } + } + + @Test fun clickingSearchIconFocusesField() { + lateinit var root: View + var query by mutableStateOf("") + compose.setContent { + DroidDeckTheme { + val view = LocalView.current + SideEffect { root = view.rootView } + ModeHeader( + title = "Library", + query = query, + onQuery = { query = it }, + placeholder = "Search", + trailing = {}, + ) + } + } + val context = RuntimeEnvironment.getApplication() + compose.onNodeWithContentDescription(context.getString(R.string.stores_search_open)).performClick() + compose.waitForIdle() + val field = editText(root)!! + compose.runOnIdle { + assertTrue("Opening search via icon must focus the text field", field.isFocused) + } + } + + private fun editText(view: View): EditText? { + if (view is EditText) return view + if (view is ViewGroup) for (i in 0 until view.childCount) { + editText(view.getChildAt(i))?.let { return it } + } + return null + } +} From fd74819c50efebc5b771b30043e7c8bbc7d693ec Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 19 Oct 2026 23:06:48 -0400 Subject: [PATCH 035/156] fix: Steam no longer sleeps again right after a paused session resumes (#1162) --- .../launcher/session/SessionService.kt | 23 ++-- .../overlay/usr/local/bin/droiddeck-agent | 60 +++++++++ .../overlay/usr/local/bin/droiddeck-login1 | 119 ++++++++++++++++-- tools/tests/test_droiddeck_agent.py | 27 ++++ tools/tests/test_login1.py | 51 +++++++- 5 files changed, 261 insertions(+), 19 deletions(-) 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 e9f7fb390..ff1a7aeeb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -80,6 +80,7 @@ class SessionService : Service() { private var nativeSleepToken: String? = null private var nativeSleepReady = false private var nativeSleepFallback = false + private var nativeSleepIdle = false private var nativeSleepTimeout: Runnable? = null private var suspendOperationPending = false private var pipTask = false @@ -1019,7 +1020,7 @@ class SessionService : Service() { screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn manualPauseRequested = false suspendDownloadNow = false - if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake() + if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() completeSteamSleep() suspendAttemptFailed = false @@ -1219,16 +1220,22 @@ class SessionService : Service() { val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow if (downloadsBlockingSuspend) acquireLocks() - if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) { + val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) { + SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden + SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested + else -> false + } + if (!steamPause) { completeNativeSleep() } else if (nativeSleepToken == null && steamSleepToken == null && !SessionState.suspended && !suspendAttemptFailed) { prepareNativeSleep() } + val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady val shouldSuspend = steamSleepToken != null || when (suspendPolicy) { - SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend + SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend - SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend + SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend else -> false } if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return @@ -1296,7 +1303,7 @@ class SessionService : Service() { private fun resumeSteamSleepOnReturn() { if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) { - if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake() + if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake() completeNativeSleep() completeSteamSleep() } @@ -1307,13 +1314,14 @@ class SessionService : Service() { nativeSleepToken = token nativeSleepReady = false nativeSleepFallback = false + nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE val dir = LinuxRuntime.sessionRoot(this) runCatching { val staged = File(dir, "steam-native-sleep.tmp") - staged.writeText("$token\n") + staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n") check(staged.renameTo(File(dir, "steam-native-sleep"))) }.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) } - SessionEvents.record("steam.native_sleep_requested") + SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle)) val gen = sessionGen val timeout = Runnable { if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable @@ -1339,6 +1347,7 @@ class SessionService : Service() { nativeSleepToken = null nativeSleepReady = false nativeSleepFallback = false + nativeSleepIdle = false } private fun requestSteamWake() { diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 81d2ad5c0..255d3f255 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -532,6 +532,46 @@ def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15) ws.close() +def steam_idle_inhibit(agent_dir, inhibit): + expression = """(async want => { + const toggle = () => new Promise(done => { + let handle = null; + let timer = null; + const finish = value => { + clearTimeout(timer); + handle?.unregister?.(); + done(value); + }; + timer = setTimeout(() => finish(null), 2500); + handle = SteamClient.Console.RegisterForSpewOutput(entry => { + const found = /Suspend inhibit: ([01])/.exec(entry?.spew || ""); + if (found) finish(found[1] === "1"); + }); + SteamClient.Console.ExecCommand("toggle_idle_suspend_inhibit 1"); + }); + let state = await toggle(); + if (state === !want) state = await toggle(); + return state === want; + })(%s)""" % ("true" if inhibit else "false") + return cdp_eval(agent_dir, expression, timeout=12).get("value") is True + + +def steam_idle_suspend_seconds(agent_dir, vpower=Path("/run/vpower/ac_status")): + expression = """(() => { + const settings = globalThis.settingsStore?.clientSettings; + return settings ? [settings.system_idle_suspend_ac_sec, settings.system_idle_suspend_battery_sec] : null; + })()""" + seconds = cdp_eval(agent_dir, expression, await_promise=False, timeout=5).get("value") + if not isinstance(seconds, list) or len(seconds) != 2: + return None + try: + on_ac = vpower.read_text().strip() != "Disconnected" + except OSError: + on_ac = True + value = seconds[0 if on_ac else 1] + return max(0, int(value)) if isinstance(value, (int, float)) else None + + def steam_selection(agent_dir, timeout=5): result = cdp_eval( agent_dir, @@ -923,6 +963,26 @@ def main(argv): state = "unknown" print(state) return {"yes": 0, "no": 5}.get(state, 1) + if argv and argv[0] == "--steam-idle-inhibit": + if len(argv) != 2 or argv[1] not in ("on", "off"): + print("usage: droiddeck-agent --steam-idle-inhibit on|off", file=sys.stderr) + return 2 + try: + done = steam_idle_inhibit(Path(base) / "agent", argv[1] == "on") + except (AgentError, OSError, ValueError) as error: + print("droiddeck-agent: could not reach the client's idle sleep: %s" % error, file=sys.stderr) + return 1 + return 0 if done else 1 + if argv and argv[0] == "--steam-idle-suspend-seconds": + try: + seconds = steam_idle_suspend_seconds(Path(base) / "agent") + except (AgentError, OSError, ValueError) as error: + print("droiddeck-agent: could not read the client's idle sleep: %s" % error, file=sys.stderr) + return 1 + if seconds is None: + return 1 + print(seconds) + return 0 if argv and argv[0] == "--steam-shortcut-state": if len(argv) not in (2, 3): print("usage: droiddeck-agent --steam-shortcut-state [timeout-seconds]", file=sys.stderr) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 index c3b884e41..5c04842c0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 @@ -28,6 +28,8 @@ UPOWER_ROOT = "/org/freedesktop/UPower" NATIVE_PREPARE_MS = 1500 AGENT = Path(__file__).resolve().with_name("droiddeck-agent") ASLEEP_WAIT = 6 +INHIBIT_WAIT = 12 +RELEASE_RETRIES = 8 INTROSPECTION = """ @@ -58,8 +60,14 @@ class LoginManager: self.native_request = self.directory / "steam-native-sleep" self.native_ready = self.directory / "steam-native-ready" self.pending = None + self.pending_idle = False + self.waiting = set() self.timeout = None self.preparation = None + self.inhibited = False + self.inhibit_lock = threading.Lock() + self.release = None + self.generation = 0 info = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION).interfaces[0] self.registration = bus.register_object(ROOT, info, self.method, self.property, None) upower_info = Gio.DBusNodeInfo.new_for_xml(UPOWER_INTROSPECTION).interfaces[0] @@ -89,6 +97,7 @@ class LoginManager: reply.return_dbus_error(NAME + ".OperationInProgress", "The session is already pausing") return self.pending = uuid.uuid4().hex + self.pending_idle = False self.notify(True) reply.return_value(None) self.bus.flush(None, self.publish_after_flush, self.pending) @@ -101,7 +110,7 @@ class LoginManager: self.resume() return if self.pending == token: - self.publish(self.request) + self.hold(token, self.publish, self.request) def publish(self, request): self.check_state() @@ -119,15 +128,21 @@ class LoginManager: def prepare_native(self): try: - token = self.native_request.read_text().strip() + words = self.native_request.read_text().split() self.native_request.unlink(missing_ok=True) except OSError: return - if not re.fullmatch(r"[a-f0-9]{32}", token) or self.pending is not None: + token = words[0] if words else "" + if not re.fullmatch(r"[a-f0-9]{32}", token) or words[1:] not in ([], ["idle"]) or self.pending is not None: return if self.read_state() == token + ":awake": return self.pending = token + self.pending_idle = words[1:] == ["idle"] + self.waiting = {"hold"} if self.pending_idle else {"hold", "prepare"} + self.hold(token, self.prepared, token, "hold") + if self.pending_idle: + return self.notify(True) self.bus.flush(None, self.prepare_after_flush, token) @@ -144,10 +159,73 @@ class LoginManager: def finish_preparation(self, token): self.preparation = None - if self.pending == token: + self.prepared(token, "prepare") + return False + + def prepared(self, token, step): + if self.pending != token: + return False + self.waiting.discard(step) + if not self.waiting: self.publish(self.native_ready) return False + def hold(self, token, then, *args): + self.cancel_release() + + def work(): + self.set_inhibit(True) + GLib.idle_add(self.after_hold, token, then, args) + + threading.Thread(target=work, daemon=True).start() + + def after_hold(self, token, then, args): + if self.pending == token: + then(*args) + elif self.pending is None: + self.release_later() + return False + + def set_inhibit(self, inhibit, generation=None): + with self.inhibit_lock: + if (generation is not None and generation != self.generation) or self.inhibited == inhibit: + return + if steam_idle_inhibit(inhibit): + self.inhibited = inhibit + print("== login1: Steam idle sleep " + ("held" if inhibit else "released"), flush=True) + else: + self.inhibited = None + + def cancel_release(self): + self.generation += 1 + if self.release is not None: + GLib.source_remove(self.release) + self.release = None + + def release_later(self): + self.cancel_release() + if self.inhibited is False: + return + generation = self.generation + threading.Thread(target=lambda: GLib.idle_add(self.schedule_release, generation, steam_idle_suspend_seconds()), + daemon=True).start() + + def schedule_release(self, generation, seconds, attempt=0): + if generation == self.generation and self.pending is None: + self.release = GLib.timeout_add_seconds(seconds or 0, self.release_now, generation, attempt) + return False + + def release_now(self, generation, attempt): + self.release = None + + def work(): + self.set_inhibit(False, generation) + if self.inhibited is not False and attempt < RELEASE_RETRIES: + GLib.idle_add(self.schedule_release, generation, 1 << attempt, attempt + 1) + + threading.Thread(target=work, daemon=True).start() + return False + def changed(self, monitor, file, other, event): if file.get_basename() == self.native_request.name or (other and other.get_basename() == self.native_request.name): self.prepare_native() @@ -178,8 +256,10 @@ class LoginManager: def woken(self): if self.pending is not None: + idle = self.pending_idle self.resume(force=True) - return + if not idle: + return threading.Thread(target=lambda: GLib.idle_add(self.replay_unless_awake, steam_asleep()), daemon=True).start() def replay_unless_awake(self, asleep): @@ -195,14 +275,18 @@ class LoginManager: self.notify(True) self.bus.flush(None, self.finish_recovery, None) return + idle = self.pending_idle self.pending = None + self.pending_idle = False if self.preparation is not None: GLib.source_remove(self.preparation) self.preparation = None if self.timeout is not None: GLib.source_remove(self.timeout) self.timeout = None - self.notify(False) + if not idle: + self.notify(False) + self.release_later() def finish_recovery(self, connection, result, _data): try: @@ -213,13 +297,26 @@ class LoginManager: self.notify(False) -def steam_asleep(): +def agent(*args, timeout=ASLEEP_WAIT): try: - code = subprocess.run([str(AGENT), "--steam-asleep"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, timeout=ASLEEP_WAIT).returncode + done = subprocess.run([str(AGENT), *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, timeout=timeout) except (OSError, subprocess.SubprocessError): - return None - return {0: True, 5: False}.get(code) + return None, "" + return done.returncode, done.stdout + + +def steam_asleep(): + return {0: True, 5: False}.get(agent("--steam-asleep")[0]) + + +def steam_idle_inhibit(inhibit): + return agent("--steam-idle-inhibit", "on" if inhibit else "off", timeout=INHIBIT_WAIT)[0] == 0 + + +def steam_idle_suspend_seconds(): + code, output = agent("--steam-idle-suspend-seconds") + return int(output) if code == 0 and output.strip().isdigit() else None def load_netmanager(): diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 986931a84..8997bff97 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -338,6 +338,33 @@ class AgentTest(unittest.TestCase): with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")): self.assertEqual(1, agent.main(["--steam-asleep"])) + def test_idle_inhibit_command_reports_whether_steam_reached_the_state(self): + with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}): + for value, code in ((True, 0), (False, 1), (None, 1)): + with self.subTest(value=value), patch.object(agent, "cdp_eval", return_value={"value": value}) as ask: + self.assertEqual(code, agent.main(["--steam-idle-inhibit", "on"])) + self.assertIn("toggle_idle_suspend_inhibit 1", ask.call_args.args[1]) + self.assertTrue(ask.call_args.args[1].rstrip().endswith("(true)")) + with patch.object(agent, "cdp_eval", return_value={"value": True}) as ask: + self.assertEqual(0, agent.main(["--steam-idle-inhibit", "off"])) + self.assertTrue(ask.call_args.args[1].rstrip().endswith("(false)")) + with patch.object(agent, "cdp_eval", side_effect=agent.AgentError("NO_DEVTOOLS", "down")): + self.assertEqual(1, agent.main(["--steam-idle-inhibit", "on"])) + self.assertEqual(2, agent.main(["--steam-idle-inhibit", "maybe"])) + + def test_idle_suspend_seconds_follow_the_power_source(self): + vpower = self.dir / "ac_status" + with patch.object(agent, "cdp_eval", return_value={"value": [3600, 900]}): + vpower.write_text("Connected\n") + self.assertEqual(3600, agent.steam_idle_suspend_seconds(self.dir, vpower)) + vpower.write_text("Disconnected\n") + self.assertEqual(900, agent.steam_idle_suspend_seconds(self.dir, vpower)) + with patch.object(agent, "cdp_eval", return_value={"value": None}): + self.assertIsNone(agent.steam_idle_suspend_seconds(self.dir, vpower)) + with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}), \ + patch.object(agent, "steam_idle_suspend_seconds", return_value=None): + self.assertEqual(1, agent.main(["--steam-idle-suspend-seconds"])) + def test_cdp_exception(self): server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": { "text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}}) diff --git a/tools/tests/test_login1.py b/tools/tests/test_login1.py index 9150d2d78..e257ccf81 100644 --- a/tools/tests/test_login1.py +++ b/tools/tests/test_login1.py @@ -77,6 +77,7 @@ class SleepHandshakeTest(unittest.TestCase): GLib.Variant("(ss)", (IFACE, "PreparingForSleep")))[0] def suspend(self): + (self.dir / "steam-sleep").unlink(missing_ok=True) self.call(IFACE, "Suspend", GLib.Variant("(b)", (True,))) self.wait_for(lambda: (self.dir / "steam-sleep").exists()) self.wait_for(lambda: self.signals == [True]) @@ -239,6 +240,7 @@ class FakeAgentCase(unittest.TestCase): suspend = SleepHandshakeTest.suspend state = SleepHandshakeTest.state request_wake = SleepHandshakeTest.request_wake + request_native = SleepHandshakeTest.request_native def asked(self): path = self.dir / "agent-calls" @@ -264,7 +266,7 @@ class AwakeSteamTest(FakeAgentCase): self.state(token, "paused") self.request_wake() self.wait_for(lambda: self.signals == [True, False]) - self.assertEqual([], self.asked()) + self.assertNotIn("--steam-asleep", self.asked()) @unittest.skipUnless(Gio and os.environ.get("DBUS_SESSION_BUS_ADDRESS"), "requires dbus-run-session and PyGObject") @@ -276,6 +278,53 @@ class AsleepSteamTest(FakeAgentCase): self.wait_for(lambda: self.signals == [True, False]) self.assertEqual(["--steam-asleep"], self.asked()) + def test_steam_sleep_request_holds_idle_sleep_before_the_host_pauses(self): + token = self.suspend() + self.assertEqual(["--steam-idle-inhibit", "on"], self.asked()) + self.state(token, "paused") + self.state(token, "awake") + self.wait_for(lambda: self.signals == [True, False]) + self.wait_for(lambda: self.asked()[-1:] == ["off"]) + self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds", + "--steam-idle-inhibit", "off"], self.asked()) + + def test_idle_pause_holds_idle_sleep_without_a_sleep_wake_pair(self): + token = self.request_native("2" * 32 + " idle").split()[0] + self.wait_for(lambda: (self.dir / "steam-native-ready").exists()) + self.assertEqual(token, (self.dir / "steam-native-ready").read_text().strip()) + self.assertEqual(["--steam-idle-inhibit", "on"], self.asked()) + self.state(token, "paused") + self.state(token, "awake") + self.wait_for(lambda: self.asked()[-1:] == ["off"]) + self.assertEqual([], self.signals) + self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds", + "--steam-idle-inhibit", "off"], self.asked()) + + def test_a_failed_release_is_tried_again(self): + failed = self.dir / "release-failed" + (self.dir / "bin/droiddeck-agent").write_text( + "#!/bin/sh\necho \"$@\" >> \"%s\"\n" + "if [ \"$2\" = off ] && [ ! -e \"%s\" ]; then : > \"%s\"; exit 1; fi\nexit 0\n" + % (self.dir / "agent-calls", failed, failed)) + token = self.suspend() + self.state(token, "paused") + self.state(token, "awake") + self.wait_for(lambda: self.asked().count("off") == 2) + time.sleep(2.5) + self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds", + "--steam-idle-inhibit", "off", "--steam-idle-inhibit", "off"], self.asked()) + + def test_a_hold_steam_did_not_confirm_is_still_released(self): + (self.dir / "bin/droiddeck-agent").write_text( + "#!/bin/sh\necho \"$@\" >> \"%s\"\n[ \"$2\" = on ] && exit 1\nexit 0\n" % (self.dir / "agent-calls")) + token = self.suspend() + self.state(token, "paused") + self.state(token, "awake") + self.wait_for(lambda: self.signals == [True, False]) + self.wait_for(lambda: self.asked()[-1:] == ["off"]) + self.assertEqual(["--steam-idle-inhibit", "on", "--steam-idle-suspend-seconds", + "--steam-idle-inhibit", "off"], self.asked()) + class WithNetworkManagerTest(SleepHandshakeTest): ARGS = ["--with-netmanager"] From fe7b75318a8721e92bd0f1a13a4232a56894cef6 Mon Sep 17 00:00:00 2001 From: Grimarius Date: Mon, 19 Oct 2026 22:22:41 -0500 Subject: [PATCH 036/156] feat: add configurable touchscreen gestures (#940) --- .github/workflows/build.yml | 11 +- app/src/main/cpp/waylandcomp/src/compositor.c | 6 +- .../com/droiddeck/launcher/SessionActivity.kt | 127 +- .../droiddeck/launcher/input/GestureKeys.kt | 111 ++ .../droiddeck/launcher/input/GesturePrefs.kt | 24 + .../launcher/input/TouchGestureConfig.kt | 226 +++ .../launcher/input/TouchscreenGestures.java | 1332 +++++++++++++++++ .../launcher/session/SessionPrefs.kt | 1 + .../droiddeck/launcher/ui/GestureActions.kt | 128 ++ .../droiddeck/launcher/ui/GestureFeedback.kt | 35 + .../launcher/ui/ModeSettingsDialog.kt | 2 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 6 +- .../droiddeck/launcher/ui/SettingsWidgets.kt | 4 +- .../launcher/ui/TouchGestureDialog.kt | 345 +++++ app/src/main/res/values-ar/gestures.xml | 103 ++ app/src/main/res/values-b+es+419/gestures.xml | 103 ++ app/src/main/res/values-bg/gestures.xml | 103 ++ app/src/main/res/values-cs/gestures.xml | 103 ++ app/src/main/res/values-da/gestures.xml | 103 ++ app/src/main/res/values-de/gestures.xml | 103 ++ app/src/main/res/values-el/gestures.xml | 103 ++ app/src/main/res/values-es/gestures.xml | 103 ++ app/src/main/res/values-fi/gestures.xml | 103 ++ app/src/main/res/values-fr/gestures.xml | 103 ++ app/src/main/res/values-hi/gestures.xml | 103 ++ app/src/main/res/values-hu/gestures.xml | 103 ++ app/src/main/res/values-in/gestures.xml | 103 ++ app/src/main/res/values-it/gestures.xml | 103 ++ app/src/main/res/values-ja/gestures.xml | 103 ++ app/src/main/res/values-ko/gestures.xml | 103 ++ app/src/main/res/values-ms/gestures.xml | 103 ++ app/src/main/res/values-nb/gestures.xml | 103 ++ app/src/main/res/values-nl/gestures.xml | 103 ++ app/src/main/res/values-pl/gestures.xml | 103 ++ app/src/main/res/values-pt-rPT/gestures.xml | 103 ++ app/src/main/res/values-pt/gestures.xml | 103 ++ app/src/main/res/values-ro/gestures.xml | 103 ++ app/src/main/res/values-ru/gestures.xml | 103 ++ app/src/main/res/values-sv/gestures.xml | 103 ++ app/src/main/res/values-th/gestures.xml | 103 ++ app/src/main/res/values-tr/gestures.xml | 103 ++ app/src/main/res/values-uk/gestures.xml | 103 ++ app/src/main/res/values-vi/gestures.xml | 103 ++ app/src/main/res/values-zh-rCN/gestures.xml | 103 ++ app/src/main/res/values-zh-rHK/gestures.xml | 103 ++ app/src/main/res/values-zh-rTW/gestures.xml | 103 ++ app/src/main/res/values/gestures.xml | 103 ++ .../launcher/input/GesturePrefsTest.kt | 36 + .../launcher/input/GestureRoutingTest.kt | 61 + .../input/SessionGestureRoutingTest.kt | 266 ++++ .../launcher/input/TouchscreenGesturesTest.kt | 1055 +++++++++++++ .../launcher/ui/GestureActionsTest.kt | 37 + .../launcher/ui/GestureLocalizationTest.kt | 90 ++ .../launcher/ui/TouchGestureDialogTest.kt | 112 ++ tools/tests/test_esync_packs.py | 4 +- 55 files changed, 7404 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt create mode 100644 app/src/main/res/values-ar/gestures.xml create mode 100644 app/src/main/res/values-b+es+419/gestures.xml create mode 100644 app/src/main/res/values-bg/gestures.xml create mode 100644 app/src/main/res/values-cs/gestures.xml create mode 100644 app/src/main/res/values-da/gestures.xml create mode 100644 app/src/main/res/values-de/gestures.xml create mode 100644 app/src/main/res/values-el/gestures.xml create mode 100644 app/src/main/res/values-es/gestures.xml create mode 100644 app/src/main/res/values-fi/gestures.xml create mode 100644 app/src/main/res/values-fr/gestures.xml create mode 100644 app/src/main/res/values-hi/gestures.xml create mode 100644 app/src/main/res/values-hu/gestures.xml create mode 100644 app/src/main/res/values-in/gestures.xml create mode 100644 app/src/main/res/values-it/gestures.xml create mode 100644 app/src/main/res/values-ja/gestures.xml create mode 100644 app/src/main/res/values-ko/gestures.xml create mode 100644 app/src/main/res/values-ms/gestures.xml create mode 100644 app/src/main/res/values-nb/gestures.xml create mode 100644 app/src/main/res/values-nl/gestures.xml create mode 100644 app/src/main/res/values-pl/gestures.xml create mode 100644 app/src/main/res/values-pt-rPT/gestures.xml create mode 100644 app/src/main/res/values-pt/gestures.xml create mode 100644 app/src/main/res/values-ro/gestures.xml create mode 100644 app/src/main/res/values-ru/gestures.xml create mode 100644 app/src/main/res/values-sv/gestures.xml create mode 100644 app/src/main/res/values-th/gestures.xml create mode 100644 app/src/main/res/values-tr/gestures.xml create mode 100644 app/src/main/res/values-uk/gestures.xml create mode 100644 app/src/main/res/values-vi/gestures.xml create mode 100644 app/src/main/res/values-zh-rCN/gestures.xml create mode 100644 app/src/main/res/values-zh-rHK/gestures.xml create mode 100644 app/src/main/res/values-zh-rTW/gestures.xml create mode 100644 app/src/main/res/values/gestures.xml create mode 100644 app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/TouchGestureDialogTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 306e459b3..2e346f6b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -254,8 +254,6 @@ jobs: - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \ @@ -292,7 +290,7 @@ jobs: done if [ -f tools/gamescope/release.env ]; then . tools/gamescope/release.env - gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst + curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst" echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c - tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D" test -f "$D/usr/local/bin/gamescope" @@ -395,18 +393,17 @@ jobs: ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386 - name: Bundle the droiddeck-esync packs - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if [ -f tools/droiddeck-esync/release.env ]; then . tools/droiddeck-esync/release.env - gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst + release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}" + curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET" echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c - mkdir -p app/src/main/assets/droiddeck-esync tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync rm sync-bundle.tzst - gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json + curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json" revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json) for id in $revoked; do if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 014c0d191..99bbbef02 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -2593,7 +2593,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int uint32_t t = now_ms(); pointer_focus(target->resource, fx, fy); - if (!(k && k->is_lock)) + // A button-only input must not replay a cached absolute position. A game + // may have warped its cursor since the last motion (before taking lock, + // or while releasing it), so that replay can become an unintended pan. + // Absolute touch events still carry their position, including on clicks. + if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0)) for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy); send_relative_motion(client, dx, dy); for_each_pointer_of(client, sp) { diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8f4de1167..510fd7795 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -81,6 +81,7 @@ import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplays import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures +import com.droiddeck.launcher.input.TouchscreenGestures import com.droiddeck.launcher.input.ScrollTouchGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState @@ -191,6 +192,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var touchpad: TouchpadGestures private lateinit var directTouchHold: DirectTouchHold private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap + private lateinit var touchscreenGestures: TouchscreenGestures + private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig() + private var gestureGame: String? = null + private var gestureSettingsOpen by mutableStateOf(false) + private var recognizedGesture by mutableStateOf(null) + private var gesturePointerX = 0f + private var gesturePointerY = 0f + private var gesturePointerReady = false + private val clearRecognizedGesture = Runnable { recognizedGesture = null } + private val gestureKeysDown = mutableSetOf() private lateinit var scrollTouch: ScrollTouchGestures private lateinit var padMouse: PadMouse @Volatile private var padMouseOn = false @@ -211,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ -> uiHandler.post { if (pointerLocked == locked) return@post + gesturePointerReady = false pointerLocked = locked Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released") padMouseCheckedAt = 0L @@ -489,6 +501,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { WaylandCompositor.nativeSendTouch(3, -1, 0, 0) } threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2) + touchscreenGestures = TouchscreenGestures( + ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput, + ) + refreshGestureConfig() + // A real touch target lets FrameLayout split fingers between the + // game surface and on-screen buttons, in either finger-down order. + root.isMotionEventSplittingEnabled = true + surfaceView.setOnTouchListener { _, event -> + routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event) + } padMouse = PadMouse(padMouseListener) bridge.setTouchRoute(object : PadBridge.TouchRoute { override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn @@ -648,6 +670,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)) }, ) + LaunchedEffect(AgentGuest.focusRevision) { + val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" } + else { + val snapshot = AgentGuest.focus(this@SessionActivity) + val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L } + val windows = snapshot?.optJSONArray("focusableApps") + focused?.toString() ?: windows?.let { values -> + (0 until values.length()).map { values.optLong(it) } + .filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString() + } + } + } + if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() } + } + LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) { + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) { + cancelTouchGestures() + } + } + com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture) + if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog( + game = gestureGame, + onDismiss = { gestureSettingsOpen = false }, + onSaved = { gestureSettingsOpen = false; refreshGestureConfig() }, + ) androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { syncLossless() @@ -823,6 +871,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.suspendPolicyChanged(this@SessionActivity) }, onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() }, + onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true }, onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, onSecondScreenMode = ::selectSecondScreenMode, onSecondScreenDisplay = ::selectSecondScreenDisplay, @@ -1543,6 +1592,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { surfaceW = width surfaceH = height if (resized) { + cancelTouchGestures() Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor") CompositorHost.resize(holder.surface) { applyFrameGen() } followWindow(width, height) @@ -1577,6 +1627,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) + cancelTouchGestures() applyFollowOrientation(newConfig) applyUnfoldedControls() } @@ -1585,12 +1636,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Hiding the phone surface after a handoff delivers a late destruction // callback. It must not reset the new output's size or attachment. if (holder !== outputHolder()) return + cancelTouchGestures() surfaceW = 0 surfaceH = 0 glassesOutputActive = false CompositorHost.detach(holder.surface) } + private fun cancelTouchGestures() { + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() + if (::scrollTouch.isInitialized) scrollTouch.cancel() + scrollTouchGesture = false + gesturePointerReady = false + } + private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) { windowManager.maximumWindowMetrics.bounds } else { @@ -1992,6 +2052,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.dispatchKeyEvent(event) } + private fun refreshGestureConfig() { + gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame) + if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig) + gesturePointerReady = false + recognizedGesture = null + } + + private val gestureOutput = object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { + val rect = drawnRect() ?: return + if (pointerLocked) { + if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true) + } else movePointer(x, y) + gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right) + gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom) + gesturePointerReady = true + cursorVisible = false + } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + val rect = drawnRect() ?: return + if (relative || pointerLocked) { + val scale = SessionState.outputSize.first / rect.width() + WaylandCompositor.sendPointerDelta(dx * scale, dy * scale) + } else moveTo(gesturePointerX + dx, gesturePointerY + dy) + } + override fun button(code: Int, down: Boolean) { + WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0) + } + override fun prepareRelativePan() { + if (pointerLocked) return + val rect = drawnRect() ?: return + // Gamescope retains the last absolute position while a game can warp + // its own cursor to the center. Its first unlocked motion must start + // from that same origin, before the game switches to pointer lock. + moveTo(rect.centerX(), rect.centerY()) + } + override fun key(code: Int, down: Boolean) { + if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code) + if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + } + override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) } + override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } } + override fun gesture(gesture: TouchscreenGestures.Gesture) { + recognizedGesture = gesture + uiHandler.removeCallbacks(clearRecognizedGesture) + uiHandler.postDelayed(clearRecognizedGesture, 1200) + } + } + private val pointerListener = object : PointerGestures.Listener { override fun onMove(x: Float, y: Float) { if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y) @@ -2282,6 +2391,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this) return when { options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF + SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD else -> SessionPrefs.TOUCH_DIRECT } @@ -2294,6 +2404,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { touchpad.sensitivity = 1.15f * speed } if (next == routedTouchMode) return + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + gesturePointerReady = false if (::touchpad.isInitialized) touchpad.cancel() if (::directTouchHold.isInitialized) directTouchHold.cancel() if (::scrollTouch.isInitialized) scrollTouch.cancel() @@ -2330,6 +2442,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onTouchEvent(event: MotionEvent): Boolean { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event) + if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true + if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false + return touchscreenGestures.onTouch(event) + } if (event.actionMasked == MotionEvent.ACTION_DOWN) { scrollTouchGesture = scrollTouchMode() scrollRestX = 0f @@ -2561,7 +2678,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun sendGuestKey(code: Int, down: Boolean) { if (down && pipUi) return if (down) guestKeysDown.add(code) else guestKeysDown.remove(code) - WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) + if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) } private fun pipBounds(): android.graphics.Rect { @@ -2716,7 +2833,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onPause() { if (runtimeRemovalBlocked) { super.onPause(); return } sessionClipboard.stop() + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() if (::directTouchHold.isInitialized) directTouchHold.cancel() + uiHandler.removeCallbacks(clearRecognizedGesture) + gestureSettingsOpen = false drawerOpen = false pcKeyboardOpen = false achievementsOpen = false @@ -3092,6 +3213,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onWindowFocusChanged(hasFocus) if (!hasFocus && ::padMouse.isInitialized) padMouse.reset() if (runtimeRemovalBlocked) return + if (!hasFocus) { + if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel() + if (::touchpad.isInitialized) touchpad.cancel() + } if (hasFocus && !pipUi) { goFullscreen() refreshClipboard() diff --git a/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt new file mode 100644 index 000000000..2efdb940b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GestureKeys.kt @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input + +object GestureKeys { + val codes: Map = linkedMapOf( + "ESC" to 1, + "1" to 2, + "2" to 3, + "3" to 4, + "4" to 5, + "5" to 6, + "6" to 7, + "7" to 8, + "8" to 9, + "9" to 10, + "0" to 11, + "MINUS" to 12, + "EQUAL" to 13, + "BKSP" to 14, + "TAB" to 15, + "Q" to 16, + "W" to 17, + "E" to 18, + "R" to 19, + "T" to 20, + "Y" to 21, + "U" to 22, + "I" to 23, + "O" to 24, + "P" to 25, + "BRACKET_LEFT" to 26, + "BRACKET_RIGHT" to 27, + "ENTER" to 28, + "CTRL_L" to 29, + "A" to 30, + "S" to 31, + "D" to 32, + "F" to 33, + "G" to 34, + "H" to 35, + "J" to 36, + "K" to 37, + "L" to 38, + "SEMICOLON" to 39, + "APOSTROPHE" to 40, + "GRAVE" to 41, + "SHIFT_L" to 42, + "BACKSLASH" to 43, + "Z" to 44, + "X" to 45, + "C" to 46, + "V" to 47, + "B" to 48, + "N" to 49, + "M" to 50, + "COMMA" to 51, + "PERIOD" to 52, + "SLASH" to 53, + "SHIFT_R" to 54, + "KP_MULTIPLY" to 55, + "ALT_L" to 56, + "SPACE" to 57, + "CAPS_LOCK" to 58, + "F1" to 59, + "F2" to 60, + "F3" to 61, + "F4" to 62, + "F5" to 63, + "F6" to 64, + "F7" to 65, + "F8" to 66, + "F9" to 67, + "F10" to 68, + "NUM_LOCK" to 69, + "SCROLL_LOCK" to 70, + "KP_7" to 71, + "KP_8" to 72, + "KP_9" to 73, + "KP_SUBTRACT" to 74, + "KP_4" to 75, + "KP_5" to 76, + "KP_6" to 77, + "KP_ADD" to 78, + "KP_1" to 79, + "KP_2" to 80, + "KP_3" to 81, + "KP_0" to 82, + "KP_DEL" to 83, + "F11" to 87, + "F12" to 88, + "KP_ENTER" to 96, + "CTRL_R" to 97, + "KP_DIVIDE" to 98, + "PRTSCN" to 99, + "ALT_R" to 100, + "HOME" to 102, + "UP" to 103, + "PRIOR" to 104, + "LEFT" to 105, + "RIGHT" to 106, + "END" to 107, + "DOWN" to 108, + "NEXT" to 109, + "INSERT" to 110, + "DEL" to 111, + ) + @JvmStatic fun code(name: String): Int? = codes[name] +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt new file mode 100644 index 000000000..b8be5bf54 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt @@ -0,0 +1,24 @@ +package com.droiddeck.launcher.input + +import android.content.Context + +/** Global defaults with optional overrides for the currently focused game. */ +object GesturePrefs { + private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE) + + fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game") + + fun read(context: Context, game: String? = null): TouchGestureConfig { + val p = prefs(context) + val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null) + return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json) + } + + fun save(context: Context, game: String?, config: TouchGestureConfig) { + prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply() + } + + fun clear(context: Context, game: String?) { + prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt new file mode 100644 index 000000000..8021536a9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt @@ -0,0 +1,226 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input + +import org.json.JSONObject + +data class TouchGestureConfig( + val tapEnabled: Boolean = true, + val tapAction: String = ACTION_LEFT_CLICK, + val dragEnabled: Boolean = true, + val dragAction: String = PAN_LEFT_CLICK_DRAG, + val longPressEnabled: Boolean = false, + val longPressAction: String = ACTION_RIGHT_CLICK, + val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val longPressDelay: Int = DEFAULT_DELAY_MS, + val doubleTapEnabled: Boolean = true, + val doubleTapDelay: Int = DEFAULT_DELAY_MS, + val twoFingerDragEnabled: Boolean = true, + val twoFingerDragAction: String = PAN_ARROW_KEYS, + val pinchEnabled: Boolean = true, + val pinchAction: String = ZOOM_SCROLL_WHEEL, + val twoFingerTapEnabled: Boolean = true, + val twoFingerTapAction: String = ACTION_RIGHT_CLICK, + val twoFingerHoldEnabled: Boolean = true, + val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK, + val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS, + val threeFingerTapEnabled: Boolean = true, + val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD, + val threeFingerDragEnabled: Boolean = false, + val threeFingerDragAction: String = PAN_ARROW_KEYS, + val threeFingerHoldEnabled: Boolean = true, + val threeFingerHoldAction: String = ACTION_KEY_ESC, + val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD, + val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS, + val showGestureDebugOverlay: Boolean = false, + val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD, + val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT, +) { + + fun toJson(): String { + return JSONObject().apply { + put(KEY_TAP_ENABLED, tapEnabled) + put(KEY_TAP_ACTION, tapAction) + put(KEY_DRAG_ENABLED, dragEnabled) + put(KEY_DRAG_ACTION, dragAction) + put(KEY_LONG_PRESS_ENABLED, longPressEnabled) + put(KEY_LONG_PRESS_ACTION, longPressAction) + put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior) + put(KEY_LONG_PRESS_DELAY, longPressDelay) + put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled) + put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay) + put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled) + put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction) + put(KEY_PINCH_ENABLED, pinchEnabled) + put(KEY_PINCH_ACTION, pinchAction) + put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled) + put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction) + put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled) + put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction) + put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior) + put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay) + put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled) + put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction) + put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled) + put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction) + put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled) + put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction) + put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior) + put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay) + put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay) + put(KEY_GESTURE_THRESHOLD, gestureThreshold) + put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode) + }.toString() + } + + companion object { + const val DEFAULT_DELAY_MS = 300 + const val DEFAULT_GESTURE_THRESHOLD = 40 + const val ACTION_LEFT_CLICK = "left_click" + const val ACTION_RIGHT_CLICK = "right_click" + const val ACTION_MIDDLE_CLICK = "middle_click" + + const val MOUSE_BEHAVIOR_HOLD = "hold" + const val MOUSE_BEHAVIOR_CLICK = "click" + + const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct" + const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative" + const val ACTION_SHOW_KEYBOARD = "show_keyboard" + const val ACTION_KEY_ESC = "key_ESC" + const val ACTION_KEY_TILDE = "key_TILDE" + const val PAN_WASD = "wasd" + const val PAN_INVERTED_WASD = "inverted_wasd" + const val PAN_ARROW_KEYS = "arrow_keys" + const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys" + const val PAN_MIDDLE_MOUSE = "middle_mouse_pan" + const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan" + const val PAN_LEFT_CLICK_DRAG = "left_click_drag" + const val PAN_RIGHT_CLICK_DRAG = "right_click_drag" + const val ZOOM_SCROLL_WHEEL = "scroll_wheel" + const val ZOOM_PLUS_MINUS = "plus_minus" + const val ZOOM_PAGE_UP_DOWN = "page_up_down" + private const val KEY_TAP_ENABLED = "tapEnabled" + private const val KEY_TAP_ACTION = "tapAction" + private const val KEY_DRAG_ENABLED = "dragEnabled" + private const val KEY_DRAG_ACTION = "dragAction" + private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled" + private const val KEY_LONG_PRESS_ACTION = "longPressAction" + private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior" + private const val KEY_LONG_PRESS_DELAY = "longPressDelay" + private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled" + private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay" + private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled" + private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction" + private const val KEY_PINCH_ENABLED = "pinchEnabled" + private const val KEY_PINCH_ACTION = "pinchAction" + private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled" + private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction" + private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled" + private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction" + private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior" + private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay" + private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled" + private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction" + private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled" + private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction" + private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled" + private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction" + private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior" + private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay" + private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay" + private const val KEY_GESTURE_THRESHOLD = "gestureThreshold" + private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode" + + private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig( + twoFingerHoldEnabled = false, + threeFingerTapEnabled = false, + threeFingerHoldEnabled = false, + ) + + fun fromJson(json: String?): TouchGestureConfig { + if (json.isNullOrBlank()) return fallbackDefaults() + return try { + val obj = JSONObject(json) + TouchGestureConfig( + tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true), + tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK), + dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true), + dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG), + longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false), + longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK), + longPressMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS), + doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true), + doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS), + twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true), + twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS), + pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true), + pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL), + twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true), + twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK), + twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false), + twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK), + twoFingerHoldMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS), + threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false), + threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD), + threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false), + threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS), + threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false), + threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC), + threeFingerHoldMouseBehavior = normalizeMouseBehavior( + obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD) + ), + threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS), + showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false), + gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD), + mouseDragMovementMode = normalizeMouseDragMovementMode( + obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT), + ), + ) + } catch (_: Exception) { + fallbackDefaults() + } + } + + private fun normalizeMouseBehavior(value: String): String { + return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD + } + + private fun normalizeMouseDragMovementMode(mode: String?): String { + return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) { + MOUSE_DRAG_MOVEMENT_RELATIVE + } else { + MOUSE_DRAG_MOVEMENT_DIRECT + } + } + + val PAN_ACTIONS = listOf( + PAN_ARROW_KEYS, + PAN_INVERTED_ARROW_KEYS, + PAN_WASD, + PAN_INVERTED_WASD, + PAN_MIDDLE_MOUSE, + PAN_INVERTED_MIDDLE_MOUSE, + PAN_LEFT_CLICK_DRAG, + PAN_RIGHT_CLICK_DRAG, + ) + + val ZOOM_ACTIONS = listOf( + ZOOM_SCROLL_WHEEL, + ZOOM_PLUS_MINUS, + ZOOM_PAGE_UP_DOWN, + ) + + @JvmStatic + fun isMouseButtonAction(action: String?): Boolean = + action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java new file mode 100644 index 000000000..e71a14cd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java @@ -0,0 +1,1332 @@ +/* + * SPDX-License-Identifier: GPL-3.0 + * Modified for DroidDeck on 2026-10-15. + */ +package com.droiddeck.launcher.input; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class TouchscreenGestures { + public enum Gesture { + TAP, DRAG, LONG_PRESS, DOUBLE_TAP, + TWO_FINGER_TAP, TWO_FINGER_DRAG, TWO_FINGER_HOLD, PINCH, + THREE_FINGER_TAP, THREE_FINGER_DRAG, THREE_FINGER_HOLD + } + + public interface Output { + void moveTo(float x, float y); + void moveBy(float dx, float dy, boolean relative); + void button(int code, boolean down); + void key(int code, boolean down); + void wheel(int steps); + void showKeyboard(); + /** Establish the pointer origin before a relative middle-mouse pan begins. */ + default void prepareRelativePan() {} + default void gesture(Gesture gesture) {} + } + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Output output; + private final float doubleTapSlop; + private TouchGestureConfig gestureConfig = new TouchGestureConfig(); + private static final int CLICK_DELAYED_TIME = 50; + private Runnable delayedPress, pendingHoldClickRelease; + private Runnable pendingTapPress; + private final ArrayDeque pendingTapClicks = new ArrayDeque<>(); + private long lastInputReleaseTime; + private String delayedPressAction, pendingHoldClickReleaseAction; + private final Map keysDown = new LinkedHashMap<>(); + private final Map buttonsDown = new LinkedHashMap<>(); + private boolean leftClickDragButtonDown, rightClickDragButtonDown; + + private static final class TapClick { + final String action; + final int x, y; + TapClick(String action, float x, float y) { + this.action = action; this.x = (int) x; this.y = (int) y; + } + } + + public TouchscreenGestures(float doubleTapSlop, Output output) { + this.output = output; + this.doubleTapSlop = Math.max(1f, doubleTapSlop); + } + public void configure(TouchGestureConfig config) { + cancel(); + gestureConfig = config; + } + public boolean onTouch(MotionEvent event) { return handleGestureTouchscreenEvent(event); } + public void cancel() { + handleTsCancel(); + handler.removeCallbacksAndMessages(null); + for (int code : keysDown.keySet()) output.key(code, false); + for (int code : buttonsDown.keySet()) output.button(code, false); + keysDown.clear(); buttonsDown.clear(); lastTapTime = 0; + } + private void postDelayed(Runnable action, long delay) { handler.postDelayed(action, delay); } + private void removeCallbacks(Runnable action) { handler.removeCallbacks(action); } + private void notifyGesture(Gesture gesture) { + if (gestureConfig.getShowGestureDebugOverlay()) output.gesture(gesture); + } + private void pressKey(int code) { press(keysDown, code, true); } + private void releaseKey(int code) { release(keysDown, code, true); } + private void pressButton(MouseButton button) { press(buttonsDown, button.code, false); } + private void releaseButton(MouseButton button) { release(buttonsDown, button.code, false); } + private void press(Map held, int code, boolean key) { + int count = held.getOrDefault(code, 0); + held.put(code, count + 1); + if (count == 0) { if (key) output.key(code, true); else output.button(code, true); } + } + private void release(Map held, int code, boolean key) { + int count = held.getOrDefault(code, 0); + if (count == 0) return; + if (count > 1) { held.put(code, count - 1); return; } + held.remove(code); + if (key) output.key(code, false); else output.button(code, false); + lastInputReleaseTime = SystemClock.uptimeMillis(); + } + private enum MouseButton { + BUTTON_LEFT(0x110), BUTTON_RIGHT(0x111), BUTTON_MIDDLE(0x112); + final int code; + MouseButton(int code) { this.code = code; } + } + // Long-press detection + private Runnable longPressRunnable; + private boolean longPressTriggered; + private boolean longPressActionHeld, twoFingerHoldActionHeld, threeFingerHoldActionHeld; + + // Double-tap detection + private long lastTapTime; + private float lastTapX; + private float lastTapY; + private boolean secondTapCandidate; + private float doubleTapAnchorX, doubleTapAnchorY; + private long singleFingerDownTime; + + // Drag tracking (box selection) + private boolean isDragging; + private boolean dragButtonPressed; + + // Two-finger tracking + private boolean twoFingerDragging; + private float twoFingerLastX0, twoFingerLastY0; + private float twoFingerLastX1, twoFingerLastY1; + private boolean twoFingerMiddleButtonDown; + // Pinch tracking + private float pinchLastDistance; + private float pinchZoomRemainder; + private float keyPanRemainderX, keyPanRemainderY; + private static final float PINCH_STEP_PX = 10f; + // Two-finger tap detection + private boolean twoFingerTapPossible; + private boolean twoFingerTapFired; + // Track which WASD/arrow keys are currently held + private boolean panKeyUp, panKeyDown, panKeyLeft, panKeyRight; + // True when finger has moved beyond tap tolerance (prevents spurious tap on lift) + private boolean movedBeyondTapThreshold; + // True after a multi-finger gesture has been active — prevents the remaining + // finger from starting a spurious single-finger drag on lift sequence. + private boolean multiFingerGestureUsed; + // Remember which keycodes were actually pressed so releasePanKeys releases only those + private Integer panLeftKey, panRightKey, panUpKey, panDownKey; + // Long-press movement tolerance (pixels of raw finger movement before cancelling long-press) + private static final float LONG_PRESS_MOVE_TOLERANCE = 50f; + // Raw touch-down coordinates for measuring finger travel + private float touchDownRawX, touchDownRawY; + // Last raw coordinates for single-finger drag delta + private float singleFingerLastRawX, singleFingerLastRawY; + // Two-finger gesture mutual exclusion (only pan OR pinch, not both) + private static final int TWO_FINGER_GESTURE_NONE = 0; + private static final int TWO_FINGER_GESTURE_PAN = 1; + private static final int TWO_FINGER_GESTURE_ZOOM = 2; + private int twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + // Accumulated deltas for deciding which two-finger gesture to lock into + private float accumulatedPinchDelta, accumulatedPanDelta; + + // Settle window: ignore movement for first N ms after second finger lands (#1) + // Also serves as a grace period for a third finger to arrive before 2F locks in + private static final long TWO_FINGER_SETTLE_MS = 120; + private static final long THREE_FINGER_SETTLE_MS = 50; + private long twoFingerDownTime; + private long threeFingerDownTime; + + // Velocity gating for two-finger hold vs drag (#2) + private long twoFingerLastMoveTime; + private static final float MIN_DRAG_VELOCITY = 0.3f; // px/ms to commit to drag + + // Pinch vs drag ratio: pinch delta must exceed pan by this factor to classify as pinch (#4) + private static final float PINCH_RATIO_THRESHOLD = 1.5f; + + // Cancel-and-reclassify: allow reclassification within this window after lock-in (#5) + private static final long RECLASSIFY_WINDOW_MS = 100; + private long twoFingerLockTime; + private float reclassifyPinchDelta, reclassifyPanDelta; + // Gesture pointer ownership tracking (prevents d-pad/button pointer conflicts) + private int gestureFingerCount = 0; + private final ArrayList gestureOwnedPointerIds = new ArrayList<>(); + private final int[] multiFingerStartIds = new int[3]; + private final float[] multiFingerStartX = new float[3], multiFingerStartY = new float[3]; + private int multiFingerStartCount; + + // Two-finger hold detection + private Runnable twoFingerHoldRunnable; + private boolean twoFingerHoldTriggered; + + // Three-finger tracking + private boolean threeFingerTapPossible; + private boolean threeFingerTapFired; + private boolean threeFingerDragging; + private float threeFingerLastMidX, threeFingerLastMidY; + private float threeFingerDragOffsetX, threeFingerDragOffsetY; + private Runnable threeFingerHoldRunnable; + private boolean threeFingerHoldTriggered; + private static final int THREE_FINGER_GESTURE_NONE = 0; + private static final int THREE_FINGER_GESTURE_PAN = 1; + private int threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + private float accumulatedThreeFingerDelta; + + private boolean handleGestureTouchscreenEvent(MotionEvent event) { + int action = event.getActionMasked(); + int actionIndex = event.getActionIndex(); + int pointerId = event.getPointerId(actionIndex); + + switch (action) { + case MotionEvent.ACTION_DOWN: + // A new touch must not shorten or discard an already recognized click. + handleTsCancel(true); + // First pointer overall — gesture system claims it + gestureOwnedPointerIds.clear(); + gestureOwnedPointerIds.add(pointerId); + gestureFingerCount = 1; + handleTsDown(event, actionIndex); + break; + + case MotionEvent.ACTION_POINTER_DOWN: + // New pointer arrived — gesture system claims it + if (!gestureOwnedPointerIds.contains(pointerId)) { + gestureOwnedPointerIds.add(pointerId); + gestureFingerCount++; + } + if (gestureFingerCount == 1) { + // First gesture finger (controls own earlier pointers) + handleTsDown(event, actionIndex); + } else if (gestureFingerCount == 2) { + handleTsPointerDown(event); + } else if (gestureFingerCount == 3) { + handleTsThreeFingerDown(event); + } + break; + + case MotionEvent.ACTION_MOVE: + if (gestureFingerCount >= 3) { + handleTsThreeFingerMove(event); + } else if (gestureFingerCount >= 2) { + handleTsTwoFingerMove(event); + } else if (gestureFingerCount == 1 && !gestureOwnedPointerIds.isEmpty()) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + if (idx >= 0) handleTsMove(event, idx); + } + break; + + case MotionEvent.ACTION_POINTER_UP: + if (gestureOwnedPointerIds.contains(pointerId)) { + int prevCount = gestureFingerCount; + int removedIndex = gestureOwnedPointerIds.indexOf(pointerId); + gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); + gestureFingerCount = Math.max(0, gestureFingerCount - 1); + if (prevCount == 3 && gestureFingerCount == 2) { + // Three-finger gesture ending + handleTsThreeFingerUp(event); + } else if (prevCount == 2 && gestureFingerCount == 1) { + // Two-finger gesture ending (2→1) + handleTsPointerUp(event); + } else if (prevCount >= 4) { + if (removedIndex < 3) rebaseThreeFingerMovement(event); + } else if (prevCount >= 1 && gestureFingerCount == 0) { + // Last gesture finger lifted while controls still hold pointers + handleTsUp(event, actionIndex); + } + } + break; + + case MotionEvent.ACTION_UP: + if (gestureOwnedPointerIds.contains(pointerId)) { + int prevCount = gestureFingerCount; + gestureOwnedPointerIds.remove(Integer.valueOf(pointerId)); + gestureFingerCount = Math.max(0, gestureFingerCount - 1); + // Clean up multi-finger state if needed, routing by previous + // gesture mode so a 3-finger gesture isn't ended via the + // two-finger cleanup path. + if (prevCount >= 3) { + handleTsThreeFingerUp(event); + } else if (prevCount == 2) { + handleTsPointerUp(event); + } + handleTsUp(event, actionIndex); + } + // ACTION_UP = last pointer overall — full cleanup + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + break; + + case MotionEvent.ACTION_CANCEL: + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + cancel(); + break; + } + return true; + } + + private void handleTsDown(MotionEvent event, int actionIndex) { + flushPendingHoldClickRelease(); + + int x = (int) event.getX(actionIndex); + int y = (int) event.getY(actionIndex); + + longPressTriggered = longPressActionHeld = false; + secondTapCandidate = false; + isDragging = false; + dragButtonPressed = false; + movedBeyondTapThreshold = false; + multiFingerGestureUsed = false; + twoFingerDragging = false; + twoFingerTapPossible = false; + twoFingerTapFired = false; + twoFingerMiddleButtonDown = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + threeFingerTapPossible = false; + threeFingerDragging = false; + threeFingerTapFired = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + threeFingerHoldTriggered = false; + accumulatedThreeFingerDelta = 0; + + // Store raw touch coordinates for long-press movement tolerance + touchDownRawX = event.getX(actionIndex); + touchDownRawY = event.getY(actionIndex); + singleFingerDownTime = SystemClock.uptimeMillis(); + singleFingerLastRawX = touchDownRawX; + singleFingerLastRawY = touchDownRawY; + + // Keep a nearby re-tap undecided until UP: it can still become a drag, + // long press, or multi-finger gesture instead of a double click. + if (isDoubleTap(event, actionIndex)) { + secondTapCandidate = true; + doubleTapAnchorX = lastTapX; + doubleTapAnchorY = lastTapY; + lastTapTime = 0; + } + + if (!secondTapCandidate && delayedPress == null && pendingTapPress == null) moveCursorTo(x, y); + + // Schedule long-press + if (gestureConfig.getLongPressEnabled()) { + longPressRunnable = () -> { + secondTapCandidate = false; + lastTapTime = 0; + longPressTriggered = true; + moveCursorTo((int) touchDownRawX, (int) touchDownRawY); + longPressActionHeld = injectHoldAction( + gestureConfig.getLongPressAction(), + gestureConfig.getLongPressMouseBehavior()); + notifyGesture(Gesture.LONG_PRESS); + }; + postDelayed(longPressRunnable, gestureConfig.getLongPressDelay()); + } + + // Tap: immediate cursor move (press is deferred to UP for click, or MOVE for drag) + } + + private void handleTsPointerDown(MotionEvent event) { + if (multiFingerGestureUsed) return; + secondTapCandidate = false; + flushPendingTapRelease(); + lastTapTime = 0; + flushPendingHoldClickRelease(); + // Cancel any single-finger long-press. cancelLongPressTimer only + // clears the pending runnable; if the long-press has already fired + // we must also release the held action before we transition into a + // multi-finger gesture, otherwise the long-press button/key stays + // down for the rest of the touch sequence. + if (longPressTriggered) { + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + } + cancelLongPressTimer(); + cancelDrag(); + + if (gestureFingerCount == 2 && gestureOwnedPointerIds.size() >= 2) { + twoFingerTapPossible = true; + twoFingerDragging = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + twoFingerHoldTriggered = twoFingerHoldActionHeld = false; + accumulatedPinchDelta = 0; + accumulatedPanDelta = 0; + resetSmallMovement(); + twoFingerDownTime = SystemClock.uptimeMillis(); + twoFingerLastMoveTime = twoFingerDownTime; + twoFingerLockTime = 0; + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + int idx0 = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1)); + if (idx0 >= 0 && idx1 >= 0) { + twoFingerLastX0 = event.getX(idx0); + twoFingerLastY0 = event.getY(idx0); + twoFingerLastX1 = event.getX(idx1); + twoFingerLastY1 = event.getY(idx1); + pinchLastDistance = (float) Math.hypot( + event.getX(idx0) - event.getX(idx1), + event.getY(idx0) - event.getY(idx1)); + recordMultiFingerOrigins(event, 2); + } + // Schedule two-finger hold + if (gestureConfig.getTwoFingerHoldEnabled()) { + twoFingerHoldRunnable = () -> { + twoFingerHoldTriggered = true; + twoFingerTapPossible = false; + twoFingerHoldActionHeld = injectHoldAction( + gestureConfig.getTwoFingerHoldAction(), + gestureConfig.getTwoFingerHoldMouseBehavior()); + notifyGesture(Gesture.TWO_FINGER_HOLD); + }; + postDelayed(twoFingerHoldRunnable, gestureConfig.getTwoFingerHoldDelay()); + } + } + } + + private void handleTsMove(MotionEvent event, int pointerIndex) { + // After a multi-finger gesture, ignore single-finger movement until all fingers lift + if (multiFingerGestureUsed) return; + + int x = (int) event.getX(pointerIndex); + int y = (int) event.getY(pointerIndex); + + // If long press already triggered, ignore movement (right-click already sent) + if (longPressTriggered) return; + + // Measure raw finger travel from initial touch point + float rawTravel = maxFingerTravel(event, pointerIndex, touchDownRawX, touchDownRawY); + if (secondTapCandidate && (rawTravel > doubleTapSlop + || maxFingerTravel(event, pointerIndex, doubleTapAnchorX, doubleTapAnchorY) >= doubleTapSlop)) { + secondTapCandidate = false; + } + + // The threshold decides when a drag starts. An active drag must keep + // following the finger even when it returns through the touch origin. + if (!isDragging && rawTravel <= LONG_PRESS_MOVE_TOLERANCE) { + return; + } + + // Beyond tolerance — cancel long press + cancelLongPressTimer(); + movedBeyondTapThreshold = true; + + // Detect drag start + if (!isDragging && gestureConfig.getDragEnabled()) { + flushPendingTapRelease(); + String dragAction = gestureConfig.getDragAction(); + isDragging = true; + dragButtonPressed = true; + singleFingerLastRawX = event.getX(pointerIndex); + singleFingerLastRawY = event.getY(pointerIndex); + // Send the configured drag press immediately so that a short + // move followed straight by UP still produces a press+release + // pair. A zero-delta performPanAction just presses the button + // (or is a no-op for the key-pan variants, which press per + // direction on their next move frame). + if (isClickDragAction(dragAction)) { + // The candidate's cursor stayed at the previous click. Start + // a drag at this touch's own origin before pressing its button. + moveCursorTo((int) touchDownRawX, (int) touchDownRawY); + if (useRelativeMouseDragMovement()) { + pressClickDragAction(dragAction); + } else { + performClickDragAt(event.getX(pointerIndex), event.getY(pointerIndex), dragAction); + } + } else { + performPanAction(0f, 0f, dragAction); + } + notifyGesture(Gesture.DRAG); + // Non-click pan actions continue with real deltas on the next frame. + return; + } + + if (isDragging) { + String dragAction = gestureConfig.getDragAction(); + if (isClickDragAction(dragAction) && !useRelativeMouseDragMovement()) { + moveCursorTo(x, y); + return; + } + + float rawX = event.getX(pointerIndex); + float rawY = event.getY(pointerIndex); + float dx = rawX - singleFingerLastRawX; + float dy = rawY - singleFingerLastRawY; + singleFingerLastRawX = rawX; + singleFingerLastRawY = rawY; + if (isClickDragAction(dragAction)) { + performClickDragWithRelativeMovement(dx, dy, dragAction); + } else { + performPanAction(dx, dy, dragAction); + } + return; + } + + // Move cursor + moveCursorTo(x, y); + } + + private void handleTsTwoFingerMove(MotionEvent event) { + if (multiFingerGestureUsed) return; + // If a two-finger hold has already fired, ignore subsequent jitter so + // it can't lock into a pan/zoom gesture mid-hold. + if (twoFingerHoldTriggered) return; + if (gestureFingerCount < 2 || gestureOwnedPointerIds.size() < 2) return; + + int idx0 = event.findPointerIndex(gestureOwnedPointerIds.get(0)); + int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1)); + if (idx0 < 0 || idx1 < 0) return; + + // Settling delays recognition, not movement tracking. A swipe or pinch + // must stop being a tap/hold even when it finishes inside that delay. + if (multiFingerMovementExceeded(event)) { + twoFingerTapPossible = false; + cancelTwoFingerHoldTimer(); + } + // Slow deliberate travel can cross touch slop before reaching the + // pan/zoom threshold. It must not turn into a stationary hold first. + if (multiFingerMovementExceeded(event, true)) cancelTwoFingerHoldTimer(); + + float x0 = event.getX(idx0), y0 = event.getY(idx0); + float x1 = event.getX(idx1), y1 = event.getY(idx1); + + // Compute per-frame pan and pinch deltas + float currDist = (float) Math.hypot(x0 - x1, y0 - y1); + float framePinchDelta = Math.abs(currDist - pinchLastDistance); + + float midX = (x0 + x1) / 2f; + float midY = (y0 + y1) / 2f; + float lastMidX = (twoFingerLastX0 + twoFingerLastX1) / 2f; + float lastMidY = (twoFingerLastY0 + twoFingerLastY1) / 2f; + float dx = midX - lastMidX; + float dy = midY - lastMidY; + float framePanDelta = (float) Math.hypot(dx, dy); + + long now = SystemClock.uptimeMillis(); + if (now - twoFingerDownTime < TWO_FINGER_SETTLE_MS) { + // Still settling — update stored positions but don't accumulate deltas + twoFingerLastX0 = x0; twoFingerLastY0 = y0; + twoFingerLastX1 = x1; twoFingerLastY1 = y1; + pinchLastDistance = currDist; + return; + } + long frameTime = now - twoFingerLastMoveTime; + twoFingerLastMoveTime = now; + float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0; // px/ms + + // Accumulate deltas until we have enough movement to decide gesture type + if (twoFingerGestureMode == TWO_FINGER_GESTURE_NONE) { + accumulatedPinchDelta += framePinchDelta; + accumulatedPanDelta += framePanDelta; + if (accumulatedPinchDelta + accumulatedPanDelta > (float) gestureConfig.getGestureThreshold()) { + // Enough movement to lock into a gesture — no longer a tap or hold + twoFingerTapPossible = false; + cancelTwoFingerHoldTimer(); + if (accumulatedPinchDelta > accumulatedPanDelta * PINCH_RATIO_THRESHOLD + && gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + twoFingerLockTime = now; + notifyGesture(Gesture.PINCH); + } else if (velocity >= MIN_DRAG_VELOCITY && gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } else if (accumulatedPanDelta >= accumulatedPinchDelta && gestureConfig.getTwoFingerDragEnabled()) { + // Fallback: pan is dominant but velocity was too low — still drag + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } else if (gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + twoFingerLockTime = now; + notifyGesture(Gesture.PINCH); + } else if (gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + twoFingerLockTime = now; + notifyGesture(Gesture.TWO_FINGER_DRAG); + } + } + } + if (twoFingerGestureMode != TWO_FINGER_GESTURE_NONE && twoFingerLockTime > 0 + && (now - twoFingerLockTime) < RECLASSIFY_WINDOW_MS) { + reclassifyPinchDelta += framePinchDelta; + reclassifyPanDelta += framePanDelta; + float reclassifyTotal = reclassifyPinchDelta + reclassifyPanDelta; + if (reclassifyTotal > 20f) { // enough post-lock data to reclassify + boolean shouldBeZoom = reclassifyPinchDelta > reclassifyPanDelta * PINCH_RATIO_THRESHOLD; + if (shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_PAN + && gestureConfig.getPinchEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_ZOOM; + releaseDragInput(); + resetSmallMovement(); + notifyGesture(Gesture.PINCH); + } else if (!shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_ZOOM + && gestureConfig.getTwoFingerDragEnabled()) { + twoFingerGestureMode = TWO_FINGER_GESTURE_PAN; + resetSmallMovement(); + notifyGesture(Gesture.TWO_FINGER_DRAG); + } + twoFingerLockTime = 0; // done reclassifying + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + } + } + if (twoFingerGestureMode == TWO_FINGER_GESTURE_ZOOM) { + float delta = currDist - pinchLastDistance; + pinchZoomRemainder = accumulateSignedMovement(pinchZoomRemainder, delta); + if (Math.abs(pinchZoomRemainder) >= PINCH_STEP_PX) { + performZoomAction(pinchZoomRemainder > 0); + // Keep the fractional travel, without queuing a burst of old + // zoom actions after a large move or a direction reversal. + pinchZoomRemainder %= PINCH_STEP_PX; + } + } + pinchLastDistance = currDist; // Always update baseline to avoid stale deltas + if (twoFingerGestureMode == TWO_FINGER_GESTURE_PAN) { + String twoFingerDragAction = gestureConfig.getTwoFingerDragAction(); + if (isClickDragAction(twoFingerDragAction)) { + if (useRelativeMouseDragMovement()) { + twoFingerDragging = true; + performClickDragWithRelativeMovement(dx, dy, twoFingerDragAction); + } else { + if (!twoFingerDragging) moveCursorToMultiFingerOrigin(); + twoFingerDragging = true; + performClickDragAt(midX, midY, twoFingerDragAction); + } + } else if (dx != 0f || dy != 0f) { + twoFingerDragging = true; + performPanAction(dx, dy, twoFingerDragAction); + } + } + + twoFingerLastX0 = x0; + twoFingerLastY0 = y0; + twoFingerLastX1 = x1; + twoFingerLastY1 = y1; + } + + private void handleTsPointerUp(MotionEvent event) { + cancelTwoFingerHoldTimer(); + if (multiFingerMovementExceeded(event)) twoFingerTapPossible = false; + // Two-finger hold release + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + // Two-finger tap detection + else if (twoFingerTapPossible && !twoFingerDragging && gestureConfig.getTwoFingerTapEnabled()) { + // Move cursor to midpoint between the two fingers + float midX = (twoFingerLastX0 + twoFingerLastX1) / 2f; + float midY = (twoFingerLastY0 + twoFingerLastY1) / 2f; + queueTapClick(gestureConfig.getTwoFingerTapAction(), midX, midY); + notifyGesture(Gesture.TWO_FINGER_TAP); + twoFingerTapFired = true; + } + releaseDragInput(); + twoFingerDragging = false; + twoFingerTapPossible = false; + multiFingerStartCount = 0; + resetSmallMovement(); + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + accumulatedPinchDelta = 0; + accumulatedPanDelta = 0; + reclassifyPinchDelta = 0; + reclassifyPanDelta = 0; + twoFingerLockTime = 0; + // Suppress single-finger tap/drag when last finger lifts after any 2F gesture + movedBeyondTapThreshold = true; + multiFingerGestureUsed = true; + } + + private void handleTsUp(MotionEvent event, int actionIndex) { + cancelLongPressTimer(); + + if (longPressTriggered) { + // Release the long-press action + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + return; + } + + float x = event.getX(actionIndex); + float y = event.getY(actionIndex); + + if (isDragging && dragButtonPressed) { + // End drag (release any held drag buttons/keys) + releaseDragInput(); + dragButtonPressed = false; + isDragging = false; + lastTapTime = 0; + return; + } + + if (secondTapCandidate) { + secondTapCandidate = false; + long tapLimit = gestureConfig.getLongPressEnabled() + ? gestureConfig.getLongPressDelay() : ViewConfiguration.getLongPressTimeout(); + if (SystemClock.uptimeMillis() - singleFingerDownTime < tapLimit + && Math.hypot(x - doubleTapAnchorX, y - doubleTapAnchorY) < doubleTapSlop) { + // A normal left tap already supplied the first click. Other + // single-tap mappings need a complete synthesized double click. + boolean firstTapClicked = gestureConfig.getTapEnabled() + && TouchGestureConfig.ACTION_LEFT_CLICK.equals(gestureConfig.getTapAction()); + int clicks = firstTapClicked ? 1 : 2; + for (int i = 0; i < clicks; i++) queueTapClick(TouchGestureConfig.ACTION_LEFT_CLICK, doubleTapAnchorX, doubleTapAnchorY); + notifyGesture(Gesture.DOUBLE_TAP); + lastTapTime = 0; + return; + } + } + + // Two-finger tap already sent its click on POINTER_UP — skip the tap on UP + if (twoFingerTapFired) { + twoFingerTapFired = false; + return; + } + + // Three-finger tap already sent its click — skip the tap on UP + if (threeFingerTapFired) { + threeFingerTapFired = false; + return; + } + + // Simple tap — only if finger stayed within tap tolerance + if (gestureConfig.getTapEnabled() && !movedBeyondTapThreshold) { + queueTapClick(gestureConfig.getTapAction(), x, y); + notifyGesture(Gesture.TAP); + } + + // Record for double-tap detection (even if tap itself is disabled, + // so double-tap can still prime from single touch-ups) + if (!movedBeyondTapThreshold && (gestureConfig.getTapEnabled() || gestureConfig.getDoubleTapEnabled())) { + lastTapTime = SystemClock.uptimeMillis(); + lastTapX = x; + lastTapY = y; + } + movedBeyondTapThreshold = false; + } + + private void handleTsCancel() { + handleTsCancel(false); + } + + private void handleTsCancel(boolean preserveTapRelease) { + cancelLongPressTimer(); + cancelTwoFingerHoldTimer(); + cancelThreeFingerHoldTimer(); + flushPendingHoldClickRelease(); + if (!preserveTapRelease) flushPendingTapRelease(); + if (dragButtonPressed) { + releaseDragInput(); + dragButtonPressed = false; + } + if (longPressTriggered) { + longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction()); + longPressTriggered = false; + } + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + if (threeFingerHoldTriggered) { + threeFingerHoldActionHeld = releaseHoldAction(threeFingerHoldActionHeld, gestureConfig.getThreeFingerHoldAction()); + threeFingerHoldTriggered = false; + } + releaseDragInput(); + isDragging = false; + twoFingerDragging = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + threeFingerDragging = false; + threeFingerTapPossible = false; + threeFingerTapFired = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + longPressTriggered = false; + longPressActionHeld = twoFingerHoldActionHeld = threeFingerHoldActionHeld = false; + multiFingerGestureUsed = false; + movedBeyondTapThreshold = false; + secondTapCandidate = false; + gestureOwnedPointerIds.clear(); + gestureFingerCount = 0; + multiFingerStartCount = 0; + resetSmallMovement(); + } + + private void handleTsThreeFingerDown(MotionEvent event) { + if (multiFingerGestureUsed) return; + flushPendingHoldClickRelease(); + cancelLongPressTimer(); + cancelDrag(); + cancelTwoFingerHoldTimer(); + // If a two-finger hold already injected its action, release it before + // transitioning so the held button/key doesn't stay down. + if (twoFingerHoldTriggered) { + twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction()); + twoFingerHoldTriggered = false; + } + // Clean up two-finger state + releaseDragInput(); + resetSmallMovement(); + twoFingerDragging = false; + twoFingerTapPossible = false; + twoFingerGestureMode = TWO_FINGER_GESTURE_NONE; + + threeFingerTapPossible = true; + threeFingerDragging = false; + threeFingerHoldTriggered = threeFingerHoldActionHeld = false; + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + accumulatedThreeFingerDelta = 0; + threeFingerDragOffsetX = threeFingerDragOffsetY = 0f; + threeFingerDownTime = SystemClock.uptimeMillis(); + + // Compute midpoint of three gesture fingers + if (gestureOwnedPointerIds.size() >= 3) { + float sumX = 0, sumY = 0; + int validCount = 0; + for (int i = 0; i < 3; i++) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (idx >= 0) { + sumX += event.getX(idx); + sumY += event.getY(idx); + validCount++; + } + } + if (validCount < 3) return; // Can't proceed without all 3 pointers + threeFingerLastMidX = sumX / 3f; + threeFingerLastMidY = sumY / 3f; + recordMultiFingerOrigins(event, 3); + } + + // Schedule three-finger hold + if (gestureConfig.getThreeFingerHoldEnabled()) { + threeFingerHoldRunnable = () -> { + threeFingerHoldTriggered = true; + threeFingerTapPossible = false; + threeFingerHoldActionHeld = injectHoldAction( + gestureConfig.getThreeFingerHoldAction(), + gestureConfig.getThreeFingerHoldMouseBehavior()); + notifyGesture(Gesture.THREE_FINGER_HOLD); + }; + postDelayed(threeFingerHoldRunnable, gestureConfig.getThreeFingerHoldDelay()); + } + } + + private void handleTsThreeFingerMove(MotionEvent event) { + if (gestureFingerCount < 3 || gestureOwnedPointerIds.size() < 3) return; + // If a three-finger hold has already fired, ignore subsequent jitter so + // it can't lock into a pan gesture mid-hold. + if (threeFingerHoldTriggered) return; + if (multiFingerMovementExceeded(event)) { + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + } + if (multiFingerMovementExceeded(event, true)) cancelThreeFingerHoldTimer(); + + float sumX = 0, sumY = 0; + int validCount = 0; + for (int i = 0; i < 3; i++) { + int idx = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (idx >= 0) { + sumX += event.getX(idx); + sumY += event.getY(idx); + validCount++; + } + } + if (validCount < 3) return; + + float midX = sumX / 3f; + float midY = sumY / 3f; + float dx = midX - threeFingerLastMidX; + float dy = midY - threeFingerLastMidY; + float frameDelta = (float) Math.hypot(dx, dy); + long now = SystemClock.uptimeMillis(); + if (now - threeFingerDownTime < THREE_FINGER_SETTLE_MS) { + threeFingerLastMidX = midX; + threeFingerLastMidY = midY; + return; + } + + if (threeFingerGestureMode == THREE_FINGER_GESTURE_NONE) { + accumulatedThreeFingerDelta += frameDelta; + if (accumulatedThreeFingerDelta > (float) gestureConfig.getGestureThreshold()) { + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + if (gestureConfig.getThreeFingerDragEnabled()) { + threeFingerGestureMode = THREE_FINGER_GESTURE_PAN; + notifyGesture(Gesture.THREE_FINGER_DRAG); + } + } + } + + if (threeFingerGestureMode == THREE_FINGER_GESTURE_PAN) { + // Once recognized, retain small mouse movements. Keyboard panning + // accumulates its own direction threshold in performKeyPan. + String threeFingerDragAction = gestureConfig.getThreeFingerDragAction(); + if (isClickDragAction(threeFingerDragAction)) { + if (useRelativeMouseDragMovement()) { + threeFingerDragging = true; + performClickDragWithRelativeMovement(dx, dy, threeFingerDragAction); + } else { + if (!threeFingerDragging) moveCursorToMultiFingerOrigin(); + threeFingerDragging = true; + performClickDragAt(midX + threeFingerDragOffsetX, midY + threeFingerDragOffsetY, threeFingerDragAction); + } + } else if (dx != 0f || dy != 0f) { + threeFingerDragging = true; + performPanAction(dx, dy, threeFingerDragAction); + } + } + + threeFingerLastMidX = midX; + threeFingerLastMidY = midY; + } + + private void handleTsThreeFingerUp(MotionEvent event) { + cancelThreeFingerHoldTimer(); + if (multiFingerMovementExceeded(event)) threeFingerTapPossible = false; + + if (threeFingerHoldTriggered) { + threeFingerHoldActionHeld = releaseHoldAction(threeFingerHoldActionHeld, gestureConfig.getThreeFingerHoldAction()); + threeFingerHoldTriggered = false; + } else if (threeFingerTapPossible && !threeFingerDragging + && gestureConfig.getThreeFingerTapEnabled()) { + queueTapClick(gestureConfig.getThreeFingerTapAction(), threeFingerLastMidX, threeFingerLastMidY); + notifyGesture(Gesture.THREE_FINGER_TAP); + threeFingerTapFired = true; + } + + releaseDragInput(); + threeFingerDragging = false; + threeFingerTapPossible = false; + multiFingerStartCount = 0; + resetSmallMovement(); + threeFingerGestureMode = THREE_FINGER_GESTURE_NONE; + // Suppress single-finger tap/drag when last finger lifts after any 3F gesture + movedBeyondTapThreshold = true; + multiFingerGestureUsed = true; + } + + private void resetSmallMovement() { + pinchZoomRemainder = keyPanRemainderX = keyPanRemainderY = 0f; + } + + private static float accumulateSignedMovement(float remainder, float delta) { + if (delta != 0f && Math.signum(delta) != Math.signum(remainder)) remainder = 0f; + return remainder + delta; + } + + private void recordMultiFingerOrigins(MotionEvent event, int count) { + multiFingerStartCount = 0; + for (int i = 0; i < count; i++) { + int id = gestureOwnedPointerIds.get(i); + int index = event.findPointerIndex(id); + if (index < 0) return; + multiFingerStartIds[i] = id; + multiFingerStartX[i] = event.getX(index); + multiFingerStartY[i] = event.getY(index); + } + multiFingerStartCount = count; + } + + private void moveCursorToMultiFingerOrigin() { + if (multiFingerStartCount == 0) return; + float x = 0, y = 0; + for (int i = 0; i < multiFingerStartCount; i++) { + x += multiFingerStartX[i]; + y += multiFingerStartY[i]; + } + moveCursorTo((int) (x / multiFingerStartCount), (int) (y / multiFingerStartCount)); + } + + private void rebaseThreeFingerMovement(MotionEvent event) { + float x = 0, y = 0; + for (int i = 0; i < 3; i++) { + int index = event.findPointerIndex(gestureOwnedPointerIds.get(i)); + if (index < 0) return; + x += event.getX(index); + y += event.getY(index); + } + x /= 3f; + y /= 3f; + // Replacing a tracked finger changes the centroid without moving the cursor. + if (threeFingerDragging) { + threeFingerDragOffsetX += threeFingerLastMidX - x; + threeFingerDragOffsetY += threeFingerLastMidY - y; + } + threeFingerLastMidX = x; + threeFingerLastMidY = y; + accumulatedThreeFingerDelta = 0; + threeFingerTapPossible = false; + cancelThreeFingerHoldTimer(); + recordMultiFingerOrigins(event, 3); + resetSmallMovement(); + } + + private float maxFingerTravel(MotionEvent event, int index, float originX, float originY) { + float distance = (float) Math.hypot(event.getX(index) - originX, event.getY(index) - originY); + for (int h = 0; h < event.getHistorySize(); h++) { + distance = Math.max(distance, (float) Math.hypot( + event.getHistoricalX(index, h) - originX, event.getHistoricalY(index, h) - originY)); + } + return distance; + } + + private boolean multiFingerMovementExceeded(MotionEvent event) { + return multiFingerMovementExceeded(event, false); + } + + private boolean multiFingerMovementExceeded(MotionEvent event, boolean forHold) { + float tolerance = forHold ? doubleTapSlop : gestureConfig.getGestureThreshold(); + for (int i = 0; i < multiFingerStartCount; i++) { + int index = event.findPointerIndex(multiFingerStartIds[i]); + if (index < 0) continue; + if (maxFingerTravel(event, index, multiFingerStartX[i], multiFingerStartY[i]) > tolerance) return true; + for (int j = 0; !forHold && j < i; j++) { + int other = event.findPointerIndex(multiFingerStartIds[j]); + if (other < 0) continue; + double initialDistance = Math.hypot(multiFingerStartX[i] - multiFingerStartX[j], multiFingerStartY[i] - multiFingerStartY[j]); + double distance = Math.hypot(event.getX(index) - event.getX(other), event.getY(index) - event.getY(other)); + if (Math.abs(distance - initialDistance) > tolerance) return true; + for (int h = 0; h < event.getHistorySize(); h++) { + distance = Math.hypot(event.getHistoricalX(index, h) - event.getHistoricalX(other, h), + event.getHistoricalY(index, h) - event.getHistoricalY(other, h)); + if (Math.abs(distance - initialDistance) > tolerance) return true; + } + } + } + return false; + } + + private boolean isDoubleTap(MotionEvent event, int index) { + return gestureConfig.getDoubleTapEnabled() && lastTapTime > 0 + && SystemClock.uptimeMillis() - lastTapTime < gestureConfig.getDoubleTapDelay() + && Math.hypot(event.getX(index) - lastTapX, event.getY(index) - lastTapY) + < doubleTapSlop; + } + + private void queueTapClick(String action, float x, float y) { + pendingTapClicks.addLast(new TapClick(action, x, y)); + startPendingTapClick(); + } + + private void startPendingTapClick() { + if (delayedPress != null || pendingTapPress != null || pendingTapClicks.isEmpty()) return; + TapClick click = pendingTapClicks.peekFirst(); + // Games that poll button state need an observable UP interval too. + // Reuse the first tap's location: normal finger drift must not turn + // this into two clicks on different targets or a tiny drag. + long delay = Math.max(0, CLICK_DELAYED_TIME - (SystemClock.uptimeMillis() - lastInputReleaseTime)); + pendingTapPress = () -> { + pendingTapPress = null; + pendingTapClicks.removeFirst(); + moveCursorTo(click.x, click.y); + injectClick(click.action); + delayedPressAction = click.action; + delayedPress = () -> { + injectRelease(click.action); + delayedPress = null; + delayedPressAction = null; + startPendingTapClick(); + }; + postDelayed(delayedPress, CLICK_DELAYED_TIME); + }; + if (delay > 0) postDelayed(pendingTapPress, delay); + else pendingTapPress.run(); + } + + private void flushPendingTapRelease() { + if (pendingTapPress != null) removeCallbacks(pendingTapPress); + pendingTapPress = null; + pendingTapClicks.clear(); + if (delayedPress != null) { + removeCallbacks(delayedPress); + injectRelease(delayedPressAction); + delayedPressAction = null; + delayedPress = null; + } + } + + private void flushPendingHoldClickRelease() { + if (pendingHoldClickRelease != null) { + removeCallbacks(pendingHoldClickRelease); + String action = pendingHoldClickReleaseAction; + pendingHoldClickRelease = null; + pendingHoldClickReleaseAction = null; + injectRelease(action); + } + } + + private void cancelLongPressTimer() { + if (longPressRunnable != null) { + removeCallbacks(longPressRunnable); + longPressRunnable = null; + } + } + + private void cancelDrag() { + if (isDragging && dragButtonPressed) { + releaseDragInput(); + dragButtonPressed = false; + } + isDragging = false; + } + + private void cancelTwoFingerHoldTimer() { + if (twoFingerHoldRunnable != null) { + removeCallbacks(twoFingerHoldRunnable); + twoFingerHoldRunnable = null; + } + } + + private void cancelThreeFingerHoldTimer() { + if (threeFingerHoldRunnable != null) { + removeCallbacks(threeFingerHoldRunnable); + threeFingerHoldRunnable = null; + } + } + + private void moveCursorTo(int x, int y) { output.moveTo(x, y); } + + private boolean injectClick(String action) { + if (action == null) return false; + if (TouchGestureConfig.ACTION_SHOW_KEYBOARD.equals(action)) { + output.showKeyboard(); return false; + } + if (TouchGestureConfig.ACTION_KEY_TILDE.equals(action)) { + pressKey(42); pressKey(41); return true; + } + Integer code = actionToKeycode(action); + if (code != null) { pressKey(code); return true; } + MouseButton button = actionToButton(action); + if (button != null) { pressButton(button); return true; } + return false; + } + + private void injectRelease(String action) { + if (action == null) return; + if (TouchGestureConfig.ACTION_KEY_TILDE.equals(action)) { releaseKey(41); releaseKey(42); return; } + Integer code = actionToKeycode(action); + if (code != null) { releaseKey(code); return; } + MouseButton button = actionToButton(action); + if (button != null) releaseButton(button); + } + + private boolean injectHoldAction(String action, String mouseBehavior) { + if (TouchGestureConfig.isMouseButtonAction(action) && TouchGestureConfig.MOUSE_BEHAVIOR_CLICK.equals(mouseBehavior)) { + flushPendingHoldClickRelease(); + if (injectClick(action)) { + pendingHoldClickReleaseAction = action; + pendingHoldClickRelease = () -> { + String pendingAction = pendingHoldClickReleaseAction; + pendingHoldClickRelease = null; + pendingHoldClickReleaseAction = null; + injectRelease(pendingAction); + }; + postDelayed(pendingHoldClickRelease, CLICK_DELAYED_TIME); + } + return false; + } + return injectClick(action); + } + + private boolean releaseHoldAction(boolean held, String action) { + if (held) injectRelease(action); + return false; + } + + private Integer actionToKeycode(String action) { + return action != null && action.startsWith("key_") ? GestureKeys.code(action.substring(4)) : null; + } + + private MouseButton actionToButton(String action) { + if (TouchGestureConfig.ACTION_LEFT_CLICK.equals(action)) return MouseButton.BUTTON_LEFT; + if (TouchGestureConfig.ACTION_RIGHT_CLICK.equals(action)) return MouseButton.BUTTON_RIGHT; + if (TouchGestureConfig.ACTION_MIDDLE_CLICK.equals(action)) return MouseButton.BUTTON_MIDDLE; + return null; + } + private void performZoomAction(boolean zoomIn) { + String action = gestureConfig.getPinchAction(); + if (TouchGestureConfig.ZOOM_SCROLL_WHEEL.equals(action)) { output.wheel(zoomIn ? -1 : 1); return; } + int code = TouchGestureConfig.ZOOM_PLUS_MINUS.equals(action) ? (zoomIn ? 13 : 12) : (zoomIn ? 104 : 109); + pressKey(code); + postDelayed(() -> releaseKey(code), CLICK_DELAYED_TIME); + } + + private void performPanAction(float dx, float dy, String action) { + switch (action) { + case TouchGestureConfig.PAN_MIDDLE_MOUSE: + performMiddleMousePan(dx, dy); + break; + case TouchGestureConfig.PAN_INVERTED_MIDDLE_MOUSE: + performMiddleMousePan(-dx, -dy); + break; + case TouchGestureConfig.PAN_WASD: + performKeyPan(dx, dy, GestureKeys.code("A"), GestureKeys.code("D"), GestureKeys.code("W"), GestureKeys.code("S")); + break; + case TouchGestureConfig.PAN_INVERTED_WASD: + performKeyPan(dx, dy, GestureKeys.code("D"), GestureKeys.code("A"), GestureKeys.code("S"), GestureKeys.code("W")); + break; + case TouchGestureConfig.PAN_ARROW_KEYS: + performKeyPan(dx, dy, GestureKeys.code("LEFT"), GestureKeys.code("RIGHT"), GestureKeys.code("UP"), GestureKeys.code("DOWN")); + break; + case TouchGestureConfig.PAN_INVERTED_ARROW_KEYS: + performKeyPan(dx, dy, GestureKeys.code("RIGHT"), GestureKeys.code("LEFT"), GestureKeys.code("DOWN"), GestureKeys.code("UP")); + break; + case TouchGestureConfig.PAN_LEFT_CLICK_DRAG: + performClickDrag(dx, dy, MouseButton.BUTTON_LEFT); + break; + case TouchGestureConfig.PAN_RIGHT_CLICK_DRAG: + performClickDrag(dx, dy, MouseButton.BUTTON_RIGHT); + break; + } + } + + private boolean isClickDragAction(String action) { + return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(action) + || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action); + } + + private boolean useRelativeMouseDragMovement() { + return TouchGestureConfig.MOUSE_DRAG_MOVEMENT_RELATIVE.equals(gestureConfig.getMouseDragMovementMode()); + } + + private MouseButton buttonForClickDragAction(String action) { + return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action) + ? MouseButton.BUTTON_RIGHT : MouseButton.BUTTON_LEFT; + } + + private void performClickDragAt(float rawX, float rawY, String action) { + pressClickDragAction(action); + moveCursorTo((int) rawX, (int) rawY); + } + + private void performMiddleMousePan(float dx, float dy) { + if (!twoFingerMiddleButtonDown) { + output.prepareRelativePan(); + pressButton(MouseButton.BUTTON_MIDDLE); + twoFingerMiddleButtonDown = true; + } + // Pan from the client's current pointer. Games can recenter the mouse + // when the middle button goes down; replaying our old absolute touch + // anchor after that warp causes a large, unintended camera movement. + moveCursorByRelativeDelta(dx, dy); + } + + private void performClickDrag(float dx, float dy, MouseButton button) { + pressClickDragButton(button); + moveCursorByDelta(dx, dy); + } + + private void performClickDragWithRelativeMovement(float dx, float dy, String action) { + pressClickDragAction(action); + moveCursorByRelativeDelta(dx, dy); + } + + private void pressClickDragAction(String action) { + pressClickDragButton(buttonForClickDragAction(action)); + } + + private void pressClickDragButton(MouseButton button) { + if (button == MouseButton.BUTTON_LEFT && !leftClickDragButtonDown) { + pressButton(MouseButton.BUTTON_LEFT); + leftClickDragButtonDown = true; + } else if (button == MouseButton.BUTTON_RIGHT && !rightClickDragButtonDown) { + pressButton(MouseButton.BUTTON_RIGHT); + rightClickDragButtonDown = true; + } + } + + private void moveCursorByRelativeDelta(float dx, float dy) { output.moveBy(dx, dy, true); } + + private void moveCursorByDelta(float dx, float dy) { output.moveBy(dx, dy, false); } + + private void releaseTwoFingerMiddleButton() { + if (twoFingerMiddleButtonDown) { + releaseButton(MouseButton.BUTTON_MIDDLE); + twoFingerMiddleButtonDown = false; + } + } + + private void releaseClickDragButtons() { + if (leftClickDragButtonDown) { + releaseButton(MouseButton.BUTTON_LEFT); + leftClickDragButtonDown = false; + } + if (rightClickDragButtonDown) { + releaseButton(MouseButton.BUTTON_RIGHT); + rightClickDragButtonDown = false; + } + } + + private void releaseDragInput() { + releasePanKeys(); + releaseAllDragButtons(); + } + + private void releaseAllDragButtons() { + releaseTwoFingerMiddleButton(); + releaseClickDragButtons(); + } + + private void performKeyPan(float dx, float dy, Integer leftKey, Integer rightKey, Integer upKey, Integer downKey) { + // When fingers are actively moving, update key states to match the + // swipe direction. When fingers stop moving but stay on screen, + // keep the current keys held — movement continues until finger-up + // calls releasePanKeys(). + + keyPanRemainderX = accumulateSignedMovement(keyPanRemainderX, dx); + keyPanRemainderY = accumulateSignedMovement(keyPanRemainderY, dy); + float totalDelta = Math.abs(keyPanRemainderX) + Math.abs(keyPanRemainderY); + if (totalDelta < 5f || (Math.abs(keyPanRemainderX) <= 3f && Math.abs(keyPanRemainderY) <= 3f)) return; + dx = keyPanRemainderX; + dy = keyPanRemainderY; + keyPanRemainderX = keyPanRemainderY = 0f; + + // --- Significant movement detected: update each axis --- + + // Horizontal: determine desired state from this frame's delta + boolean wantLeft = dx < -3f; + boolean wantRight = dx > 3f; + + if (panKeyLeft && !wantLeft) { releaseKey(leftKey); panKeyLeft = false; } + if (panKeyRight && !wantRight) { releaseKey(rightKey); panKeyRight = false; } + if (wantLeft && !panKeyLeft) { pressKey(leftKey); panKeyLeft = true; panLeftKey = leftKey; } + if (wantRight && !panKeyRight) { pressKey(rightKey); panKeyRight = true; panRightKey = rightKey; } + + // Vertical: determine desired state from this frame's delta + boolean wantUp = dy < -3f; + boolean wantDown = dy > 3f; + + if (panKeyUp && !wantUp) { releaseKey(upKey); panKeyUp = false; } + if (panKeyDown && !wantDown) { releaseKey(downKey); panKeyDown = false; } + if (wantUp && !panKeyUp) { pressKey(upKey); panKeyUp = true; panUpKey = upKey; } + if (wantDown && !panKeyDown) { pressKey(downKey); panKeyDown = true; panDownKey = downKey; } + } + + private void releasePanKeys() { + keyPanRemainderX = keyPanRemainderY = 0f; + if (panKeyLeft && panLeftKey != null) { releaseKey(panLeftKey); panKeyLeft = false; } + if (panKeyRight && panRightKey != null) { releaseKey(panRightKey); panKeyRight = false; } + if (panKeyUp && panUpKey != null) { releaseKey(panUpKey); panKeyUp = false; } + if (panKeyDown && panDownKey != null) { releaseKey(panDownKey); panKeyDown = false; } + } + +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index f808be94d..3cf155ccb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -168,6 +168,7 @@ object SessionPrefs { const val TOUCH_AUTO = "auto" const val TOUCH_PAD = "touchpad" const val TOUCH_DIRECT = "direct" + const val TOUCH_GESTURES = "gestures" const val TOUCH_OFF = "off" fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt new file mode 100644 index 000000000..56643effe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GestureActions.kt @@ -0,0 +1,128 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R +import java.text.Collator + +internal enum class GestureActionGroup(@StringRes val title: Int) { + MOUSE(R.string.gesture_group_mouse), APP(R.string.gesture_group_app), + NAVIGATION(R.string.gesture_group_navigation), LETTERS(R.string.gesture_group_letters), + NUMBERS(R.string.gesture_group_numbers), FUNCTION(R.string.gesture_group_function), + MODIFIERS(R.string.gesture_group_modifiers), NUMPAD(R.string.gesture_group_numpad), + SYMBOLS(R.string.gesture_group_symbols), +} + +internal data class GestureAction(val id: String, val title: String, val group: GestureActionGroup) + +internal fun gestureActionGroup(id: String): GestureActionGroup { + if (id == "show_keyboard") return GestureActionGroup.APP + if (id in setOf("left_click", "right_click", "middle_click", "left_click_drag", "right_click_drag", + "middle_mouse_pan", "inverted_middle_mouse_pan", "scroll_wheel")) return GestureActionGroup.MOUSE + val key = id.removePrefix("key_") + return when { + key.startsWith("KP_") -> GestureActionGroup.NUMPAD + key.length == 1 && key[0] in 'A'..'Z' -> GestureActionGroup.LETTERS + key.length == 1 && key[0].isDigit() -> GestureActionGroup.NUMBERS + key.matches(Regex("F(?:[1-9]|1[0-2])")) -> GestureActionGroup.FUNCTION + key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_") || key.endsWith("_LOCK") -> GestureActionGroup.MODIFIERS + key in setOf("MINUS", "EQUAL", "BRACKET_LEFT", "BRACKET_RIGHT", "SEMICOLON", "APOSTROPHE", + "GRAVE", "TILDE", "BACKSLASH", "COMMA", "PERIOD", "SLASH") || id == "plus_minus" -> GestureActionGroup.SYMBOLS + else -> GestureActionGroup.NAVIGATION + } +} + +internal fun filterGestureActions(actions: List, query: String, group: GestureActionGroup?): List { + val words = query.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + val collator = Collator.getInstance() + return actions.filter { action -> + (group == null || action.group == group) && words.all { word -> + action.title.contains(word, ignoreCase = true) || action.id.removePrefix("key_").replace('_', ' ').contains(word, ignoreCase = true) + } + }.sortedWith(compareBy { it.group.ordinal }.thenComparator { a, b -> + // Function keys follow their numeric sequence; names use the UI locale. + if (a.group == GestureActionGroup.FUNCTION) { + a.id.removePrefix("key_F").toInt().compareTo(b.id.removePrefix("key_F").toInt()) + } else collator.compare(a.title, b.title) + }.thenBy { it.id }) +} + +@Composable +internal fun gestureActionTitle(action: String): String { + val named = when (action) { + "left_click" -> R.string.gesture_left_click + "right_click" -> R.string.gesture_right_click + "middle_click" -> R.string.gesture_middle_click + "show_keyboard" -> R.string.gesture_keyboard + "left_click_drag" -> R.string.gesture_left_drag + "right_click_drag" -> R.string.gesture_right_drag + "middle_mouse_pan" -> R.string.gesture_middle_pan + "inverted_middle_mouse_pan" -> R.string.gesture_middle_pan_inverted + "arrow_keys" -> R.string.gesture_arrows + "inverted_arrow_keys" -> R.string.gesture_arrows_inverted + "inverted_wasd" -> R.string.gesture_wasd_inverted + "scroll_wheel" -> R.string.gesture_scroll + else -> null + } + if (named != null) return stringResource(named) + return when (action) { + "wasd" -> "WASD" + "plus_minus" -> "+ / −" + "page_up_down" -> stringResource(R.string.gesture_key_page_up) + " / " + stringResource(R.string.gesture_key_page_down) + else -> gestureKeyTitle(action.removePrefix("key_")) + } +} + +@Composable +private fun gestureKeyTitle(key: String): String { + if (key.startsWith("KP_")) return stringResource(R.string.gesture_key_numpad, gestureKeyTitle(key.removePrefix("KP_"))) + val named = when (key) { + "ESC" -> R.string.gesture_key_escape + "BKSP" -> R.string.gesture_key_backspace + "ENTER" -> R.string.gesture_key_enter + "TAB" -> R.string.gesture_key_tab + "SPACE" -> R.string.gesture_key_space + "DEL" -> R.string.gesture_key_delete + "INSERT" -> R.string.gesture_key_insert + "HOME" -> R.string.gesture_key_home + "END" -> R.string.gesture_key_end + "PRIOR" -> R.string.gesture_key_page_up + "NEXT" -> R.string.gesture_key_page_down + "PRTSCN" -> R.string.gesture_key_print_screen + "UP" -> R.string.gesture_key_up + "DOWN" -> R.string.gesture_key_down + "LEFT" -> R.string.gesture_key_left_arrow + "RIGHT" -> R.string.gesture_key_right_arrow + "CAPS_LOCK" -> R.string.gesture_key_caps_lock + "NUM_LOCK" -> R.string.gesture_key_num_lock + "SCROLL_LOCK" -> R.string.gesture_key_scroll_lock + else -> null + } + if (named != null) return stringResource(named) + if (key.startsWith("CTRL_") || key.startsWith("SHIFT_") || key.startsWith("ALT_")) { + val name = stringResource(when (key.substringBefore('_')) { + "CTRL" -> R.string.pckb_key_ctrl + "SHIFT" -> R.string.gesture_key_shift + else -> R.string.pckb_key_alt + }) + return stringResource(if (key.endsWith("_L")) R.string.gesture_key_left else R.string.gesture_key_right, name) + } + return when (key) { + "MINUS", "SUBTRACT" -> "−" + "EQUAL" -> "=" + "ADD" -> "+" + "MULTIPLY" -> "×" + "DIVIDE", "SLASH" -> "/" + "BRACKET_LEFT" -> "[" + "BRACKET_RIGHT" -> "]" + "SEMICOLON" -> ";" + "APOSTROPHE" -> "'" + "GRAVE" -> "`" + "TILDE" -> "~" + "BACKSLASH" -> "\\" + "COMMA" -> "," + "PERIOD" -> "." + else -> key.replace('_', ' ') + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt b/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt new file mode 100644 index 000000000..89233e924 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GestureFeedback.kt @@ -0,0 +1,35 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.TouchscreenGestures.Gesture + +@Composable +fun GestureFeedback(gesture: Gesture?) { + Box(Modifier.fillMaxSize()) { + if (gesture != null) Text(stringResource(when (gesture) { + Gesture.TAP -> R.string.gesture_tap + Gesture.DRAG -> R.string.gesture_drag + Gesture.LONG_PRESS -> R.string.gesture_long_press + Gesture.DOUBLE_TAP -> R.string.gesture_double_tap + Gesture.TWO_FINGER_TAP -> R.string.gesture_two_tap + Gesture.TWO_FINGER_DRAG -> R.string.gesture_two_drag + Gesture.TWO_FINGER_HOLD -> R.string.gesture_two_hold + Gesture.PINCH -> R.string.gesture_pinch + Gesture.THREE_FINGER_TAP -> R.string.gesture_three_tap + Gesture.THREE_FINGER_DRAG -> R.string.gesture_three_drag + Gesture.THREE_FINGER_HOLD -> R.string.gesture_three_hold + }), + Modifier.align(Alignment.TopCenter).padding(top = 24.dp) + .background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurface) + } +} 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 0fb34d663..b0f501cbe 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -250,7 +250,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { ChoiceRow( host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, note = stringResource(R.string.mode_touch_note), onPick = a.onTouch, ) 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 4d4cdbaba..6fa4cba7b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -245,6 +245,7 @@ class DrawerActions( val onLowLatencyController: (Boolean) -> Unit = {}, val onLowLatencyTouch: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, + val onGestureSettings: () -> Unit = {}, val onFexPreset: (String) -> Unit, val onUpscaler: (Int) -> Unit = {}, val onUpscaleSharpness: (Int) -> Unit = {}, @@ -500,8 +501,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType) } ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_GESTURES to stringResource(R.string.mode_touch_gestures), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch) + ActionRow(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_settings_hint), + stringResource(R.string.ctrl_edit), a.onGestureSettings, + modifier = focus.track(page, "gesture-settings")) ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null, if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)) else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 8db2cc793..fb9247333 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -648,8 +648,8 @@ fun MultiRow( } @Composable -fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) { - SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) } +fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null, modifier: Modifier = Modifier) { + SettingsRow(label, hint) { SecondaryButton(button, modifier = modifier, progress = progress, onClick = onClick) } } @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt new file mode 100644 index 000000000..8cd79257d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/TouchGestureDialog.kt @@ -0,0 +1,345 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Mouse +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.GestureKeys +import com.droiddeck.launcher.input.GesturePrefs +import com.droiddeck.launcher.input.TouchGestureConfig +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_LEFT_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_RIGHT_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_MIDDLE_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_SHOW_KEYBOARD +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ACTION_KEY_TILDE +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.PAN_ACTIONS +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.ZOOM_ACTIONS +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_HOLD +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_BEHAVIOR_CLICK +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_DIRECT +import com.droiddeck.launcher.input.TouchGestureConfig.Companion.MOUSE_DRAG_MOVEMENT_RELATIVE + +@Composable +fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit) { + val context = LocalContext.current + // Keep an open editor attached to the game it was opened for. + val targetGame = remember { game } + val initialDefaults = remember { GesturePrefs.read(context) } + val initialGame = remember { targetGame?.takeIf { GesturePrefs.hasOverride(context, it) }?.let { GesturePrefs.read(context, it) } } + var defaults by remember { mutableStateOf(initialDefaults) } + var gameDraft by remember { mutableStateOf(initialGame) } + var forGame by rememberSaveable { mutableStateOf(targetGame != null) } + val config = if (forGame) gameDraft ?: defaults else defaults + val change: (TouchGestureConfig) -> Unit = { if (forGame) gameDraft = it else defaults = it } + var tab by rememberSaveable { mutableIntStateOf(0) } + val tabs = listOf(R.string.gesture_tab_one, R.string.gesture_tab_two, R.string.gesture_tab_three, R.string.gesture_tab_options) + val mouse = listOf(ACTION_LEFT_CLICK, ACTION_RIGHT_CLICK, ACTION_MIDDLE_CLICK) + val actions = mouse + ACTION_SHOW_KEYBOARD + GestureKeys.codes.keys.map { "key_$it" } + ACTION_KEY_TILDE + val p = LocalPalette.current + + GestureDialogFrame(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_editor_subtitle), onDismiss, + footer = { + Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) } + Spacer(Modifier.width(12.dp)) + Button(onClick = { + if (defaults != initialDefaults) GesturePrefs.save(context, null, defaults) + if (targetGame != null && gameDraft != initialGame) { + gameDraft?.let { GesturePrefs.save(context, targetGame, it) } ?: GesturePrefs.clear(context, targetGame) + } + onSaved() + }, modifier = Modifier.testTag("gesture-save")) { Text(stringResource(R.string.gesture_save_changes)) } + } + }) { + Column(Modifier.padding(horizontal = 20.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default")) + if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game")) + if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) } + } + Text(stringResource(if (!forGame) R.string.gesture_scope_default else if (gameDraft == null) R.string.gesture_inheriting else R.string.gesture_scope_game), + color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 12.dp)) + } + ScrollableTabRow(tab, edgePadding = 12.dp, containerColor = p.surface, divider = { HorizontalDivider(color = p.line) }) { + tabs.forEachIndexed { index, title -> + Tab(tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }, selectedContentColor = p.onBackground, + unselectedContentColor = p.onSurfaceVariant, modifier = Modifier.testTag("gesture-tab-$index")) + } + } + key(tab) { + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + when (tab) { + 0 -> { + GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint) + GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) { + GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) } + } + GestureCard(R.string.gesture_drag, config.dragEnabled, { change(config.copy(dragEnabled = it)) }) { + GestureActionField("drag", R.string.gesture_drag, config.dragAction, PAN_ACTIONS) { change(config.copy(dragAction = it)) } + } + GestureCard(R.string.gesture_long_press, config.longPressEnabled, { change(config.copy(longPressEnabled = it)) }) { + GestureActionField("long-press", R.string.gesture_long_press, config.longPressAction, actions) { change(config.copy(longPressAction = it)) } + GestureHoldOptions(config.longPressAction, config.longPressMouseBehavior, config.longPressDelay, { change(config.copy(longPressMouseBehavior = it)) }, { change(config.copy(longPressDelay = it)) }) + } + GestureCard(R.string.gesture_double_tap, config.doubleTapEnabled, { change(config.copy(doubleTapEnabled = it)) }) { + GestureDelay(stringResource(R.string.gesture_double_delay), config.doubleTapDelay) { change(config.copy(doubleTapDelay = it)) } + } + } + 1 -> { + GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint) + GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) { + GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) } + } + GestureCard(R.string.gesture_two_drag, config.twoFingerDragEnabled, { change(config.copy(twoFingerDragEnabled = it)) }) { + GestureActionField("two-drag", R.string.gesture_two_drag, config.twoFingerDragAction, PAN_ACTIONS) { change(config.copy(twoFingerDragAction = it)) } + } + GestureCard(R.string.gesture_two_hold, config.twoFingerHoldEnabled, { change(config.copy(twoFingerHoldEnabled = it)) }) { + GestureActionField("two-hold", R.string.gesture_two_hold, config.twoFingerHoldAction, actions) { change(config.copy(twoFingerHoldAction = it)) } + GestureHoldOptions(config.twoFingerHoldAction, config.twoFingerHoldMouseBehavior, config.twoFingerHoldDelay, { change(config.copy(twoFingerHoldMouseBehavior = it)) }, { change(config.copy(twoFingerHoldDelay = it)) }) + } + GestureCard(R.string.gesture_pinch, config.pinchEnabled, { change(config.copy(pinchEnabled = it)) }) { + GestureActionField("pinch", R.string.gesture_pinch, config.pinchAction, ZOOM_ACTIONS) { change(config.copy(pinchAction = it)) } + } + } + 2 -> { + GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint) + GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) { + GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) } + } + GestureCard(R.string.gesture_three_drag, config.threeFingerDragEnabled, { change(config.copy(threeFingerDragEnabled = it)) }) { + GestureActionField("three-drag", R.string.gesture_three_drag, config.threeFingerDragAction, PAN_ACTIONS) { change(config.copy(threeFingerDragAction = it)) } + } + GestureCard(R.string.gesture_three_hold, config.threeFingerHoldEnabled, { change(config.copy(threeFingerHoldEnabled = it)) }) { + GestureActionField("three-hold", R.string.gesture_three_hold, config.threeFingerHoldAction, actions) { change(config.copy(threeFingerHoldAction = it)) } + GestureHoldOptions(config.threeFingerHoldAction, config.threeFingerHoldMouseBehavior, config.threeFingerHoldDelay, { change(config.copy(threeFingerHoldMouseBehavior = it)) }, { change(config.copy(threeFingerHoldDelay = it)) }) + } + } + 3 -> { + GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint) + GestureSettingsCard(R.string.gesture_movement) { + Text(stringResource(R.string.gesture_movement_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) } + Text(stringResource(R.string.gesture_middle_relative_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + } + GestureSettingsCard(R.string.gesture_recognition) { + Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium) + Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17) + Text(stringResource(R.string.gesture_threshold_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) + } + GestureSettingsCard(R.string.gesture_feedback) { + GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) } + } + OutlinedButton(onClick = { change(TouchGestureConfig()) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.gesture_reset)) } + } + } + } + } + } +} + +@Composable +private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () -> Unit, + footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + // The app window does not opt into RTL, so these dialogs follow their resource locale explicitly. + val direction = if (LocalConfiguration.current.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL) LayoutDirection.Rtl else LayoutDirection.Ltr + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + CompositionLocalProvider(LocalLayoutDirection provides direction) { + Box(Modifier.fillMaxSize().systemBarsPadding().imePadding().padding(12.dp), contentAlignment = Alignment.Center) { + Surface(Modifier.widthIn(max = 1040.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(22.dp), color = p.background, + contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) { + Column { + Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(12.dp), color = p.primary.copy(alpha = 0.14f)) { + Icon(Icons.Default.TouchApp, null, tint = p.primary, modifier = Modifier.padding(10.dp).size(24.dp)) + } + Column(Modifier.weight(1f).padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = p.onSurfaceVariant) + } + IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) } + } + content() + HorizontalDivider(color = p.line) + footer() + } + } + } + } + } +} + +@Composable +private fun GestureSection(@StringRes title: Int, @StringRes hint: Int) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(hint), style = MaterialTheme.typography.bodyMedium, color = LocalPalette.current.onSurfaceVariant) + } +} + +@Composable +private fun GestureCard(@StringRes title: Int, checked: Boolean, onToggle: (Boolean) -> Unit, + content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = p.surface, + border = BorderStroke(1.dp, if (checked) p.line2 else p.line)) { + Column { + Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = onToggle) + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).padding(end = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + Switch(checked, onCheckedChange = null) + } + if (checked) Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content) + } + } +} + +@Composable +private fun GestureActionField(id: String, @StringRes gesture: Int, selected: String, choices: List, onChoose: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + val p = LocalPalette.current + Surface(onClick = { open = true }, modifier = Modifier.fillMaxWidth().testTag("gesture-choice-$id"), shape = RoundedCornerShape(10.dp), + color = p.surfaceVariant, contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) { + Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.gesture_action_label), color = p.onSurfaceVariant, style = MaterialTheme.typography.labelSmall) + Text(gestureActionTitle(selected), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + } + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(19.dp), tint = p.onSurfaceVariant) + } + } + if (open) GestureActionPicker(choices, selected, stringResource(gesture), onDismiss = { open = false }) { open = false; onChoose(it) } +} + +@Composable +internal fun GestureActionPicker(choices: List, selected: String, gesture: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) { + var query by rememberSaveable { mutableStateOf("") } + var category by remember { mutableStateOf(null) } + val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) } + val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } } + val shown = filterGestureActions(catalog, query, category) + val p = LocalPalette.current + GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), onDismiss) { + OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"), + singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) }, + leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = { + if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { Icon(Icons.Default.Close, stringResource(R.string.gesture_clear_search)) } + }) + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GestureFilterChip(category == null, onClick = { category = null }, label = { Text(stringResource(R.string.gesture_all_actions)) }) + if (groups.size > 1) groups.forEach { group -> + GestureFilterChip(category == group, onClick = { category = group }, label = { Text(stringResource(group.title)) }, modifier = Modifier.testTag("gesture-category-${group.name}")) + } + } + if (shown.isEmpty()) Box(Modifier.weight(1f).fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.gesture_no_actions), color = p.onSurfaceVariant) + } else LazyColumn(Modifier.weight(1f).fillMaxWidth().testTag("gesture-action-list"), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + groups.forEach { group -> + val entries = shown.filter { it.group == group } + if (entries.isNotEmpty()) { + item(key = group.name) { Text(stringResource(group.title), style = MaterialTheme.typography.labelLarge, color = p.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp, bottom = 5.dp)) } + items(entries, key = { it.id }) { action -> + val isSelected = action.id == selected + Surface(shape = RoundedCornerShape(12.dp), color = if (isSelected) p.primary.copy(alpha = 0.12f) else p.surface, + border = BorderStroke(1.dp, if (isSelected) p.primary else p.line)) { + Row(Modifier.fillMaxWidth().selectable(isSelected, role = Role.RadioButton, onClick = { onChoose(action.id) }) + .testTag("gesture-action-${action.id}").padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(when (group) { GestureActionGroup.MOUSE -> Icons.Default.Mouse; GestureActionGroup.APP -> Icons.Default.TouchApp; else -> Icons.Default.Keyboard }, + null, tint = if (isSelected) p.primary else p.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Text(action.title, Modifier.weight(1f).padding(horizontal = 14.dp), style = MaterialTheme.typography.bodyLarge) + RadioButton(isSelected, onClick = null) + } + } + } + } + } + } + } +} + +@Composable +private fun GestureHoldOptions(action: String, behavior: String, delay: Int, onBehavior: (String) -> Unit, onDelay: (Int) -> Unit) { + if (TouchGestureConfig.isMouseButtonAction(action)) { + Text(stringResource(R.string.gesture_mouse_behavior), style = MaterialTheme.typography.labelMedium, color = LocalPalette.current.onSurfaceVariant) + GestureChoiceChips(behavior, listOf(MOUSE_BEHAVIOR_HOLD to stringResource(R.string.gesture_hold_button), MOUSE_BEHAVIOR_CLICK to stringResource(R.string.gesture_click_once)), onBehavior) + } + GestureDelay(stringResource(R.string.gesture_hold_delay), delay, onDelay) +} + +@Composable +private fun GestureFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier) { + val p = LocalPalette.current + FilterChip(selected, onClick, label, modifier = modifier, colors = FilterChipDefaults.filterChipColors( + containerColor = p.surface, labelColor = p.onSurfaceVariant, + selectedContainerColor = p.primary.copy(alpha = 0.16f), selectedLabelColor = p.onBackground)) +} + +@Composable +private fun GestureChoiceChips(selected: String, options: List>, choose: (String) -> Unit) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (id, label) -> GestureFilterChip(selected == id, onClick = { choose(id) }, label = { Text(label) }) } + } +} + +@Composable +private fun GestureSettingsCard(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) { + val p = LocalPalette.current + Surface(shape = RoundedCornerShape(16.dp), color = p.surface, border = BorderStroke(1.dp, p.line)) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } + } +} + +@Composable +private fun GestureToggle(@StringRes title: Int, checked: Boolean, change: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = change).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(title), Modifier.weight(1f).padding(end = 12.dp), style = MaterialTheme.typography.bodyMedium) + Switch(checked, null) + } +} + +@Composable +private fun GestureDelay(label: String, value: Int, change: (Int) -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.gesture_ms, value), Modifier.background(LocalPalette.current.surfaceVariant, RoundedCornerShape(6.dp)).padding(horizontal = 10.dp, vertical = 5.dp), style = MaterialTheme.typography.labelLarge) + } + Slider(value.toFloat().coerceIn(100f, 1000f), { change((it / 50).toInt() * 50) }, valueRange = 100f..1000f, steps = 17) +} diff --git a/app/src/main/res/values-ar/gestures.xml b/app/src/main/res/values-ar/gestures.xml new file mode 100644 index 000000000..5aee4b085 --- /dev/null +++ b/app/src/main/res/values-ar/gestures.xml @@ -0,0 +1,103 @@ + + + الإيماءات + إعدادات إيماءات اللمس + خصّص إيماءات الفأرة ولوحة المفاتيح. اختر الإيماءات كوضع للمس لاستخدامها. + نقرة + سحب بإصبع واحد + ضغط مطوّل + نقر مزدوج (نقرتان بالزر الأيسر) + نقر بإصبعين + سحب بإصبعين + ضغط مستمر بإصبعين + نقر بثلاثة أصابع + سحب بثلاثة أصابع + ضغط مستمر بثلاثة أصابع + ضمّ الإصبعين أو إبعادهما للتكبير والتصغير + إبقاء الزر مضغوطًا + النقر مرة واحدة + مهلة الضغط المستمر + الفاصل بين النقرتين + ينطبق على اللعبة الحالية + الإعدادات الافتراضية لجميع الألعاب + استعادة إعدادات الإيماءات الأصلية + إلغاء + اختيار إجراء + نقر بالزر الأيسر + نقر بالزر الأيمن + نقر بالزر الأوسط + إظهار لوحة المفاتيح + نقر أيسر وسحب + نقر أيمن وسحب + تحريك العرض بزر الفأرة الأوسط + تحريك العرض بزر الفأرة الأوسط (معكوس) + مفاتيح الأسهم + مفاتيح الأسهم (معكوسة) + WASD (معكوس) + عجلة الفأرة + عتبة الإيماءات: %1$d بكسل + إظهار الإيماءة المكتشفة + عناصر تحكم الفأرة ولوحة المفاتيح مرتبة حسب الإيماءة + إصبع واحد + إصبعان + ثلاثة أصابع + خيارات + اختر إجراء النقر أو السحب أو الضغط المستمر. + اضبط تحريك الكاميرا والتكبير والنقرات الثانوية. + اجعل الاختصارات الإضافية في متناولك. + اضبط الحركة والتعرّف والاستجابة المرئية. + الافتراضية + هذه اللعبة + استخدام الإعدادات الافتراضية + تُستخدم الإعدادات الافتراضية. يؤدي التعديل إلى إنشاء إعدادات خاصة باللعبة. + حفظ التغييرات + إغلاق + الإجراء + لـ %1$s + البحث عن إجراءات الفأرة أو مفاتيح لوحة المفاتيح + مسح البحث + الكل + لا توجد إجراءات مطابقة. جرّب اسم مفتاح أو إجراء آخر. + الفأرة + إجراءات التطبيق + التنقل + الأحرف + الأرقام + مفاتيح الوظائف + مفاتيح التعديل + لوحة الأرقام + الرموز + حركة الفأرة + يتبع السحب المباشر موضع اللمس. ويتبع السحب النسبي حركة الإصبع، وهو مفيد عندما تعيد اللعبة الفأرة إلى المنتصف. + يتبع تحريك العرض بالزر الأوسط حركة الإصبع دائمًا لتجنّب قفزات المؤشر. + مباشر + نسبي + التعرّف على الإيماءات + تحرّك هذه المسافة قبل أن تتحول إيماءة بإصبعين أو ثلاثة إلى تحريك العرض أو التكبير. + الاستجابة المرئية + سلوك زر الفأرة + %1$d مللي ثانية + لوحة الأرقام %1$s + هروب + مسافة للخلف + إدخال + Tab + مسافة + حذف + إدراج + Home + End + صفحة لأعلى + صفحة لأسفل + طباعة الشاشة + سهم لأعلى + سهم لأسفل + سهم لليسار + سهم لليمين + قفل الأحرف الكبيرة + قفل الأرقام + قفل التمرير + %1$s الأيسر + %1$s الأيمن + Shift + diff --git a/app/src/main/res/values-b+es+419/gestures.xml b/app/src/main/res/values-b+es+419/gestures.xml new file mode 100644 index 000000000..13a89a1b8 --- /dev/null +++ b/app/src/main/res/values-b+es+419/gestures.xml @@ -0,0 +1,103 @@ + + + Gestos + Ajustes de gestos táctiles + Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos. + Toque + Arrastre con un dedo + Pulsación prolongada + Doble toque (doble clic izquierdo) + Toque con dos dedos + Arrastre con dos dedos + Pulsación sostenida con dos dedos + Toque con tres dedos + Arrastre con tres dedos + Pulsación sostenida con tres dedos + Pellizcar para ampliar + Mantener el botón pulsado + Hacer clic una vez + Tiempo de pulsación sostenida + Intervalo de doble toque + Se aplica al juego actual + Ajustes predeterminados para todos los juegos + Restablecer los ajustes originales de gestos + Cancelar + Elegir acción + Clic izquierdo + Clic derecho + Clic central + Mostrar teclado + Clic izquierdo y arrastre + Clic derecho y arrastre + Desplazamiento con el botón central + Desplazamiento con el botón central (invertido) + Teclas de dirección + Teclas de dirección (invertidas) + WASD (invertido) + Rueda del ratón + Umbral de gestos: %1$d px + Mostrar gesto reconocido + Controles de ratón y teclado organizados por gesto + Un dedo + Dos dedos + Tres dedos + Opciones + Elige qué debe hacer un toque, arrastre o pulsación sostenida. + Configura el movimiento de cámara, el zoom y los clics secundarios. + Ten más accesos directos al alcance de la mano. + Ajusta el movimiento, el reconocimiento y la respuesta visual. + Predeterminados + Este juego + Usar valores predeterminados + Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego. + Guardar cambios + Cerrar + ACCIÓN + Para %1$s + Buscar acciones de ratón o teclas del teclado + Borrar búsqueda + Todas + No hay acciones coincidentes. Prueba con otro nombre de tecla o acción. + Ratón + Acciones de la aplicación + Navegación + Letras + Números + Teclas de función + Teclas modificadoras + Teclado numérico + Símbolos + Movimiento del ratón + El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón. + El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor. + Directo + Relativo + Reconocimiento de gestos + Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom. + Respuesta visual + Comportamiento del botón del ratón + %1$d ms + Teclado numérico %1$s + Escape + Retroceso + Intro + Tab + Espacio + Supr + Insertar + Inicio + Fin + Re Pág + Av Pág + Impr Pant + Flecha arriba + Flecha abajo + Flecha izquierda + Flecha derecha + Bloq Mayús + Bloq Num + Bloq Despl + %1$s izquierdo + %1$s derecho + Mayús + diff --git a/app/src/main/res/values-bg/gestures.xml b/app/src/main/res/values-bg/gestures.xml new file mode 100644 index 000000000..4c389aaba --- /dev/null +++ b/app/src/main/res/values-bg/gestures.xml @@ -0,0 +1,103 @@ + + + Жестове + Настройки на сензорните жестове + Настройте жестовете за мишка и клавиатура. За да ги използвате, изберете сензорен режим „Жестове“. + Докосване + Плъзгане с един пръст + Продължително натискане + Двойно докосване (двойно ляво щракване) + Докосване с два пръста + Плъзгане с два пръста + Задържане с два пръста + Докосване с три пръста + Плъзгане с три пръста + Задържане с три пръста + Мащабиране със събиране или раздалечаване на пръстите + Задържане на бутона натиснат + Еднократно щракване + Закъснение за задържане + Интервал за двойно докосване + Прилага се към текущата игра + Настройки по подразбиране за всички игри + Възстановяване на първоначалните настройки на жестовете + Отказ + Избор на действие + Ляво щракване + Дясно щракване + Средно щракване + Показване на клавиатурата + Ляво щракване и плъзгане + Дясно щракване и плъзгане + Преместване със средния бутон + Преместване със средния бутон (обърнато) + Клавиши със стрелки + Клавиши със стрелки (обърнати) + WASD (обърнато) + Колелце на мишката + Праг на жестовете: %1$d пиксела + Показване на разпознатия жест + Управление с мишка и клавиатура, подредено по жестове + Един пръст + Два пръста + Три пръста + Опции + Изберете действието при докосване, плъзгане или задържане. + Настройте преместването на камерата, мащабирането и вторичните щраквания. + Дръжте допълнителните команди подръка. + Настройте движението, разпознаването и визуалната обратна връзка. + Стандартни настройки + Тази игра + Използване на подразбиращите се + Използват се настройките по подразбиране. Редактирането създава настройки за тази игра. + Запазване на промените + Затвори + ДЕЙСТВИЕ + За %1$s + Търсене на действия с мишката или клавиши + Изчистване на търсенето + Всички + Няма съвпадащи действия. Опитайте друго име на клавиш или действие. + Мишка + Действия на приложението + Навигация + Букви + Цифри + Функционални клавиши + Модифициращи клавиши + Цифрова клавиатура + Символи + Движение на мишката + Директното плъзгане следва мястото на докосване. Относителното следва движението на пръста и е полезно, когато играта центрира мишката. + Преместването със средния бутон винаги следва движението на пръста, за да няма скокове на курсора. + Директно + Относително + Разпознаване на жестове + Изминете това разстояние, преди жест с два или три пръста да стане преместване или мащабиране. + Визуална обратна връзка + Поведение на бутона на мишката + %1$d мс + Цифрова клавиатура %1$s + Esc + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрелка нагоре + Стрелка надолу + Стрелка наляво + Стрелка надясно + Caps Lock + Num Lock + Scroll Lock + Ляв %1$s + Десен %1$s + Shift + diff --git a/app/src/main/res/values-cs/gestures.xml b/app/src/main/res/values-cs/gestures.xml new file mode 100644 index 000000000..81de9094d --- /dev/null +++ b/app/src/main/res/values-cs/gestures.xml @@ -0,0 +1,103 @@ + + + Gesta + Nastavení dotykových gest + Přizpůsobte gesta myši a klávesnice. Chcete-li je používat, vyberte dotykový režim Gesta. + Klepnutí + Tažení jedním prstem + Dlouhé stisknutí + Dvojité klepnutí (dvojklik levým tlačítkem) + Klepnutí dvěma prsty + Tažení dvěma prsty + Podržení dvěma prsty + Klepnutí třemi prsty + Tažení třemi prsty + Podržení třemi prsty + Přiblížení sevřením či rozevřením prstů + Držet tlačítko stisknuté + Jednou kliknout + Prodleva podržení + Interval dvojitého klepnutí + Platí pro aktuální hru + Výchozí nastavení pro všechny hry + Obnovit původní nastavení gest + Zrušit + Vybrat akci + Levé kliknutí + Pravé kliknutí + Prostřední kliknutí + Zobrazit klávesnici + Levé kliknutí a tažení + Pravé kliknutí a tažení + Posouvání prostředním tlačítkem myši + Posouvání prostředním tlačítkem myši (obráceně) + Šipky + Šipky (obráceně) + WASD (obráceně) + Kolečko myši + Práh gest: %1$d px + Zobrazovat rozpoznané gesto + Ovládání myši a klávesnice uspořádané podle gest + Jeden prst + Dva prsty + Tři prsty + Možnosti + Vyberte akci pro klepnutí, tažení nebo podržení. + Nastavte posouvání kamery, přiblížení a vedlejší kliknutí. + Mějte další zkratky na dosah. + Upravte pohyb, rozpoznávání a vizuální odezvu. + Výchozí + Tato hra + Použít výchozí + Používá se výchozí nastavení. Úpravou vznikne nastavení pro tuto hru. + Uložit změny + Zavřít + AKCE + Pro %1$s + Hledat akce myši nebo klávesy + Vymazat hledání + Vše + Žádné odpovídající akce. Zkuste jiný název klávesy nebo akce. + Myš + Akce aplikace + Navigace + Písmena + Číslice + Funkční klávesy + Modifikační klávesy + Numerická klávesnice + Symboly + Pohyb myši + Přímé tažení sleduje polohu dotyku. Relativní tažení sleduje pohyb prstu a hodí se, když hra vrací myš do středu. + Posouvání prostředním tlačítkem vždy sleduje pohyb prstu, aby kurzor nepřeskakoval. + Přímé + Relativní + Rozpoznávání gest + Po překonání této vzdálenosti se gesto dvěma či třemi prsty změní na posouvání nebo přiblížení. + Vizuální odezva + Chování tlačítka myši + %1$d ms + Numerická klávesnice %1$s + Esc + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Šipka nahoru + Šipka dolů + Šipka vlevo + Šipka vpravo + Caps Lock + Num Lock + Scroll Lock + Levý %1$s + Pravý %1$s + Shift + diff --git a/app/src/main/res/values-da/gestures.xml b/app/src/main/res/values-da/gestures.xml new file mode 100644 index 000000000..e7b0cc1dc --- /dev/null +++ b/app/src/main/res/values-da/gestures.xml @@ -0,0 +1,103 @@ + + + Bevægelser + Indstillinger for berøringsbevægelser + Tilpas muse- og tastaturbevægelser. Vælg Bevægelser som berøringstilstand for at bruge dem. + Tryk + Træk med én finger + Langt tryk + Dobbelttryk (dobbelt venstreklik) + Tryk med to fingre + Træk med to fingre + Hold med to fingre + Tryk med tre fingre + Træk med tre fingre + Hold med tre fingre + Knib for at zoome + Hold knappen nede + Klik én gang + Forsinkelse før hold + Interval for dobbelttryk + Gælder det aktuelle spil + Standardindstillinger for alle spil + Gendan de oprindelige bevægelsesindstillinger + Annuller + Vælg handling + Venstreklik + Højreklik + Midterklik + Vis tastatur + Venstreklik og træk + Højreklik og træk + Panorering med midterste museknap + Panorering med midterste museknap (omvendt) + Piletaster + Piletaster (omvendt) + WASD (omvendt) + Musehjul + Bevægelsestærskel: %1$d px + Vis genkendt bevægelse + Muse- og tastaturstyring organiseret efter bevægelse + Én finger + To fingre + Tre fingre + Indstillinger + Vælg, hvad et tryk, træk eller hold skal gøre. + Indstil kamerapanorering, zoom og sekundære klik. + Hav flere genveje inden for rækkevidde. + Juster bevægelse, genkendelse og visuel feedback. + Standarder + Dette spil + Brug standardindstillinger + Bruger standardindstillingerne. Redigering opretter særlige indstillinger for spillet. + Gem ændringer + Luk + HANDLING + For %1$s + Søg efter musehandlinger eller tastaturtaster + Ryd søgning + Alle + Ingen matchende handlinger. Prøv et andet navn på en tast eller handling. + Mus + Apphandlinger + Navigation + Bogstaver + Tal + Funktionstaster + Modifikationstaster + Numerisk tastatur + Symboler + Musebevægelse + Direkte træk følger berøringspositionen. Relative træk følger fingerbevægelsen og er nyttige, når et spil centrerer musen. + Panorering med midterste museknap følger altid fingerbevægelsen for at undgå markørspring. + Direkte + Relativ + Genkendelse af bevægelser + Bevæg fingrene så langt, før en bevægelse med to eller tre fingre bliver til panorering eller zoom. + Visuel feedback + Museknappens funktion + %1$d ms + Numerisk tastatur %1$s + Escape + Tilbage + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil op + Pil ned + Pil til venstre + Pil til højre + Caps Lock + Num Lock + Scroll Lock + Venstre %1$s + Højre %1$s + Skift + diff --git a/app/src/main/res/values-de/gestures.xml b/app/src/main/res/values-de/gestures.xml new file mode 100644 index 000000000..22b750ece --- /dev/null +++ b/app/src/main/res/values-de/gestures.xml @@ -0,0 +1,103 @@ + + + Gesten + Touchgesten-Einstellungen + Maus- und Tastaturgesten anpassen. Wähle Gesten als Touchmodus, um sie zu verwenden. + Tippen + Mit einem Finger ziehen + Lange drücken + Doppeltippen (doppelter Linksklick) + Mit zwei Fingern tippen + Mit zwei Fingern ziehen + Mit zwei Fingern halten + Mit drei Fingern tippen + Mit drei Fingern ziehen + Mit drei Fingern halten + Zum Zoomen Finger zusammen- oder auseinanderziehen + Taste gedrückt halten + Einmal klicken + Halteverzögerung + Doppeltipp-Intervall + Gilt für das aktuelle Spiel + Standardeinstellungen für alle Spiele + Ursprüngliche Gesteneinstellungen wiederherstellen + Abbrechen + Aktion auswählen + Linksklick + Rechtsklick + Mittelklick + Tastatur einblenden + Linksklick und ziehen + Rechtsklick und ziehen + Mit mittlerer Maustaste schwenken + Mit mittlerer Maustaste schwenken (umgekehrt) + Pfeiltasten + Pfeiltasten (umgekehrt) + WASD (umgekehrt) + Mausrad + Gestenschwelle: %1$d px + Erkannte Geste anzeigen + Maus- und Tastatursteuerung nach Gesten geordnet + Ein Finger + Zwei Finger + Drei Finger + Optionen + Wähle die Aktion für Tippen, Ziehen oder Halten. + Kameraschwenks, Zoom und sekundäre Klicks anpassen. + Zusätzliche Kurzbefehle griffbereit halten. + Bewegung, Erkennung und visuelle Rückmeldung anpassen. + Standardwerte + Dieses Spiel + Standardeinstellungen verwenden + Standardeinstellungen werden verwendet. Änderungen erstellen eine Spielanpassung. + Änderungen speichern + Schließen + AKTION + Für %1$s + Mausaktionen oder Tastaturtasten suchen + Suche leeren + Alle + Keine passenden Aktionen. Versuche einen anderen Tasten- oder Aktionsnamen. + Maus + App-Aktionen + Navigation + Buchstaben + Zahlen + Funktionstasten + Modifikatortasten + Ziffernblock + Symbole + Mausbewegung + Direktes Ziehen folgt der Berührungsposition. Relatives Ziehen folgt der Fingerbewegung und eignet sich für Spiele, die die Maus zentrieren. + Schwenks mit mittlerer Maustaste folgen immer der Fingerbewegung, um Zeigersprünge zu vermeiden. + Direkt + Relativ + Gestenerkennung + Bewege die Finger um diese Strecke, bevor eine Zwei- oder Dreifingergeste als Schwenken oder Zoomen gilt. + Visuelle Rückmeldung + Verhalten der Maustaste + %1$d ms + Ziffernblock %1$s + Esc + Rücktaste + Eingabetaste + Tab + Leertaste + Entf + Einfg + Pos1 + Ende + Bild auf + Bild ab + Druck + Pfeil nach oben + Pfeil nach unten + Pfeil nach links + Pfeil nach rechts + Feststelltaste + Num-Feststelltaste + Rollen-Feststelltaste + Linke %1$s + Rechte %1$s + Umschalt + diff --git a/app/src/main/res/values-el/gestures.xml b/app/src/main/res/values-el/gestures.xml new file mode 100644 index 000000000..65b86e4a7 --- /dev/null +++ b/app/src/main/res/values-el/gestures.xml @@ -0,0 +1,103 @@ + + + Χειρονομίες + Ρυθμίσεις χειρονομιών αφής + Προσαρμόστε τις χειρονομίες ποντικιού και πληκτρολογίου. Επιλέξτε Χειρονομίες ως λειτουργία αφής για να τις χρησιμοποιήσετε. + Πάτημα + Σύρσιμο με ένα δάχτυλο + Παρατεταμένο πάτημα + Διπλό πάτημα (διπλό αριστερό κλικ) + Πάτημα με δύο δάχτυλα + Σύρσιμο με δύο δάχτυλα + Κράτημα με δύο δάχτυλα + Πάτημα με τρία δάχτυλα + Σύρσιμο με τρία δάχτυλα + Κράτημα με τρία δάχτυλα + Μεγέθυνση με τσίμπημα + Διατήρηση του κουμπιού πατημένου + Ένα κλικ + Καθυστέρηση κρατήματος + Διάστημα διπλού πατήματος + Ισχύει για το τρέχον παιχνίδι + Προεπιλεγμένες ρυθμίσεις για όλα τα παιχνίδια + Επαναφορά αρχικών ρυθμίσεων χειρονομιών + Ακύρωση + Επιλογή ενέργειας + Αριστερό κλικ + Δεξί κλικ + Μεσαίο κλικ + Εμφάνιση πληκτρολογίου + Αριστερό κλικ και σύρσιμο + Δεξί κλικ και σύρσιμο + Μετατόπιση με το μεσαίο κουμπί + Μετατόπιση με το μεσαίο κουμπί (αντίστροφη) + Πλήκτρα βέλους + Πλήκτρα βέλους (αντίστροφα) + WASD (αντίστροφα) + Ροδέλα ποντικιού + Όριο χειρονομιών: %1$d px + Εμφάνιση αναγνωρισμένης χειρονομίας + Χειρισμοί ποντικιού και πληκτρολογίου ανά χειρονομία + Ένα δάχτυλο + Δύο δάχτυλα + Τρία δάχτυλα + Επιλογές + Επιλέξτε την ενέργεια για πάτημα, σύρσιμο ή κράτημα. + Ρυθμίστε τη μετατόπιση της κάμερας, τη μεγέθυνση και τα δευτερεύοντα κλικ. + Κρατήστε επιπλέον συντομεύσεις κοντά σας. + Ρυθμίστε την κίνηση, την αναγνώριση και την οπτική ανάδραση. + Προεπιλογές + Αυτό το παιχνίδι + Χρήση προεπιλογών + Χρησιμοποιούνται οι προεπιλογές. Η επεξεργασία δημιουργεί ρυθμίσεις για αυτό το παιχνίδι. + Αποθήκευση αλλαγών + Κλείσιμο + ΕΝΕΡΓΕΙΑ + Για %1$s + Αναζήτηση ενεργειών ποντικιού ή πλήκτρων + Εκκαθάριση αναζήτησης + Όλα + Δεν βρέθηκαν ενέργειες. Δοκιμάστε άλλο όνομα πλήκτρου ή ενέργειας. + Ποντίκι + Ενέργειες εφαρμογής + Πλοήγηση + Γράμματα + Αριθμοί + Πλήκτρα λειτουργιών + Πλήκτρα τροποποίησης + Αριθμητικό πληκτρολόγιο + Σύμβολα + Κίνηση ποντικιού + Το άμεσο σύρσιμο ακολουθεί τη θέση αφής. Το σχετικό σύρσιμο ακολουθεί την κίνηση του δαχτύλου, χρήσιμο όταν το παιχνίδι επαναφέρει το ποντίκι στο κέντρο. + Η μετατόπιση με το μεσαίο κουμπί ακολουθεί πάντα την κίνηση του δαχτύλου για να αποφεύγονται άλματα του δείκτη. + Απευθείας + Σχετική + Αναγνώριση χειρονομιών + Κινηθείτε κατά αυτή την απόσταση προτού μια χειρονομία με δύο ή τρία δάχτυλα γίνει μετατόπιση ή μεγέθυνση. + Οπτική ανάδραση + Συμπεριφορά κουμπιού ποντικιού + %1$d ms + Αριθμητικό πληκτρολόγιο %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Βέλος πάνω + Βέλος κάτω + Βέλος αριστερά + Βέλος δεξιά + Caps Lock + Num Lock + Scroll Lock + Αριστερό %1$s + Δεξί %1$s + Shift + diff --git a/app/src/main/res/values-es/gestures.xml b/app/src/main/res/values-es/gestures.xml new file mode 100644 index 000000000..13a89a1b8 --- /dev/null +++ b/app/src/main/res/values-es/gestures.xml @@ -0,0 +1,103 @@ + + + Gestos + Ajustes de gestos táctiles + Personaliza los gestos de ratón y teclado. Selecciona Gestos como modo táctil para usarlos. + Toque + Arrastre con un dedo + Pulsación prolongada + Doble toque (doble clic izquierdo) + Toque con dos dedos + Arrastre con dos dedos + Pulsación sostenida con dos dedos + Toque con tres dedos + Arrastre con tres dedos + Pulsación sostenida con tres dedos + Pellizcar para ampliar + Mantener el botón pulsado + Hacer clic una vez + Tiempo de pulsación sostenida + Intervalo de doble toque + Se aplica al juego actual + Ajustes predeterminados para todos los juegos + Restablecer los ajustes originales de gestos + Cancelar + Elegir acción + Clic izquierdo + Clic derecho + Clic central + Mostrar teclado + Clic izquierdo y arrastre + Clic derecho y arrastre + Desplazamiento con el botón central + Desplazamiento con el botón central (invertido) + Teclas de dirección + Teclas de dirección (invertidas) + WASD (invertido) + Rueda del ratón + Umbral de gestos: %1$d px + Mostrar gesto reconocido + Controles de ratón y teclado organizados por gesto + Un dedo + Dos dedos + Tres dedos + Opciones + Elige qué debe hacer un toque, arrastre o pulsación sostenida. + Configura el movimiento de cámara, el zoom y los clics secundarios. + Ten más accesos directos al alcance de la mano. + Ajusta el movimiento, el reconocimiento y la respuesta visual. + Predeterminados + Este juego + Usar valores predeterminados + Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego. + Guardar cambios + Cerrar + ACCIÓN + Para %1$s + Buscar acciones de ratón o teclas del teclado + Borrar búsqueda + Todas + No hay acciones coincidentes. Prueba con otro nombre de tecla o acción. + Ratón + Acciones de la aplicación + Navegación + Letras + Números + Teclas de función + Teclas modificadoras + Teclado numérico + Símbolos + Movimiento del ratón + El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón. + El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor. + Directo + Relativo + Reconocimiento de gestos + Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom. + Respuesta visual + Comportamiento del botón del ratón + %1$d ms + Teclado numérico %1$s + Escape + Retroceso + Intro + Tab + Espacio + Supr + Insertar + Inicio + Fin + Re Pág + Av Pág + Impr Pant + Flecha arriba + Flecha abajo + Flecha izquierda + Flecha derecha + Bloq Mayús + Bloq Num + Bloq Despl + %1$s izquierdo + %1$s derecho + Mayús + diff --git a/app/src/main/res/values-fi/gestures.xml b/app/src/main/res/values-fi/gestures.xml new file mode 100644 index 000000000..1cca2cb40 --- /dev/null +++ b/app/src/main/res/values-fi/gestures.xml @@ -0,0 +1,103 @@ + + + Eleet + Kosketuseleiden asetukset + Mukauta hiiri- ja näppäimistöeleitä. Ota ne käyttöön valitsemalla kosketustilaksi Eleet. + Napautus + Veto yhdellä sormella + Pitkä painallus + Kaksoisnapautus (vasemman painikkeen kaksoisnapsautus) + Napautus kahdella sormella + Veto kahdella sormella + Pito kahdella sormella + Napautus kolmella sormella + Veto kolmella sormella + Pito kolmella sormella + Zoomaa nipistämällä + Pidä painike painettuna + Napsauta kerran + Pidon viive + Kaksoisnapautuksen aikaväli + Koskee nykyistä peliä + Kaikkien pelien oletusasetukset + Palauta alkuperäiset eleasetukset + Peruuta + Valitse toiminto + Vasen napsautus + Oikea napsautus + Keskinapsautus + Näytä näppäimistö + Vasen napsautus ja veto + Oikea napsautus ja veto + Panorointi hiiren keskipainikkeella + Panorointi hiiren keskipainikkeella (käänteinen) + Nuolinäppäimet + Nuolinäppäimet (käänteiset) + WASD (käänteinen) + Hiiren rulla + Elekynnys: %1$d px + Näytä tunnistettu ele + Hiiri- ja näppäimistöohjaus eleittäin + Yksi sormi + Kaksi sormea + Kolme sormea + Asetukset + Valitse napautuksen, vedon tai pidon toiminto. + Määritä kameran panorointi, zoomaus ja toissijaiset napsautukset. + Pidä lisäpikakomennot ulottuvillasi. + Säädä liikettä, tunnistusta ja visuaalista palautetta. + Oletukset + Tämä peli + Käytä oletuksia + Oletusasetukset ovat käytössä. Muokkaaminen luo pelikohtaiset asetukset. + Tallenna muutokset + Sulje + TOIMINTO + Kohteelle %1$s + Etsi hiiritoimintoja tai näppäimiä + Tyhjennä haku + Kaikki + Vastaavia toimintoja ei löytynyt. Kokeile toista näppäimen tai toiminnon nimeä. + Hiiri + Sovellustoiminnot + Siirtyminen + Kirjaimet + Numerot + Funktionäppäimet + Muunnosnäppäimet + Numeronäppäimistö + Symbolit + Hiiren liike + Suora veto seuraa kosketuskohtaa. Suhteellinen veto seuraa sormen liikettä ja sopii peleihin, jotka keskittävät hiiren. + Panorointi keskipainikkeella seuraa aina sormen liikettä, jotta osoitin ei hypi. + Suora + Suhteellinen + Eleiden tunnistus + Liikuta sormia tämän matkan verran, ennen kuin kahden tai kolmen sormen ele muuttuu panoroinniksi tai zoomaukseksi. + Visuaalinen palaute + Hiiripainikkeen toiminta + %1$d ms + Numeronäppäimistö %1$s + Esc + Askelpalautin + Enter + Tab + Space + Delete + Insert + Home + End + Sivu ylös + Sivu alas + Print Screen + Nuoli ylös + Nuoli alas + Nuoli vasemmalle + Nuoli oikealle + Caps Lock + Num Lock + Scroll Lock + Vasen %1$s + Oikea %1$s + Vaihto + diff --git a/app/src/main/res/values-fr/gestures.xml b/app/src/main/res/values-fr/gestures.xml new file mode 100644 index 000000000..72c9b6a0b --- /dev/null +++ b/app/src/main/res/values-fr/gestures.xml @@ -0,0 +1,103 @@ + + + Gestes + Paramètres des gestes tactiles + Personnalisez les gestes de souris et de clavier. Sélectionnez Gestes comme mode tactile pour les utiliser. + Appui + Glissement à un doigt + Appui prolongé + Double appui (double clic gauche) + Appui à deux doigts + Glissement à deux doigts + Maintien à deux doigts + Appui à trois doigts + Glissement à trois doigts + Maintien à trois doigts + Pincer pour zoomer + Maintenir le bouton enfoncé + Cliquer une fois + Délai de maintien + Intervalle du double appui + S\'applique au jeu actuel + Paramètres par défaut pour tous les jeux + Rétablir les paramètres des gestes d\'origine + Annuler + Choisir une action + Clic gauche + Clic droit + Clic du milieu + Afficher le clavier + Clic gauche et glissement + Clic droit et glissement + Déplacement avec le bouton du milieu + Déplacement avec le bouton du milieu (inversé) + Touches fléchées + Touches fléchées (inversées) + WASD (inversé) + Molette de la souris + Seuil des gestes : %1$d px + Afficher le geste reconnu + Commandes de souris et de clavier organisées par geste + Un doigt + Deux doigts + Trois doigts + Options + Choisissez l\'action d\'un appui, d\'un glissement ou d\'un maintien. + Configurez les déplacements de caméra, le zoom et les clics secondaires. + Gardez des raccourcis supplémentaires à portée de main. + Ajustez les mouvements, la reconnaissance et le retour visuel. + Valeurs par défaut + Ce jeu + Utiliser les valeurs par défaut + Paramètres par défaut utilisés. Une modification crée des paramètres propres au jeu. + Enregistrer les modifications + Fermer + ACTION + Pour %1$s + Rechercher des actions de souris ou des touches du clavier + Effacer la recherche + Tout + Aucune action correspondante. Essayez un autre nom de touche ou d\'action. + Souris + Actions de l\'application + Navigation + Lettres + Chiffres + Touches de fonction + Touches de modification + Pavé numérique + Symboles + Mouvement de la souris + Le glissement direct suit la position du toucher. Le glissement relatif suit le mouvement du doigt, utile lorsqu\'un jeu recentre la souris. + Le déplacement avec le bouton du milieu suit toujours le mouvement du doigt pour éviter les sauts du curseur. + Direct + Relatif + Reconnaissance des gestes + Parcourez cette distance avant qu\'un geste à deux ou trois doigts soit reconnu comme un déplacement ou un zoom. + Retour visuel + Comportement du bouton de souris + %1$d ms + Pavé numérique %1$s + Échap + Retour arrière + Entrée + Tab + Espace + Suppr + Inser + Début + Fin + Page précédente + Page suivante + Impr. écran + Flèche haut + Flèche bas + Flèche gauche + Flèche droite + Verr. maj. + Verr. num. + Arrêt défil. + %1$s gauche + %1$s droite + Maj + diff --git a/app/src/main/res/values-hi/gestures.xml b/app/src/main/res/values-hi/gestures.xml new file mode 100644 index 000000000..7d4a2365e --- /dev/null +++ b/app/src/main/res/values-hi/gestures.xml @@ -0,0 +1,103 @@ + + + जेस्चर + टच जेस्चर सेटिंग + माउस और कीबोर्ड जेस्चर बदलें। इस्तेमाल करने के लिए टच मोड में जेस्चर चुनें। + टैप + एक उँगली से खींचना + देर तक दबाना + दो बार टैप (दो बार बायाँ क्लिक) + दो उँगलियों से टैप + दो उँगलियों से खींचना + दो उँगलियों से दबाए रखना + तीन उँगलियों से टैप + तीन उँगलियों से खींचना + तीन उँगलियों से दबाए रखना + पिंच करके ज़ूम करें + बटन दबाए रखें + एक बार क्लिक करें + दबाए रखने का विलंब + दो टैप के बीच का अंतराल + मौजूदा गेम पर लागू होता है + सभी गेम की डिफ़ॉल्ट सेटिंग + जेस्चर की मूल सेटिंग वापस लाएँ + रद्द करें + कार्रवाई चुनें + बायाँ क्लिक + दायाँ क्लिक + बीच का क्लिक + कीबोर्ड दिखाएं + बायाँ क्लिक करके खींचना + दायाँ क्लिक करके खींचना + माउस के बीच वाले बटन से दृश्य खिसकाना + माउस के बीच वाले बटन से दृश्य खिसकाना (उल्टा) + तीर वाली कुंजियाँ + तीर वाली कुंजियाँ (उल्टी) + WASD (उल्टा) + माउस व्हील + जेस्चर की सीमा: %1$d पिक्सेल + पहचाना गया जेस्चर दिखाएँ + जेस्चर के अनुसार माउस और कीबोर्ड कंट्रोल + एक उँगली + दो उँगलियाँ + तीन उँगलियाँ + विकल्प + चुनें कि टैप करने, खींचने या दबाए रखने पर क्या हो। + कैमरा खिसकाना, ज़ूम और दूसरे क्लिक सेट करें। + अतिरिक्त शॉर्टकट आसानी से इस्तेमाल करें। + गति, पहचान और दृश्य प्रतिक्रिया में बदलाव करें। + डिफ़ॉल्ट + यह गेम + डिफ़ॉल्ट इस्तेमाल करें + डिफ़ॉल्ट सेटिंग इस्तेमाल हो रही हैं। बदलाव करने पर इस गेम की अलग सेटिंग बनेंगी। + बदलाव सेव करें + बंद करें + कार्रवाई + %1$s के लिए + माउस की कार्रवाइयाँ या कीबोर्ड की कुंजियाँ खोजें + खोज मिटाएँ + सभी + कोई मिलती-जुलती कार्रवाई नहीं मिली। किसी दूसरी कुंजी या कार्रवाई का नाम आज़माएँ। + माउस + ऐप की कार्रवाइयाँ + नेविगेशन + अक्षर + अंक + फ़ंक्शन कुंजियाँ + मॉडिफ़ायर कुंजियाँ + नंबर पैड + चिह्न + माउस की गति + सीधे खींचना छुई गई जगह के अनुसार चलता है। सापेक्ष खींचना उँगली की गति के अनुसार चलता है और गेम के माउस को बीच में लाने पर उपयोगी है। + माउस के बीच वाले बटन से दृश्य खिसकाना हमेशा उँगली की गति के अनुसार चलता है, ताकि कर्सर न उछले। + डायरेक्ट + सापेक्ष + जेस्चर की पहचान + दो या तीन उँगलियों का जेस्चर दृश्य खिसकाने या ज़ूम में बदलने से पहले इतनी दूरी तय करें। + दृश्य प्रतिक्रिया + माउस बटन का व्यवहार + %1$d मिलीसेकंड + नंबर पैड %1$s + एस्केप + बैकस्पेस + एंटर + Tab + Space + डिलीट + इंसर्ट + Home + End + पेज अप + पेज डाउन + प्रिंट स्क्रीन + ऊपर का तीर + नीचे का तीर + बायाँ तीर + दायाँ तीर + कैप्स लॉक + नम लॉक + स्क्रॉल लॉक + बायाँ %1$s + दायाँ %1$s + शिफ़्ट + diff --git a/app/src/main/res/values-hu/gestures.xml b/app/src/main/res/values-hu/gestures.xml new file mode 100644 index 000000000..359224c54 --- /dev/null +++ b/app/src/main/res/values-hu/gestures.xml @@ -0,0 +1,103 @@ + + + Gesztusok + Érintési gesztusok beállításai + Szabd testre az egér- és billentyűzetgesztusokat. Használatukhoz válaszd a Gesztusok érintési módot. + Koppintás + Húzás egy ujjal + Hosszú nyomás + Dupla koppintás (dupla bal kattintás) + Koppintás két ujjal + Húzás két ujjal + Nyomva tartás két ujjal + Koppintás három ujjal + Húzás három ujjal + Nyomva tartás három ujjal + Csippentéses nagyítás + Gomb nyomva tartása + Egyszeri kattintás + Nyomva tartás késleltetése + Dupla koppintás időköze + Az aktuális játékra érvényes + Alapbeállítások minden játékhoz + Eredeti gesztusbeállítások visszaállítása + Mégse + Művelet kiválasztása + Bal kattintás + Jobb kattintás + Középső kattintás + Billentyűzet megjelenítése + Bal kattintás és húzás + Jobb kattintás és húzás + Pásztázás középső egérgombbal + Pásztázás középső egérgombbal (fordított) + Nyílbillentyűk + Nyílbillentyűk (fordított) + WASD (fordított) + Egérgörgő + Gesztusküszöb: %1$d px + Felismert gesztus megjelenítése + Egér- és billentyűzetvezérlés gesztusok szerint + Egy ujj + Két ujj + Három ujj + Beállítások + Válaszd ki a koppintás, húzás vagy nyomva tartás műveletét. + Állítsd be a kamera pásztázását, a nagyítást és a másodlagos kattintásokat. + Tarts további gyorsparancsokat kéznél. + Finomhangold a mozgást, a felismerést és a vizuális visszajelzést. + Alapértelmezések + Ez a játék + Alapbeállítások használata + Az alapbeállítások érvényesek. A szerkesztés játékhoz tartozó beállításokat hoz létre. + Módosítások mentése + Bezárás + MŰVELET + Ehhez: %1$s + Egérműveletek vagy billentyűk keresése + Keresés törlése + Összes + Nincs megfelelő művelet. Próbálj másik billentyű- vagy műveletnevet. + Egér + Alkalmazásműveletek + Navigáció + Betűk + Számok + Funkcióbillentyűk + Módosítóbillentyűk + Numerikus billentyűzet + Szimbólumok + Egérmozgás + A közvetlen húzás az érintési helyet követi. A relatív húzás az ujj mozgását követi, ami hasznos, ha a játék középre állítja az egeret. + A középső egérgombos pásztázás mindig az ujj mozgását követi, hogy a kurzor ne ugorjon el. + Közvetlen + Relatív + Gesztusfelismerés + Ennyit kell elmozdítani az ujjakat, mielőtt a két- vagy háromujjas gesztus pásztázássá vagy nagyítássá válik. + Vizuális visszajelzés + Egérgomb működése + %1$d ms + Numerikus billentyűzet %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Fel nyíl + Le nyíl + Balra nyíl + Jobbra nyíl + Caps Lock + Num Lock + Scroll Lock + Bal %1$s + Jobb %1$s + Shift + diff --git a/app/src/main/res/values-in/gestures.xml b/app/src/main/res/values-in/gestures.xml new file mode 100644 index 000000000..67a82a4e2 --- /dev/null +++ b/app/src/main/res/values-in/gestures.xml @@ -0,0 +1,103 @@ + + + Gestur + Setelan gestur layar sentuh + Sesuaikan gestur mouse dan keyboard. Pilih Gestur sebagai mode sentuh untuk menggunakannya. + Ketuk + Seret dengan satu jari + Tekan lama + Ketuk dua kali (klik kiri dua kali) + Ketuk dengan dua jari + Seret dengan dua jari + Tahan dengan dua jari + Ketuk dengan tiga jari + Seret dengan tiga jari + Tahan dengan tiga jari + Cubit untuk memperbesar + Tetap tekan tombol + Klik sekali + Jeda sebelum menahan + Interval ketuk dua kali + Berlaku untuk game saat ini + Setelan default untuk semua game + Pulihkan setelan gestur awal + Batal + Pilih tindakan + Klik kiri + Klik kanan + Klik tengah + Tampilkan keyboard + Klik kiri dan seret + Klik kanan dan seret + Geser dengan tombol tengah mouse + Geser dengan tombol tengah mouse (terbalik) + Tombol panah + Tombol panah (terbalik) + WASD (terbalik) + Roda mouse + Ambang gestur: %1$d px + Tampilkan gestur yang dikenali + Kontrol mouse dan keyboard menurut gestur + Satu jari + Dua jari + Tiga jari + Opsi + Pilih tindakan saat mengetuk, menyeret, atau menahan. + Atur pergeseran kamera, zoom, dan klik sekunder. + Jangkau lebih banyak pintasan dengan mudah. + Sesuaikan gerakan, pengenalan, dan umpan balik visual. + Default + Game ini + Gunakan default + Menggunakan setelan default. Mengedit akan membuat setelan khusus game. + Simpan perubahan + Tutup + TINDAKAN + Untuk %1$s + Cari tindakan mouse atau tombol keyboard + Hapus pencarian + Semua + Tidak ada tindakan yang cocok. Coba nama tombol atau tindakan lain. + Mouse + Tindakan aplikasi + Navigasi + Huruf + Angka + Tombol fungsi + Tombol pengubah + Keypad numerik + Simbol + Gerakan mouse + Seret langsung mengikuti posisi sentuhan. Seret relatif mengikuti gerakan jari, berguna saat game memusatkan mouse. + Pergeseran dengan tombol tengah mouse selalu mengikuti gerakan jari agar kursor tidak melompat. + Langsung + Relatif + Pengenalan gestur + Gerakkan sejauh ini sebelum gestur dua atau tiga jari menjadi pergeseran atau zoom. + Umpan balik visual + Perilaku tombol mouse + %1$d md + Keypad numerik %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Panah atas + Panah bawah + Panah kiri + Panah kanan + Caps Lock + Num Lock + Scroll Lock + %1$s kiri + %1$s kanan + Shift + diff --git a/app/src/main/res/values-it/gestures.xml b/app/src/main/res/values-it/gestures.xml new file mode 100644 index 000000000..07475d5d7 --- /dev/null +++ b/app/src/main/res/values-it/gestures.xml @@ -0,0 +1,103 @@ + + + Gesti + Impostazioni dei gesti tattili + Personalizza i gesti di mouse e tastiera. Seleziona Gesti come modalità tattile per usarli. + Tocco + Trascinamento con un dito + Pressione prolungata + Doppio tocco (doppio clic sinistro) + Tocco con due dita + Trascinamento con due dita + Pressione con due dita + Tocco con tre dita + Trascinamento con tre dita + Pressione con tre dita + Pizzica per ingrandire + Tieni premuto il pulsante + Clic singolo + Ritardo della pressione + Intervallo del doppio tocco + Si applica al gioco attuale + Impostazioni predefinite per tutti i giochi + Ripristina le impostazioni originali dei gesti + Annulla + Scegli azione + Clic sinistro + Clic destro + Clic centrale + Mostra tastiera + Clic sinistro e trascinamento + Clic destro e trascinamento + Spostamento con il pulsante centrale + Spostamento con il pulsante centrale (invertito) + Tasti freccia + Tasti freccia (invertiti) + WASD (invertito) + Rotellina del mouse + Soglia dei gesti: %1$d px + Mostra il gesto riconosciuto + Comandi di mouse e tastiera organizzati per gesto + Un dito + Due dita + Tre dita + Opzioni + Scegli l\'azione di un tocco, trascinamento o pressione. + Configura lo spostamento della visuale, lo zoom e i clic secondari. + Tieni altre scorciatoie a portata di mano. + Regola movimento, riconoscimento e riscontro visivo. + Predefiniti + Questo gioco + Usa impostazioni predefinite + Sono in uso le impostazioni predefinite. Una modifica crea impostazioni specifiche del gioco. + Salva modifiche + Chiudi + AZIONE + Per %1$s + Cerca azioni del mouse o tasti della tastiera + Cancella ricerca + Tutte + Nessuna azione corrispondente. Prova un altro nome di tasto o azione. + Mouse + Azioni dell\'app + Navigazione + Lettere + Numeri + Tasti funzione + Tasti modificatori + Tastierino numerico + Simboli + Movimento del mouse + Il trascinamento diretto segue la posizione del tocco. Quello relativo segue il movimento del dito ed è utile quando un gioco ricentra il mouse. + Lo spostamento con il pulsante centrale segue sempre il movimento del dito per evitare salti del cursore. + Diretto + Relativo + Riconoscimento dei gesti + Percorri questa distanza prima che un gesto con due o tre dita diventi uno spostamento o uno zoom. + Riscontro visivo + Comportamento del pulsante del mouse + %1$d ms + Tastierino numerico %1$s + Esc + Backspace + Invio + Tab + Spazio + Canc + Ins + Home + Fine + Pagina su + Pagina giù + Stamp + Freccia su + Freccia giù + Freccia sinistra + Freccia destra + Bloc Maiusc + Bloc Num + Bloc Scorr + %1$s sinistro + %1$s destro + Maiusc + diff --git a/app/src/main/res/values-ja/gestures.xml b/app/src/main/res/values-ja/gestures.xml new file mode 100644 index 000000000..7568885f2 --- /dev/null +++ b/app/src/main/res/values-ja/gestures.xml @@ -0,0 +1,103 @@ + + + ジェスチャー + タッチジェスチャーの設定 + マウスとキーボードのジェスチャーを設定します。使用するにはタッチモードで「ジェスチャー」を選択してください。 + タップ + 1本指でドラッグ + 長押し + ダブルタップ(左ダブルクリック) + 2本指でタップ + 2本指でドラッグ + 2本指で長押し + 3本指でタップ + 3本指でドラッグ + 3本指で長押し + ピンチで拡大・縮小 + ボタンを押したままにする + 1回クリック + 長押しの待ち時間 + ダブルタップの間隔 + 現在のゲームに適用 + すべてのゲームの既定設定 + ジェスチャーの設定を初期状態に戻す + キャンセル + 操作を選択 + 左クリック + 右クリック + 中クリック + キーボードを表示 + 左クリックしてドラッグ + 右クリックしてドラッグ + マウスの中ボタンで視点移動 + マウスの中ボタンで視点移動(反転) + 矢印キー + 矢印キー(反転) + WASD(反転) + マウスホイール + ジェスチャーのしきい値:%1$d px + 認識したジェスチャーを表示 + ジェスチャー別のマウスとキーボード操作 + 1本指 + 2本指 + 3本指 + オプション + タップ、ドラッグ、長押しの動作を選択します。 + カメラ移動、ズーム、補助クリックを設定します。 + 追加のショートカットを手元に配置します。 + 移動、認識、視覚的フィードバックを調整します。 + デフォルト + このゲーム + 既定値を使用 + 既定設定を使用しています。編集するとゲーム固有の設定が作成されます。 + 変更を保存 + 閉じる + 操作 + %1$sの操作 + マウス操作やキーボードのキーを検索 + 検索をクリア + すべて + 一致する操作がありません。別のキー名や操作名を試してください。 + マウス + アプリの操作 + ナビゲーション + 英字 + 数字 + ファンクションキー + 修飾キー + テンキー + 記号 + マウスの移動 + 直接ドラッグはタッチ位置に従います。相対ドラッグは指の動きに従い、ゲームがマウスを中央に戻す場合に適しています。 + カーソルの飛びを防ぐため、中ボタンによる視点移動は常に指の動きに従います。 + ダイレクト + 相対 + ジェスチャー認識 + 2本指または3本指のジェスチャーが視点移動やズームになるまでに必要な移動距離です。 + 視覚的フィードバック + マウスボタンの動作 + %1$dミリ秒 + テンキー %1$s + Esc + Backspace + Enter + Tab + スペース + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + 上矢印 + 下矢印 + 左矢印 + 右矢印 + Caps Lock + Num Lock + Scroll Lock + 左%1$s + 右%1$s + Shift + diff --git a/app/src/main/res/values-ko/gestures.xml b/app/src/main/res/values-ko/gestures.xml new file mode 100644 index 000000000..5ca6d89f5 --- /dev/null +++ b/app/src/main/res/values-ko/gestures.xml @@ -0,0 +1,103 @@ + + + 제스처 + 터치 제스처 설정 + 마우스와 키보드 제스처를 맞춤 설정합니다. 사용하려면 터치 모드에서 제스처를 선택하세요. + 탭 + 한 손가락 드래그 + 길게 누르기 + 두 번 탭 (왼쪽 더블 클릭) + 두 손가락 탭 + 두 손가락 드래그 + 두 손가락 길게 누르기 + 세 손가락 탭 + 세 손가락 드래그 + 세 손가락 길게 누르기 + 손가락을 모으거나 벌려 확대/축소 + 버튼을 누른 상태로 유지 + 한 번 클릭 + 길게 누르기 대기 시간 + 두 번 탭 간격 + 현재 게임에 적용 + 모든 게임의 기본 설정 + 제스처 설정을 초기 상태로 재설정 + 취소 + 동작 선택 + 왼쪽 클릭 + 오른쪽 클릭 + 가운데 클릭 + 키보드 표시 + 왼쪽 클릭 후 드래그 + 오른쪽 클릭 후 드래그 + 마우스 가운데 버튼으로 이동 + 마우스 가운데 버튼으로 이동 (반전) + 방향키 + 방향키 (반전) + WASD (반전) + 마우스 휠 + 제스처 임계값: %1$d px + 인식된 제스처 표시 + 제스처별 마우스 및 키보드 조작 + 한 손가락 + 두 손가락 + 세 손가락 + 옵션 + 탭, 드래그, 길게 누르기의 동작을 선택하세요. + 카메라 이동, 확대/축소, 보조 클릭을 설정하세요. + 추가 단축 동작을 손쉽게 사용하세요. + 이동, 인식, 시각적 피드백을 조정하세요. + 기본값 + 이 게임 + 기본값 사용 + 기본 설정을 사용 중입니다. 수정하면 게임별 설정이 생성됩니다. + 변경 사항 저장 + 닫기 + 동작 + %1$s 동작 + 마우스 동작 또는 키보드 키 검색 + 검색 지우기 + 전체 + 일치하는 동작이 없습니다. 다른 키나 동작 이름을 검색하세요. + 마우스 + 앱 동작 + 탐색 + 문자 + 숫자 + 기능키 + 보조키 + 숫자 키패드 + 기호 + 마우스 이동 + 직접 드래그는 터치 위치를 따릅니다. 상대 드래그는 손가락 움직임을 따르며, 게임에서 마우스를 중앙으로 돌려놓을 때 유용합니다. + 커서가 튀지 않도록 가운데 버튼 이동은 항상 손가락 움직임을 따릅니다. + 직접 터치 + 상대 + 제스처 인식 + 두 손가락 또는 세 손가락 제스처가 카메라 이동이나 확대/축소로 인식되기 위한 이동 거리입니다. + 시각적 피드백 + 마우스 버튼 동작 + %1$d ms + 숫자 키패드 %1$s + Esc + 백스페이스 + 엔터 + Tab + Space + 삭제 + 삽입 + Home + End + 페이지 위로 + 페이지 아래로 + 화면 인쇄 + 위쪽 화살표 + 아래쪽 화살표 + 왼쪽 화살표 + 오른쪽 화살표 + Caps Lock + Num Lock + Scroll Lock + 왼쪽 %1$s + 오른쪽 %1$s + Shift + diff --git a/app/src/main/res/values-ms/gestures.xml b/app/src/main/res/values-ms/gestures.xml new file mode 100644 index 000000000..f4762ac34 --- /dev/null +++ b/app/src/main/res/values-ms/gestures.xml @@ -0,0 +1,103 @@ + + + Gerak isyarat + Tetapan gerak isyarat sentuhan + Sesuaikan gerak isyarat tetikus dan papan kekunci. Pilih Gerak isyarat sebagai mod sentuhan untuk menggunakannya. + Ketik + Seret dengan satu jari + Tekan lama + Ketik dua kali (klik kiri dua kali) + Ketik dengan dua jari + Seret dengan dua jari + Tahan dengan dua jari + Ketik dengan tiga jari + Seret dengan tiga jari + Tahan dengan tiga jari + Cubit untuk zum + Kekalkan butang ditekan + Klik sekali + Lengah sebelum menahan + Sela ketikan dua kali + Digunakan untuk permainan semasa + Tetapan lalai untuk semua permainan + Pulihkan tetapan gerak isyarat asal + Batal + Pilih tindakan + Klik kiri + Klik kanan + Klik tengah + Papar papan kekunci + Klik kiri dan seret + Klik kanan dan seret + Sorot dengan butang tengah tetikus + Sorot dengan butang tengah tetikus (terbalik) + Kekunci anak panah + Kekunci anak panah (terbalik) + WASD (terbalik) + Roda tetikus + Ambang gerak isyarat: %1$d px + Tunjukkan gerak isyarat yang dikenal pasti + Kawalan tetikus dan papan kekunci mengikut gerak isyarat + Satu jari + Dua jari + Tiga jari + Pilihan + Pilih tindakan apabila mengetik, menyeret atau menahan. + Tetapkan sorotan kamera, zum dan klik sekunder. + Pastikan pintasan tambahan mudah dicapai. + Laraskan pergerakan, pengecaman dan maklum balas visual. + Lalai + Permainan ini + Gunakan lalai + Menggunakan tetapan lalai. Penyuntingan mencipta tetapan khusus permainan. + Simpan perubahan + Tutup + TINDAKAN + Untuk %1$s + Cari tindakan tetikus atau kekunci papan kekunci + Kosongkan carian + Semua + Tiada tindakan sepadan. Cuba nama kekunci atau tindakan lain. + Tetikus + Tindakan aplikasi + Navigasi + Huruf + Nombor + Kekunci fungsi + Kekunci pengubah suai + Pad nombor + Simbol + Pergerakan tetikus + Seretan langsung mengikut kedudukan sentuhan. Seretan relatif mengikut pergerakan jari dan berguna apabila permainan memusatkan tetikus. + Sorotan dengan butang tengah tetikus sentiasa mengikut pergerakan jari untuk mengelakkan lompatan kursor. + Terus + Relatif + Pengecaman gerak isyarat + Gerakkan sejauh ini sebelum gerak isyarat dua atau tiga jari menjadi sorotan atau zum. + Maklum balas visual + Kelakuan butang tetikus + %1$d ms + Pad nombor %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Anak panah atas + Anak panah bawah + Anak panah kiri + Anak panah kanan + Caps Lock + Num Lock + Scroll Lock + %1$s kiri + %1$s kanan + Shift + diff --git a/app/src/main/res/values-nb/gestures.xml b/app/src/main/res/values-nb/gestures.xml new file mode 100644 index 000000000..77330e3c3 --- /dev/null +++ b/app/src/main/res/values-nb/gestures.xml @@ -0,0 +1,103 @@ + + + Bevegelser + Innstillinger for berøringsbevegelser + Tilpass muse- og tastaturbevegelser. Velg Bevegelser som berøringsmodus for å bruke dem. + Trykk + Dra med én finger + Langt trykk + Dobbelttrykk (dobbelt venstreklikk) + Trykk med to fingre + Dra med to fingre + Hold med to fingre + Trykk med tre fingre + Dra med tre fingre + Hold med tre fingre + Knip for å zoome + Hold knappen nede + Klikk én gang + Forsinkelse før hold + Intervall for dobbelttrykk + Gjelder det gjeldende spillet + Standardinnstillinger for alle spill + Gjenopprett de opprinnelige bevegelsesinnstillingene + Avbryt + Velg handling + Venstreklikk + Høyreklikk + Midtklikk + Vis tastatur + Venstreklikk og dra + Høyreklikk og dra + Panorer med midtre museknapp + Panorer med midtre museknapp (omvendt) + Piltaster + Piltaster (omvendt) + WASD (omvendt) + Musehjul + Bevegelsesterskel: %1$d px + Vis gjenkjent bevegelse + Muse- og tastaturstyring organisert etter bevegelse + Én finger + To fingre + Tre fingre + Alternativer + Velg hva et trykk, drag eller hold skal gjøre. + Still inn kamerapanorering, zoom og sekundære klikk. + Ha flere snarveier innen rekkevidde. + Juster bevegelse, gjenkjenning og visuell tilbakemelding. + Standardverdier + Dette spillet + Bruk standardinnstillinger + Standardinnstillinger brukes. Redigering oppretter egne innstillinger for spillet. + Lagre endringer + Lukk + HANDLING + For %1$s + Søk etter musehandlinger eller tastaturtaster + Tøm søket + Alle + Ingen handlinger samsvarer. Prøv et annet navn på en tast eller handling. + Mus + Apphandlinger + Navigasjon + Bokstaver + Tall + Funksjonstaster + Modifikatortaster + Numerisk tastatur + Symboler + Musebevegelse + Direkte draing følger berøringsposisjonen. Relativ draing følger fingerbevegelsen og er nyttig når et spill sentrerer musen. + Panorering med midtre museknapp følger alltid fingerbevegelsen for å unngå markørhopp. + Direkte + Relativ + Gjenkjenning av bevegelser + Beveg fingrene så langt før en bevegelse med to eller tre fingre blir til panorering eller zoom. + Visuell tilbakemelding + Museknappens funksjon + %1$d ms + Numerisk tastatur %1$s + Escape + Tilbake + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil opp + Pil ned + Pil til venstre + Pil til høyre + Caps Lock + Num Lock + Scroll Lock + Venstre %1$s + Høyre %1$s + Shift + diff --git a/app/src/main/res/values-nl/gestures.xml b/app/src/main/res/values-nl/gestures.xml new file mode 100644 index 000000000..1b225d5ad --- /dev/null +++ b/app/src/main/res/values-nl/gestures.xml @@ -0,0 +1,103 @@ + + + Gebaren + Instellingen voor aanraakgebaren + Pas muis- en toetsenbordgebaren aan. Kies Gebaren als aanraakmodus om ze te gebruiken. + Tikken + Slepen met één vinger + Lang indrukken + Dubbeltikken (dubbele linkermuisklik) + Tikken met twee vingers + Slepen met twee vingers + Vasthouden met twee vingers + Tikken met drie vingers + Slepen met drie vingers + Vasthouden met drie vingers + Knijpen om te zoomen + Knop ingedrukt houden + Eenmaal klikken + Wachttijd voor vasthouden + Interval voor dubbeltikken + Geldt voor het huidige spel + Standaardinstellingen voor alle spellen + Oorspronkelijke gebareninstellingen herstellen + Annuleren + Actie kiezen + Linkermuisklik + Rechtermuisklik + Middelste muisklik + Toetsenbord tonen + Linkermuisknop en slepen + Rechtermuisknop en slepen + Pannen met de middelste muisknop + Pannen met de middelste muisknop (omgekeerd) + Pijltoetsen + Pijltoetsen (omgekeerd) + WASD (omgekeerd) + Muiswiel + Gebaardrempel: %1$d px + Herkend gebaar tonen + Muis- en toetsenbordbediening per gebaar + Eén vinger + Twee vingers + Drie vingers + Opties + Kies wat tikken, slepen of vasthouden doet. + Stel camerabewegingen, zoomen en secundaire klikken in. + Houd extra sneltoetsen binnen handbereik. + Pas beweging, herkenning en visuele feedback aan. + Standaardinstellingen + Dit spel + Standaardinstellingen gebruiken + Standaardinstellingen zijn actief. Bewerken maakt instellingen voor dit spel. + Wijzigingen opslaan + Sluiten + ACTIE + Voor %1$s + Zoek muisacties of toetsen + Zoekopdracht wissen + Alle + Geen overeenkomende acties. Probeer een andere toets- of actienaam. + Muis + App-acties + Navigatie + Letters + Cijfers + Functietoetsen + Modificatietoetsen + Numeriek toetsenblok + Symbolen + Muisbeweging + Direct slepen volgt de aanraakpositie. Relatief slepen volgt de vingerbeweging en is handig als een spel de muis opnieuw centreert. + Pannen met de middelste muisknop volgt altijd de vingerbeweging om cursorsprongen te voorkomen. + Direct + Relatief + Gebarenherkenning + Beweeg deze afstand voordat een gebaar met twee of drie vingers overgaat in pannen of zoomen. + Visuele feedback + Gedrag van de muisknop + %1$d ms + Numeriek toetsenblok %1$s + Escape + Backspace + Enter + Tab + Spatie + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pijl omhoog + Pijl omlaag + Pijl links + Pijl rechts + Caps Lock + Num Lock + Scroll Lock + Linker %1$s + Rechter %1$s + Shift + diff --git a/app/src/main/res/values-pl/gestures.xml b/app/src/main/res/values-pl/gestures.xml new file mode 100644 index 000000000..9ff5f867b --- /dev/null +++ b/app/src/main/res/values-pl/gestures.xml @@ -0,0 +1,103 @@ + + + Gesty + Ustawienia gestów dotykowych + Dostosuj gesty myszy i klawiatury. Aby ich używać, wybierz Gesty jako tryb dotyku. + Stuknięcie + Przeciąganie jednym palcem + Długie naciśnięcie + Podwójne stuknięcie (podwójny lewy klik) + Stuknięcie dwoma palcami + Przeciąganie dwoma palcami + Przytrzymanie dwoma palcami + Stuknięcie trzema palcami + Przeciąganie trzema palcami + Przytrzymanie trzema palcami + Uszczypnij, aby przybliżyć + Trzymaj przycisk wciśnięty + Kliknij raz + Czas przytrzymania + Odstęp podwójnego stuknięcia + Dotyczy bieżącej gry + Ustawienia domyślne dla wszystkich gier + Przywróć pierwotne ustawienia gestów + Anuluj + Wybierz akcję + Lewy klik + Prawy klik + Środkowy klik + Pokaż klawiaturę + Lewy klik i przeciąganie + Prawy klik i przeciąganie + Przesuwanie środkowym przyciskiem myszy + Przesuwanie środkowym przyciskiem myszy (odwrócone) + Klawisze strzałek + Klawisze strzałek (odwrócone) + WASD (odwrócone) + Kółko myszy + Próg gestów: %1$d px + Pokaż rozpoznany gest + Sterowanie myszą i klawiaturą pogrupowane według gestów + Jeden palec + Dwa palce + Trzy palce + Opcje + Wybierz działanie stuknięcia, przeciągania lub przytrzymania. + Skonfiguruj przesuwanie kamery, przybliżanie i kliknięcia pomocnicze. + Miej dodatkowe skróty pod ręką. + Dostosuj ruch, rozpoznawanie i wizualne potwierdzenia. + Domyślne + Ta gra + Użyj domyślnych + Używane są ustawienia domyślne. Edycja tworzy ustawienia dla tej gry. + Zapisz zmiany + Zamknij + AKCJA + Dla %1$s + Szukaj akcji myszy lub klawiszy klawiatury + Wyczyść wyszukiwanie + Wszystkie + Brak pasujących akcji. Spróbuj innej nazwy klawisza lub akcji. + Mysz + Akcje aplikacji + Nawigacja + Litery + Cyfry + Klawisze funkcyjne + Klawisze modyfikujące + Klawiatura numeryczna + Symbole + Ruch myszy + Przeciąganie bezpośrednie podąża za pozycją dotyku. Względne podąża za ruchem palca i przydaje się, gdy gra centruje mysz. + Przesuwanie środkowym przyciskiem myszy zawsze podąża za ruchem palca, aby uniknąć skoków kursora. + Bezpośredni + Względny + Rozpoznawanie gestów + Przesuń palce o tę odległość, zanim gest dwoma lub trzema palcami stanie się przesuwaniem lub przybliżaniem. + Wizualne potwierdzenia + Działanie przycisku myszy + %1$d ms + Klawiatura numeryczna %1$s + Escape + Backspace + Enter + Tab + Spacja + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Strzałka w górę + Strzałka w dół + Strzałka w lewo + Strzałka w prawo + Caps Lock + Num Lock + Scroll Lock + Lewy %1$s + Prawy %1$s + Shift + diff --git a/app/src/main/res/values-pt-rPT/gestures.xml b/app/src/main/res/values-pt-rPT/gestures.xml new file mode 100644 index 000000000..610ebc414 --- /dev/null +++ b/app/src/main/res/values-pt-rPT/gestures.xml @@ -0,0 +1,103 @@ + + + Gestos + Configurações de gestos de toque + Personalize os gestos de rato e teclado. Selecione Gestos como modo de toque para usá-los. + Toque + Arrastar com um dedo + Toque longo + Toque duplo (clique duplo esquerdo) + Toque com dois dedos + Arrastar com dois dedos + Manter premido com dois dedos + Toque com três dedos + Arrastar com três dedos + Manter premido com três dedos + Pinçar para ampliar + Manter o botão premido + Clicar uma vez + Tempo para manter premido + Intervalo de toque duplo + Aplica-se ao jogo atual + Configurações padrão para todos os jogos + Restaurar as configurações originais de gestos + Cancelar + Escolher ação + Clique esquerdo + Clique direito + Clique do meio + Mostrar teclado + Clique esquerdo e arrastar + Clique direito e arrastar + Mover com o botão do meio + Mover com o botão do meio (invertido) + Teclas de direção + Teclas de direção (invertidas) + WASD (invertido) + Roda do rato + Limiar dos gestos: %1$d px + Mostrar gesto reconhecido + Controlos de rato e teclado organizados por gesto + Um dedo + Dois dedos + Três dedos + Opções + Escolha o que um toque, arraste ou toque mantido deve fazer. + Configure o movimento da câmera, o zoom e os cliques secundários. + Mantenha mais atalhos ao alcance dos dedos. + Ajuste o movimento, o reconhecimento e a resposta visual. + Valores padrão + Este jogo + Usar padrões + Usando as configurações padrão. A edição cria configurações específicas do jogo. + Salvar alterações + Fechar + AÇÃO + Para %1$s + Pesquisar ações de rato ou teclas do teclado + Limpar pesquisa + Todas + Nenhuma ação correspondente. Tente outro nome de tecla ou ação. + Rato + Ações da aplicação + Navegação + Letras + Números + Teclas de função + Teclas modificadoras + Teclado numérico + Símbolos + Movimento do rato + O arraste direto segue a posição do toque. O relativo segue o movimento do dedo e é útil quando um jogo centra o rato. + O movimento com o botão do meio sempre segue o movimento do dedo para evitar saltos do cursor. + Direto + Relativo + Reconhecimento de gestos + Percorra esta distância antes que um gesto de dois ou três dedos se torne movimento de câmera ou zoom. + Resposta visual + Comportamento do botão do rato + %1$d ms + Teclado numérico %1$s + Esc + Backspace + Enter + Tab + Espaço + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Seta para cima + Seta para baixo + Seta para a esquerda + Seta para a direita + Caps Lock + Num Lock + Scroll Lock + %1$s esquerdo + %1$s direito + Shift + diff --git a/app/src/main/res/values-pt/gestures.xml b/app/src/main/res/values-pt/gestures.xml new file mode 100644 index 000000000..545700a31 --- /dev/null +++ b/app/src/main/res/values-pt/gestures.xml @@ -0,0 +1,103 @@ + + + Gestos + Configurações de gestos de toque + Personalize os gestos de mouse e teclado. Selecione Gestos como modo de toque para usá-los. + Toque + Arrastar com um dedo + Toque longo + Toque duplo (clique duplo esquerdo) + Toque com dois dedos + Arrastar com dois dedos + Manter pressionado com dois dedos + Toque com três dedos + Arrastar com três dedos + Manter pressionado com três dedos + Pinçar para ampliar + Manter o botão pressionado + Clicar uma vez + Tempo para manter pressionado + Intervalo de toque duplo + Aplica-se ao jogo atual + Configurações padrão para todos os jogos + Restaurar as configurações originais de gestos + Cancelar + Escolher ação + Clique esquerdo + Clique direito + Clique do meio + Mostrar teclado + Clique esquerdo e arrastar + Clique direito e arrastar + Mover com o botão do meio + Mover com o botão do meio (invertido) + Teclas de direção + Teclas de direção (invertidas) + WASD (invertido) + Roda do mouse + Limiar dos gestos: %1$d px + Mostrar gesto reconhecido + Controles de mouse e teclado organizados por gesto + Um dedo + Dois dedos + Três dedos + Opções + Escolha o que um toque, arraste ou toque mantido deve fazer. + Configure o movimento da câmera, o zoom e os cliques secundários. + Mantenha mais atalhos ao alcance dos dedos. + Ajuste o movimento, o reconhecimento e a resposta visual. + Padrões + Este jogo + Usar padrões + Usando as configurações padrão. A edição cria configurações específicas do jogo. + Salvar alterações + Fechar + AÇÃO + Para %1$s + Pesquisar ações de mouse ou teclas do teclado + Limpar pesquisa + Todos + Nenhuma ação correspondente. Tente outro nome de tecla ou ação. + Mouse + Ações do aplicativo + Navegação + Letras + Números + Teclas de função + Teclas modificadoras + Teclado numérico + Símbolos + Movimento do mouse + O arraste direto segue a posição do toque. O relativo segue o movimento do dedo e é útil quando um jogo centraliza o mouse. + O movimento com o botão do meio sempre segue o movimento do dedo para evitar saltos do cursor. + Direto + Relativo + Reconhecimento de gestos + Percorra esta distância antes que um gesto de dois ou três dedos se torne movimento de câmera ou zoom. + Resposta visual + Comportamento do botão do mouse + %1$d ms + Teclado numérico %1$s + Esc + Backspace + Enter + Tab + Espaço + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Seta para cima + Seta para baixo + Seta para a esquerda + Seta para a direita + Caps Lock + Num Lock + Scroll Lock + %1$s esquerdo + %1$s direito + Shift + diff --git a/app/src/main/res/values-ro/gestures.xml b/app/src/main/res/values-ro/gestures.xml new file mode 100644 index 000000000..6b7ab9bde --- /dev/null +++ b/app/src/main/res/values-ro/gestures.xml @@ -0,0 +1,103 @@ + + + Gesturi + Setări pentru gesturile tactile + Personalizează gesturile de mouse și tastatură. Selectează Gesturi ca mod tactil pentru a le folosi. + Atingere + Tragere cu un deget + Apăsare lungă + Atingere dublă (dublu clic stânga) + Atingere cu două degete + Tragere cu două degete + Menținere cu două degete + Atingere cu trei degete + Tragere cu trei degete + Menținere cu trei degete + Apropie sau depărtează degetele pentru zoom + Ține butonul apăsat + Un singur clic + Durata până la menținere + Intervalul atingerii duble + Se aplică jocului curent + Setări implicite pentru toate jocurile + Restabilește setările originale ale gesturilor + Anulează + Alege acțiunea + Clic stânga + Clic dreapta + Clic mijloc + Arată tastatura + Clic stânga și tragere + Clic dreapta și tragere + Deplasare cu butonul din mijloc + Deplasare cu butonul din mijloc (inversată) + Taste săgeată + Taste săgeată (inversate) + WASD (inversat) + Rotița mouse-ului + Pragul gesturilor: %1$d px + Arată gestul recunoscut + Comenzi de mouse și tastatură organizate după gest + Un deget + Două degete + Trei degete + Opțiuni + Alege acțiunea unei atingeri, trageri sau mențineri. + Configurează deplasarea camerei, zoomul și clicurile secundare. + Păstrează scurtături suplimentare la îndemână. + Ajustează mișcarea, recunoașterea și feedbackul vizual. + Valori implicite + Acest joc + Folosește valorile implicite + Se folosesc setările implicite. Editarea creează setări specifice jocului. + Salvează modificările + Închide + ACȚIUNE + Pentru %1$s + Caută acțiuni de mouse sau taste + Șterge căutarea + Toate + Nicio acțiune potrivită. Încearcă alt nume de tastă sau acțiune. + Mouse + Acțiuni ale aplicației + Navigare + Litere + Cifre + Taste funcționale + Taste modificatoare + Tastatură numerică + Simboluri + Mișcarea mouse-ului + Tragerea directă urmărește poziția atingerii. Cea relativă urmărește mișcarea degetului și este utilă când jocul recentrează mouse-ul. + Deplasarea cu butonul din mijloc urmărește întotdeauna mișcarea degetului pentru a evita salturile cursorului. + Direct + Relativă + Recunoașterea gesturilor + Parcurge această distanță înainte ca un gest cu două sau trei degete să devină deplasare sau zoom. + Feedback vizual + Comportamentul butonului de mouse + %1$d ms + Tastatură numerică %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Săgeată sus + Săgeată jos + Săgeată stânga + Săgeată dreapta + Caps Lock + Num Lock + Scroll Lock + %1$s stânga + %1$s dreapta + Shift + diff --git a/app/src/main/res/values-ru/gestures.xml b/app/src/main/res/values-ru/gestures.xml new file mode 100644 index 000000000..631b1fcfe --- /dev/null +++ b/app/src/main/res/values-ru/gestures.xml @@ -0,0 +1,103 @@ + + + Жесты + Настройки сенсорных жестов + Настройте жесты мыши и клавиатуры. Чтобы использовать их, выберите режим касаний «Жесты». + Касание + Перетаскивание одним пальцем + Долгое нажатие + Двойное касание (двойной щелчок левой кнопкой) + Касание двумя пальцами + Перетаскивание двумя пальцами + Удержание двумя пальцами + Касание тремя пальцами + Перетаскивание тремя пальцами + Удержание тремя пальцами + Масштабирование щипком + Удерживать кнопку нажатой + Один щелчок + Задержка удержания + Интервал двойного касания + Применяется к текущей игре + Настройки по умолчанию для всех игр + Восстановить исходные настройки жестов + Отмена + Выберите действие + Левая кнопка мыши + Правая кнопка мыши + Средняя кнопка мыши + Показать клавиатуру + Перетаскивание левой кнопкой + Перетаскивание правой кнопкой + Панорамирование средней кнопкой + Панорамирование средней кнопкой (обратное) + Клавиши со стрелками + Клавиши со стрелками (обратные) + WASD (обратное) + Колесо мыши + Порог жестов: %1$d пикс. + Показывать распознанный жест + Управление мышью и клавиатурой по жестам + Один палец + Два пальца + Три пальца + Параметры + Выберите действие для касания, перетаскивания или удержания. + Настройте перемещение камеры, масштабирование и дополнительные щелчки. + Держите дополнительные команды под рукой. + Настройте движение, распознавание и визуальный отклик. + По умолчанию + Эта игра + Использовать настройки по умолчанию + Используются настройки по умолчанию. Изменение создаст настройки для этой игры. + Сохранить изменения + Закрыть + ДЕЙСТВИЕ + Для %1$s + Поиск действий мыши или клавиш клавиатуры + Очистить поиск + Все + Подходящих действий нет. Попробуйте другое название клавиши или действия. + Мышь + Действия приложения + Навигация + Буквы + Цифры + Функциональные клавиши + Клавиши-модификаторы + Цифровой блок + Символы + Движение мыши + Прямое перетаскивание следует позиции касания. Относительное следует движению пальца и полезно, когда игра возвращает мышь в центр. + Панорамирование средней кнопкой всегда следует движению пальца, чтобы избежать скачков курсора. + Прямой + Относительное + Распознавание жестов + Пройдите это расстояние, прежде чем жест двумя или тремя пальцами станет перемещением камеры или масштабированием. + Визуальный отклик + Поведение кнопки мыши + %1$d мс + Цифровой блок: %1$s + Esc + Backspace + Enter + Tab + Пробел + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрелка вверх + Стрелка вниз + Стрелка влево + Стрелка вправо + Caps Lock + Num Lock + Scroll Lock + Левый %1$s + Правый %1$s + Shift + diff --git a/app/src/main/res/values-sv/gestures.xml b/app/src/main/res/values-sv/gestures.xml new file mode 100644 index 000000000..a7475e820 --- /dev/null +++ b/app/src/main/res/values-sv/gestures.xml @@ -0,0 +1,103 @@ + + + Gester + Inställningar för pekgester + Anpassa mus- och tangentbordsgester. Välj Gester som pekläge för att använda dem. + Tryck + Dra med ett finger + Långt tryck + Dubbeltryck (dubbelt vänsterklick) + Tryck med två fingrar + Dra med två fingrar + Håll med två fingrar + Tryck med tre fingrar + Dra med tre fingrar + Håll med tre fingrar + Nyp för att zooma + Håll knappen nedtryckt + Klicka en gång + Fördröjning före hållning + Intervall för dubbeltryck + Gäller det aktuella spelet + Standardinställningar för alla spel + Återställ de ursprungliga gestinställningarna + Avbryt + Välj åtgärd + Vänsterklick + Högerklick + Mittenklick + Visa tangentbord + Vänsterklicka och dra + Högerklicka och dra + Panorera med musens mittknapp + Panorera med musens mittknapp (omvänt) + Piltangenter + Piltangenter (omvänt) + WASD (omvänt) + Mushjul + Gesttröskel: %1$d px + Visa identifierad gest + Mus- och tangentbordsstyrning ordnad efter gest + Ett finger + Två fingrar + Tre fingrar + Alternativ + Välj vad ett tryck, drag eller håll ska göra. + Ställ in kamerapanorering, zoom och sekundära klick. + Ha fler genvägar inom räckhåll. + Justera rörelse, identifiering och visuell återkoppling. + Standardvärden + Det här spelet + Använd standardinställningar + Standardinställningar används. Redigering skapar inställningar för spelet. + Spara ändringar + Stäng + ÅTGÄRD + För %1$s + Sök efter musåtgärder eller tangenter + Rensa sökning + Alla + Inga matchande åtgärder. Prova ett annat tangent- eller åtgärdsnamn. + Mus + Appåtgärder + Navigering + Bokstäver + Siffror + Funktionstangenter + Modifierartangenter + Numeriskt tangentbord + Symboler + Musrörelse + Direkta drag följer pekpositionen. Relativa drag följer fingerrörelsen och är användbara när ett spel centrerar musen. + Panorering med musens mittknapp följer alltid fingerrörelsen för att undvika att pekaren hoppar. + Direkt + Relativ + Gestidentifiering + Flytta fingrarna så här långt innan en gest med två eller tre fingrar blir panorering eller zoom. + Visuell återkoppling + Musknappens beteende + %1$d ms + Numeriskt tangentbord %1$s + Escape + Backsteg + Retur + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Pil upp + Pil ned + Pil vänster + Pil höger + Caps Lock + Num Lock + Scroll Lock + Vänster %1$s + Höger %1$s + Skift + diff --git a/app/src/main/res/values-th/gestures.xml b/app/src/main/res/values-th/gestures.xml new file mode 100644 index 000000000..f625c607b --- /dev/null +++ b/app/src/main/res/values-th/gestures.xml @@ -0,0 +1,103 @@ + + + ท่าทาง + การตั้งค่าท่าทางสัมผัส + ปรับแต่งท่าทางเมาส์และแป้นพิมพ์ เลือกท่าทางเป็นโหมดสัมผัสเพื่อใช้งาน + แตะ + ลากด้วยหนึ่งนิ้ว + กดค้าง + แตะสองครั้ง (ดับเบิลคลิกซ้าย) + แตะด้วยสองนิ้ว + ลากด้วยสองนิ้ว + กดค้างด้วยสองนิ้ว + แตะด้วยสามนิ้ว + ลากด้วยสามนิ้ว + กดค้างด้วยสามนิ้ว + จีบนิ้วเพื่อซูม + กดปุ่มค้างไว้ + คลิกหนึ่งครั้ง + ระยะเวลาก่อนกดค้าง + ช่วงเวลาแตะสองครั้ง + ใช้กับเกมปัจจุบัน + การตั้งค่าเริ่มต้นสำหรับทุกเกม + คืนค่าการตั้งค่าท่าทางเดิม + ยกเลิก + เลือกการกระทำ + คลิกซ้าย + คลิกขวา + คลิกกลาง + แสดงแป้นพิมพ์ + คลิกซ้ายแล้วลาก + คลิกขวาแล้วลาก + เลื่อนมุมมองด้วยปุ่มเมาส์กลาง + เลื่อนมุมมองด้วยปุ่มเมาส์กลาง (กลับด้าน) + ปุ่มลูกศร + ปุ่มลูกศร (กลับด้าน) + WASD (กลับด้าน) + ล้อเมาส์ + เกณฑ์ท่าทาง: %1$d พิกเซล + แสดงท่าทางที่ตรวจพบ + การควบคุมเมาส์และแป้นพิมพ์แยกตามท่าทาง + หนึ่งนิ้ว + สองนิ้ว + สามนิ้ว + ตัวเลือก + เลือกการทำงานเมื่อแตะ ลาก หรือกดค้าง + ตั้งค่าการเลื่อนกล้อง การซูม และการคลิกรอง + เข้าถึงทางลัดเพิ่มเติมได้ง่าย + ปรับการเคลื่อนไหว การตรวจจับ และการตอบสนองทางภาพ + ค่าเริ่มต้น + เกมนี้ + ใช้ค่าเริ่มต้น + กำลังใช้การตั้งค่าเริ่มต้น การแก้ไขจะสร้างการตั้งค่าเฉพาะเกม + บันทึกการเปลี่ยนแปลง + ปิด + การกระทำ + สำหรับ %1$s + ค้นหาการกระทำของเมาส์หรือปุ่มแป้นพิมพ์ + ล้างการค้นหา + ทั้งหมด + ไม่พบการกระทำที่ตรงกัน ลองชื่อปุ่มหรือการกระทำอื่น + เมาส์ + การกระทำของแอป + การนำทาง + ตัวอักษร + ตัวเลข + ปุ่มฟังก์ชัน + ปุ่มปรับเปลี่ยน + แป้นตัวเลข + สัญลักษณ์ + การเคลื่อนที่ของเมาส์ + การลากแบบตรงจะตามตำแหน่งที่สัมผัส ส่วนการลากแบบสัมพัทธ์จะตามการเคลื่อนนิ้ว เหมาะเมื่อเกมนำเมาส์กลับไปตรงกลาง + การเลื่อนมุมมองด้วยปุ่มเมาส์กลางจะตามการเคลื่อนนิ้วเสมอเพื่อไม่ให้เคอร์เซอร์กระโดด + โดยตรง + สัมพัทธ์ + การตรวจจับท่าทาง + เคลื่อนนิ้วตามระยะนี้ก่อนที่ท่าทางสองหรือสามนิ้วจะกลายเป็นการเลื่อนมุมมองหรือซูม + การตอบสนองทางภาพ + การทำงานของปุ่มเมาส์ + %1$d มิลลิวินาที + แป้นตัวเลข %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + ลูกศรขึ้น + ลูกศรลง + ลูกศรซ้าย + ลูกศรขวา + Caps Lock + Num Lock + Scroll Lock + %1$s ซ้าย + %1$s ขวา + Shift + diff --git a/app/src/main/res/values-tr/gestures.xml b/app/src/main/res/values-tr/gestures.xml new file mode 100644 index 000000000..53cc3ffb2 --- /dev/null +++ b/app/src/main/res/values-tr/gestures.xml @@ -0,0 +1,103 @@ + + + Hareketler + Dokunmatik hareket ayarları + Fare ve klavye hareketlerini özelleştirin. Kullanmak için dokunma modu olarak Hareketler\'i seçin. + Dokunma + Tek parmakla sürükleme + Uzun basma + Çift dokunma (çift sol tıklama) + İki parmakla dokunma + İki parmakla sürükleme + İki parmakla basılı tutma + Üç parmakla dokunma + Üç parmakla sürükleme + Üç parmakla basılı tutma + İki parmakla yakınlaştırma + Düğmeyi basılı tut + Bir kez tıkla + Basılı tutma gecikmesi + Çift dokunma aralığı + Geçerli oyuna uygulanır + Tüm oyunlar için varsayılan ayarlar + İlk hareket ayarlarına sıfırla + İptal + Eylem seç + Sol tıklama + Sağ tıklama + Orta tıklama + Klavyeyi göster + Sol tıklayıp sürükleme + Sağ tıklayıp sürükleme + Orta fare düğmesiyle kaydırma + Orta fare düğmesiyle kaydırma (ters) + Ok tuşları + Ok tuşları (ters) + WASD (ters) + Fare tekerleği + Hareket eşiği: %1$d piksel + Algılanan hareketi göster + Hareketlere göre düzenlenmiş fare ve klavye kontrolleri + Tek parmak + İki parmak + Üç parmak + Seçenekler + Dokunma, sürükleme veya basılı tutmanın ne yapacağını seçin. + Kamera kaydırmasını, yakınlaştırmayı ve ikincil tıklamaları ayarlayın. + Ek kısayolları elinizin altında tutun. + Hareketi, algılamayı ve görsel geri bildirimi ayarlayın. + Varsayılanlar + Bu oyun + Varsayılanları kullan + Varsayılan ayarlar kullanılıyor. Düzenleme, oyuna özel ayarlar oluşturur. + Değişiklikleri kaydet + Kapat + EYLEM + %1$s için + Fare eylemlerini veya klavye tuşlarını ara + Aramayı temizle + Tümü + Eşleşen eylem yok. Başka bir tuş veya eylem adı deneyin. + Fare + Uygulama eylemleri + Gezinme + Harfler + Sayılar + İşlev tuşları + Değiştirici tuşlar + Sayısal tuş takımı + Semboller + Fare hareketi + Doğrudan sürükleme dokunma konumunu izler. Göreli sürükleme parmak hareketini izler ve oyun fareyi merkeze aldığında kullanışlıdır. + Orta fare düğmesiyle kaydırma, imlecin sıçramasını önlemek için her zaman parmak hareketini izler. + Doğrudan + Göreli + Hareket algılama + İki veya üç parmak hareketinin kaydırma ya da yakınlaştırmaya dönüşmesi için bu mesafeyi aşın. + Görsel geri bildirim + Fare düğmesi davranışı + %1$d ms + Sayısal tuş takımı %1$s + Escape + Geri silme + Enter + Tab + Boşluk + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Yukarı ok + Aşağı ok + Sol ok + Sağ ok + Caps Lock + Num Lock + Scroll Lock + Sol %1$s + Sağ %1$s + Üst Karakter + diff --git a/app/src/main/res/values-uk/gestures.xml b/app/src/main/res/values-uk/gestures.xml new file mode 100644 index 000000000..b5d4e1f41 --- /dev/null +++ b/app/src/main/res/values-uk/gestures.xml @@ -0,0 +1,103 @@ + + + Жести + Налаштування сенсорних жестів + Налаштуйте жести миші та клавіатури. Щоб використовувати їх, виберіть режим дотику «Жести». + Дотик + Перетягування одним пальцем + Тривале натискання + Подвійний дотик (подвійне клацання лівою кнопкою) + Дотик двома пальцями + Перетягування двома пальцями + Утримання двома пальцями + Дотик трьома пальцями + Перетягування трьома пальцями + Утримання трьома пальцями + Масштабування щипком + Утримувати кнопку натиснутою + Одне клацання + Затримка утримання + Інтервал подвійного дотику + Застосовується до поточної гри + Типові налаштування для всіх ігор + Відновити початкові налаштування жестів + Скасувати + Виберіть дію + Ліва кнопка миші + Права кнопка миші + Середня кнопка миші + Показати клавіатуру + Перетягування лівою кнопкою + Перетягування правою кнопкою + Переміщення середньою кнопкою миші + Переміщення середньою кнопкою миші (зворотне) + Клавіші зі стрілками + Клавіші зі стрілками (зворотні) + WASD (зворотне) + Коліщатко миші + Поріг жестів: %1$d пікс. + Показувати розпізнаний жест + Керування мишею та клавіатурою за жестами + Один палець + Два пальці + Три пальці + Параметри + Виберіть дію для дотику, перетягування чи утримання. + Налаштуйте переміщення камери, масштабування та додаткові клацання. + Тримайте додаткові команди під рукою. + Налаштуйте рух, розпізнавання та візуальний відгук. + Стандартні + Ця гра + Використовувати типові + Використовуються типові налаштування. Зміна створить налаштування для цієї гри. + Зберегти зміни + Закрити + ДІЯ + Для %1$s + Пошук дій миші або клавіш клавіатури + Очистити пошук + Усі + Відповідних дій немає. Спробуйте іншу назву клавіші або дії. + Миша + Дії застосунку + Навігація + Літери + Цифри + Функціональні клавіші + Клавіші-модифікатори + Цифровий блок + Символи + Рух миші + Пряме перетягування відповідає положенню дотику. Відносне відповідає руху пальця й корисне, коли гра повертає мишу до центру. + Переміщення середньою кнопкою завжди відповідає руху пальця, щоб уникнути стрибків курсора. + Пряме + Відносний + Розпізнавання жестів + Подолайте цю відстань, перш ніж жест двома чи трьома пальцями стане переміщенням камери або масштабуванням. + Візуальний відгук + Поведінка кнопки миші + %1$d мс + Цифровий блок: %1$s + Esc + Backspace + Enter + Tab + Пробіл + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Стрілка вгору + Стрілка вниз + Стрілка ліворуч + Стрілка праворуч + Caps Lock + Num Lock + Scroll Lock + Лівий %1$s + Правий %1$s + Shift + diff --git a/app/src/main/res/values-vi/gestures.xml b/app/src/main/res/values-vi/gestures.xml new file mode 100644 index 000000000..cf6f95eef --- /dev/null +++ b/app/src/main/res/values-vi/gestures.xml @@ -0,0 +1,103 @@ + + + Cử chỉ + Cài đặt cử chỉ cảm ứng + Tùy chỉnh cử chỉ chuột và bàn phím. Chọn Cử chỉ làm chế độ cảm ứng để sử dụng. + Chạm + Kéo bằng một ngón tay + Nhấn giữ + Chạm hai lần (nhấp đúp chuột trái) + Chạm bằng hai ngón tay + Kéo bằng hai ngón tay + Giữ bằng hai ngón tay + Chạm bằng ba ngón tay + Kéo bằng ba ngón tay + Giữ bằng ba ngón tay + Chụm hoặc mở hai ngón tay để thu phóng + Giữ nút được nhấn + Nhấp một lần + Độ trễ nhấn giữ + Khoảng thời gian chạm hai lần + Áp dụng cho trò chơi hiện tại + Cài đặt mặc định cho mọi trò chơi + Khôi phục cài đặt cử chỉ ban đầu + Hủy + Chọn thao tác + Nhấp chuột trái + Nhấp chuột phải + Nhấp chuột giữa + Hiện bàn phím + Nhấp chuột trái và kéo + Nhấp chuột phải và kéo + Di chuyển bằng nút chuột giữa + Di chuyển bằng nút chuột giữa (đảo ngược) + Phím mũi tên + Phím mũi tên (đảo ngược) + WASD (đảo ngược) + Con lăn chuột + Ngưỡng cử chỉ: %1$d px + Hiện cử chỉ đã nhận dạng + Điều khiển chuột và bàn phím theo từng cử chỉ + Một ngón tay + Hai ngón tay + Ba ngón tay + Tùy chọn + Chọn thao tác cho chạm, kéo hoặc giữ. + Cấu hình di chuyển camera, thu phóng và nhấp phụ. + Giữ các phím tắt bổ sung trong tầm tay. + Tinh chỉnh chuyển động, nhận dạng và phản hồi hình ảnh. + Mặc định + Trò chơi này + Dùng mặc định + Đang dùng cài đặt mặc định. Chỉnh sửa sẽ tạo cài đặt riêng cho trò chơi. + Lưu thay đổi + Đóng + THAO TÁC + Cho %1$s + Tìm thao tác chuột hoặc phím bàn phím + Xóa tìm kiếm + Tất cả + Không có thao tác phù hợp. Thử tên phím hoặc thao tác khác. + Chuột + Thao tác ứng dụng + Điều hướng + Chữ cái + Chữ số + Phím chức năng + Phím bổ trợ + Bàn phím số + Ký hiệu + Chuyển động chuột + Kéo trực tiếp theo vị trí chạm. Kéo tương đối theo chuyển động ngón tay, hữu ích khi trò chơi đưa chuột về giữa. + Di chuyển bằng nút chuột giữa luôn theo chuyển động ngón tay để tránh con trỏ nhảy vị trí. + Trực tiếp + Tương đối + Nhận dạng cử chỉ + Di chuyển khoảng cách này trước khi cử chỉ hai hoặc ba ngón tay trở thành di chuyển camera hoặc thu phóng. + Phản hồi hình ảnh + Cách hoạt động của nút chuột + %1$d ms + Bàn phím số %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Mũi tên lên + Mũi tên xuống + Mũi tên trái + Mũi tên phải + Caps Lock + Num Lock + Scroll Lock + %1$s trái + %1$s phải + Shift + diff --git a/app/src/main/res/values-zh-rCN/gestures.xml b/app/src/main/res/values-zh-rCN/gestures.xml new file mode 100644 index 000000000..c3b12946c --- /dev/null +++ b/app/src/main/res/values-zh-rCN/gestures.xml @@ -0,0 +1,103 @@ + + + 手势 + 触屏手势设置 + 自定义鼠标和键盘手势。请选择“手势”触控模式以使用这些功能。 + 点按 + 单指拖动 + 长按 + 双击(鼠标左键双击) + 双指点按 + 双指拖动 + 双指按住 + 三指点按 + 三指拖动 + 三指按住 + 双指缩放 + 持续按住按钮 + 单击一次 + 按住延迟 + 双击间隔 + 应用于当前游戏 + 所有游戏的默认设置 + 恢复初始手势设置 + 取消 + 选择操作 + 左键单击 + 右键单击 + 中键单击 + 显示键盘 + 按住左键拖动 + 按住右键拖动 + 鼠标中键平移 + 鼠标中键平移(反向) + 方向键 + 方向键(反向) + WASD(反向) + 鼠标滚轮 + 手势阈值:%1$d 像素 + 显示识别到的手势 + 按手势分类的鼠标和键盘控制 + 单指 + 双指 + 三指 + 选项 + 选择点按、拖动或按住时执行的操作。 + 配置镜头平移、缩放和辅助单击。 + 随手使用更多快捷操作。 + 调整移动、识别和视觉反馈。 + 默认值 + 此游戏 + 使用默认设置 + 正在使用默认设置。修改后会创建此游戏的专用设置。 + 保存更改 + 关闭 + 操作 + 用于%1$s + 搜索鼠标操作或键盘按键 + 清除搜索 + 全部 + 没有匹配的操作。请尝试其他按键或操作名称。 + 鼠标 + 应用操作 + 导航 + 字母 + 数字 + 功能键 + 修饰键 + 数字小键盘 + 符号 + 鼠标移动 + 直接拖动跟随触摸位置。相对拖动跟随手指移动,适用于会将鼠标重置到中心的游戏。 + 鼠标中键平移始终跟随手指移动,以避免光标跳动。 + 直接 + 相对 + 手势识别 + 双指或三指移动达到此距离后,才会识别为平移或缩放。 + 视觉反馈 + 鼠标按钮行为 + %1$d 毫秒 + 数字小键盘 %1$s + Esc + 退格 + 回车 + Tab + 空格 + 删除 + 插入 + Home + End + 向上翻页 + 向下翻页 + 打印屏幕 + 向上箭头 + 向下箭头 + 向左箭头 + 向右箭头 + 大写锁定 + 数字锁定 + 滚动锁定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values-zh-rHK/gestures.xml b/app/src/main/res/values-zh-rHK/gestures.xml new file mode 100644 index 000000000..50d00f410 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/gestures.xml @@ -0,0 +1,103 @@ + + + 手勢 + 觸控手勢設定 + 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 + 點按 + 單指拖曳 + 長按 + 連點兩下(滑鼠左鍵連按兩下) + 雙指點按 + 雙指拖曳 + 雙指按住 + 三指點按 + 三指拖曳 + 三指按住 + 雙指縮放 + 持續按住按鈕 + 按一下 + 按住延遲 + 連點兩下間隔 + 套用至目前遊戲 + 所有遊戲的預設設定 + 還原初始手勢設定 + 取消 + 選擇動作 + 按一下左鍵 + 按一下右鍵 + 按一下中鍵 + 顯示鍵盤 + 按住左鍵拖曳 + 按住右鍵拖曳 + 滑鼠中鍵平移 + 滑鼠中鍵平移(反向) + 方向鍵 + 方向鍵(反向) + WASD(反向) + 滑鼠滾輪 + 手勢閾值:%1$d 像素 + 顯示辨識到的手勢 + 依手勢分類的滑鼠與鍵盤控制 + 單指 + 雙指 + 三指 + 選項 + 選擇點按、拖曳或按住時執行的動作。 + 設定鏡頭平移、縮放與輔助點擊。 + 隨手使用更多快捷操作。 + 調整移動、辨識與視覺回饋。 + 預設值 + 此遊戲 + 使用預設值 + 正在使用預設設定。修改後會建立此遊戲的專用設定。 + 儲存變更 + 關閉 + 動作 + 用於%1$s + 搜尋滑鼠動作或鍵盤按鍵 + 清除搜尋 + 全部 + 沒有符合的動作。請嘗試其他按鍵或動作名稱。 + 滑鼠 + 應用程式動作 + 導覽 + 字母 + 數字 + 功能鍵 + 修飾鍵 + 數字鍵盤 + 符號 + 滑鼠移動 + 直接拖曳會跟隨觸控位置。相對拖曳會跟隨手指移動,適用於會將滑鼠重設到中央的遊戲。 + 滑鼠中鍵平移一律跟隨手指移動,以避免游標跳動。 + 直接 + 相對 + 手勢辨識 + 雙指或三指移動達到此距離後,才會辨識為平移或縮放。 + 視覺回饋 + 滑鼠按鈕行為 + %1$d 毫秒 + 數字鍵盤 %1$s + Esc + 退格 + Enter + Tab + 空格 + 刪除 + 插入 + Home + End + 上一頁 + 下一頁 + 列印螢幕 + 向上箭頭 + 向下箭頭 + 向左箭頭 + 向右箭頭 + 大寫鎖定 + 數字鎖定 + 捲動鎖定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values-zh-rTW/gestures.xml b/app/src/main/res/values-zh-rTW/gestures.xml new file mode 100644 index 000000000..b51d68f11 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/gestures.xml @@ -0,0 +1,103 @@ + + + 手勢 + 觸控手勢設定 + 自訂滑鼠與鍵盤手勢。請選擇「手勢」觸控模式以使用這些功能。 + 點按 + 單指拖曳 + 長按 + 連點兩下(滑鼠左鍵連按兩下) + 雙指點按 + 雙指拖曳 + 雙指按住 + 三指點按 + 三指拖曳 + 三指按住 + 雙指縮放 + 持續按住按鈕 + 按一下 + 按住延遲 + 連點兩下間隔 + 套用至目前遊戲 + 所有遊戲的預設設定 + 還原初始手勢設定 + 取消 + 選擇動作 + 按一下左鍵 + 按一下右鍵 + 按一下中鍵 + 顯示鍵盤 + 按住左鍵拖曳 + 按住右鍵拖曳 + 滑鼠中鍵平移 + 滑鼠中鍵平移(反向) + 方向鍵 + 方向鍵(反向) + WASD(反向) + 滑鼠滾輪 + 手勢閾值:%1$d 像素 + 顯示辨識到的手勢 + 依手勢分類的滑鼠與鍵盤控制 + 單指 + 雙指 + 三指 + 選項 + 選擇點按、拖曳或按住時執行的動作。 + 設定鏡頭平移、縮放與輔助點擊。 + 隨手使用更多快捷操作。 + 調整移動、辨識與視覺回饋。 + 預設值 + 此遊戲 + 使用預設值 + 正在使用預設設定。修改後會建立此遊戲的專用設定。 + 儲存變更 + 關閉 + 動作 + 用於%1$s + 搜尋滑鼠動作或鍵盤按鍵 + 清除搜尋 + 全部 + 沒有符合的動作。請嘗試其他按鍵或動作名稱。 + 滑鼠 + 應用程式動作 + 導覽 + 字母 + 數字 + 功能鍵 + 修飾鍵 + 數字鍵盤 + 符號 + 滑鼠移動 + 直接拖曳會跟隨觸控位置。相對拖曳會跟隨手指移動,適用於會將滑鼠重設到中央的遊戲。 + 滑鼠中鍵平移一律跟隨手指移動,以避免游標跳動。 + 直接 + 相對 + 手勢辨識 + 雙指或三指移動達到此距離後,才會辨識為平移或縮放。 + 視覺回饋 + 滑鼠按鈕行為 + %1$d 毫秒 + 數字鍵盤 %1$s + Esc + 退格 + Enter + Tab + Space + 刪除 + 插入 + Home + End + 上一頁 + 下一頁 + 列印螢幕 + 向上箭頭 + 向下箭頭 + 向左箭頭 + 向右箭頭 + 大寫鎖定 + 數字鎖定 + 捲動鎖定 + 左 %1$s + 右 %1$s + Shift + diff --git a/app/src/main/res/values/gestures.xml b/app/src/main/res/values/gestures.xml new file mode 100644 index 000000000..0a7c5c25b --- /dev/null +++ b/app/src/main/res/values/gestures.xml @@ -0,0 +1,103 @@ + + + Gestures + Touch gesture settings + Customize mouse and keyboard gestures. Select Gestures as the touch mode to use them. + Tap + One-finger drag + Long press + Double tap (double left click) + Two-finger tap + Two-finger drag + Two-finger hold + Three-finger tap + Three-finger drag + Three-finger hold + Pinch to zoom + Keep button held + Click once + Hold delay + Double-tap interval + Applies to the current game + Default settings for all games + Reset to original gesture settings + Cancel + Choose action + Left click + Right click + Middle click + Show keyboard + Left click and drag + Right click and drag + Middle mouse pan + Middle mouse pan (inverted) + Arrow keys + Arrow keys (inverted) + WASD (inverted) + Mouse wheel + Gesture threshold: %1$d px + Show recognized gesture + Mouse and keyboard controls, organized by gesture + One finger + Two fingers + Three fingers + Options + Choose what a tap, drag, or hold should do. + Configure camera panning, zoom, and secondary clicks. + Keep extra shortcuts within reach. + Fine-tune movement, recognition, and visual feedback. + Defaults + This game + Use defaults + Using default settings. Editing creates a game override. + Save changes + Close + ACTION + For %1$s + Search mouse actions or keyboard keys + Clear search + All + No matching actions. Try another key or action name. + Mouse + App actions + Navigation + Letters + Numbers + Function keys + Modifiers + Numpad + Symbols + Mouse movement + Direct drags follow your touch position. Relative drags follow finger movement, useful when a game recenters the mouse. + Middle mouse pan always follows finger movement to avoid cursor jumps. + Direct + Relative + Gesture recognition + Move this far before a two- or three-finger gesture becomes a pan or zoom. + Visual feedback + Mouse button behavior + %1$d ms + Numpad %1$s + Escape + Backspace + Enter + Tab + Space + Delete + Insert + Home + End + Page Up + Page Down + Print Screen + Up arrow + Down arrow + Left arrow + Right arrow + Caps Lock + Num Lock + Scroll Lock + Left %1$s + Right %1$s + Shift + diff --git a/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt new file mode 100644 index 000000000..b210be520 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.input + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GesturePrefsTest { + @Test fun removedOptionsAreIgnoredWhenLoadingExistingSettings() { + val old = """{"holdMouseButtonWhileTouchingEnabled":true,"holdMouseButtonWhileTouchingAction":"right_click", + "showClickHighlight":true,"showCursorInTouchscreenMode":true,"tapAction":"key_E","longPressDelay":650}""" + val config = TouchGestureConfig.fromJson(old) + assertEquals("key_E", config.tapAction) + assertEquals(650, config.longPressDelay) + assertFalse(config.toJson().contains("holdMouseButtonWhileTouching")) + assertFalse(config.toJson().contains("showClickHighlight")) + assertFalse(config.toJson().contains("showCursorInTouchscreenMode")) + } + + @Test fun gameOverridesFallBackToDefaultsAndSurviveSerialization() { + val context = RuntimeEnvironment.getApplication() + val defaults = TouchGestureConfig(tapAction = "key_E", mouseDragMovementMode = "relative") + GesturePrefs.save(context, null, defaults) + assertEquals(defaults, GesturePrefs.read(context, "123")) + val game = defaults.copy(twoFingerTapAction = "show_keyboard", longPressDelay = 650) + GesturePrefs.save(context, "123", game) + assertEquals(game, GesturePrefs.read(context, "123")) + assertEquals(defaults, GesturePrefs.read(context, "456")) + GesturePrefs.clear(context, "123") + assertEquals(defaults, GesturePrefs.read(context, "123")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt new file mode 100644 index 000000000..a9e672ea5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/GestureRoutingTest.kt @@ -0,0 +1,61 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.view.Gravity +import android.view.InputDevice +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GestureRoutingTest { + @Test fun controlFingerDoesNotCountTowardGestureWhenControlIsTouchedFirst() = checkOrder(controlFirst = true) + @Test fun controlFingerDoesNotCountTowardGestureWhenScreenIsTouchedFirst() = checkOrder(controlFirst = false) + + private fun checkOrder(controlFirst: Boolean) { + val context = RuntimeEnvironment.getApplication() + val buttons = mutableListOf>() + val gestures = TouchscreenGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) {} + override fun moveBy(dx: Float, dy: Float, relative: Boolean) {} + override fun button(code: Int, down: Boolean) { buttons += code to down } + override fun key(code: Int, down: Boolean) {} + override fun wheel(steps: Int) {} + override fun showKeyboard() {} + }) + val root = FrameLayout(context).apply { isMotionEventSplittingEnabled = true } + root.addView(View(context).apply { setOnTouchListener { _, e -> gestures.onTouch(e) } }, FrameLayout.LayoutParams(1000, 600)) + root.addView(View(context).apply { setOnTouchListener { _, _ -> true } }, FrameLayout.LayoutParams(200, 600, Gravity.RIGHT)) + root.measure(View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)) + root.layout(0, 0, 1000, 600) + val firstX = if (controlFirst) 900f else 400f + val secondX = if (controlFirst) 400f else 900f + val downTime = SystemClock.uptimeMillis() + fun send(action: Int, index: Int, vararg points: Pair) { + val properties = points.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = points.map { (_, px) -> MotionEvent.PointerCoords().apply { x = px; y = 300f; pressure = 1f; size = 1f } }.toTypedArray() + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), points.size, + properties, coords, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0).also { + root.dispatchTouchEvent(it); it.recycle() + } + } + send(MotionEvent.ACTION_DOWN, 0, 3 to firstX) + send(MotionEvent.ACTION_POINTER_DOWN, 1, 3 to firstX, 8 to secondX) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(30)) + send(MotionEvent.ACTION_POINTER_UP, 1, 3 to firstX, 8 to secondX) + send(MotionEvent.ACTION_UP, 0, 3 to firstX) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100)) + assertEquals(listOf(272 to true, 272 to false), buttons) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt new file mode 100644 index 000000000..7b2a55e2e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SessionGestureRoutingTest.kt @@ -0,0 +1,266 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.content.res.Configuration +import android.view.InputDevice +import android.view.MotionEvent +import android.view.SurfaceView +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.time.Duration +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, shadows = [SessionGestureRoutingTest.CompositorShadow::class]) +class SessionGestureRoutingTest { + private lateinit var activity: SessionActivity + private lateinit var gestures: TouchscreenGestures + private lateinit var steamTap: ThreeFingerTap + private lateinit var launcherScroll: ScrollTouchGestures + private lateinit var directHold: DirectTouchHold + private val keys = mutableListOf>() + private val directButtons = mutableListOf>() + private val scrollPositions = mutableListOf>() + private val scrollDeltas = mutableListOf>() + private var keyboards = 0 + + @After fun tearDown() { + if (::gestures.isInitialized) gestures.cancel() + if (::launcherScroll.isInitialized) launcherScroll.cancel() + if (::directHold.isInitialized) directHold.cancel() + SessionState.mode = SessionService.MODE_STEAM + SessionState.steamUi = null + SessionState.suspended = false + } + + @Test fun configuredThreeFingerActionTakesPrecedenceOverSteamShortcut() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapAction = "key_E")) + tapThreeFingers(expectSteamGesture = false) + assertEquals(listOf(18 to true, 18 to false), keys) + assertEquals(0, keyboards) + } + + @Test fun disablingThreeFingerTapDoesNotFallBackToSteamShortcut() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapEnabled = false)) + tapThreeFingers(expectSteamGesture = false) + assertTrue(keys.isEmpty()) + assertEquals(0, keyboards) + } + + @Test fun defaultThreeFingerActionOpensPcKeyboardOnce() { + prepare(SessionPrefs.TOUCH_GESTURES) + tapThreeFingers(expectSteamGesture = false) + assertEquals(1, keyboards) + assertTrue(keys.isEmpty()) + } + + @Test fun gestureModeKeepsItsBindingsWhileLauncherMouseIsAvailable() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(threeFingerTapAction = "key_E")) + setField("padMouseOn", true) + tapThreeFingers(expectSteamGesture = false) + assertEquals(listOf(18 to true, 18 to false), keys) + assertTrue(scrollPositions.isEmpty()) + assertTrue(scrollDeltas.isEmpty()) + } + + @Test fun directModeStillRoutesLauncherTouchesToScrolling() { + prepare(SessionPrefs.TOUCH_DIRECT) + setField("padMouseOn", true) + val down = SystemClock.uptimeMillis() + for ((index, action) in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP).withIndex()) { + val event = MotionEvent.obtain(down, down + index * 32L, action, 400f, if (index == 0) 300f else 200f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + } + assertEquals(listOf(400f to 300f), scrollPositions) + assertTrue(scrollDeltas.isNotEmpty()) + assertTrue(keys.isEmpty()) + assertFalse(steamTap.active) + } + + @Test fun upstreamSteamShortcutStillReceivesTouchesInLegacyModes() { + for (mode in listOf(SessionPrefs.TOUCH_DIRECT, SessionPrefs.TOUCH_PAD)) { + prepare(mode) + tapThreeFingers(expectSteamGesture = true) + assertTrue(keys.isEmpty()) + assertEquals(0, keyboards) + } + } + + @Test fun configurationChangeReleasesHeldGestureAndResetsPointerAnchor() { + startLongPress() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(listOf(18 to true), keys) + activity.onConfigurationChanged(Configuration(activity.resources.configuration).apply { + orientation = Configuration.ORIENTATION_PORTRAIT + }) + assertEquals(listOf(18 to true, 18 to false), keys) + assertFalse(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun surfaceResizeCancelsPendingHoldAndResetsPointerAnchor() { + startLongPress() + setField("surfaceW", 1000) + setField("surfaceH", 600) + val window = android.view.Surface(android.graphics.SurfaceTexture(0)) + var activeHolder: android.view.SurfaceHolder? = null + val surface = object : SurfaceView(RuntimeEnvironment.getApplication()) { + override fun getHolder() = activeHolder ?: super.getHolder() + }.apply { layout(0, 0, 1000, 600) } + activeHolder = object : android.view.SurfaceHolder by surface.holder { + override fun getSurface() = window + } + setField("surfaceView", surface) + try { activity.surfaceChanged(surface.holder, 0, 800, 600) } finally { window.release() } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertTrue(keys.isEmpty()) + assertFalse(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun unchangedSurfaceDimensionsDoNotInterruptPendingHold() { + startLongPress() + setField("surfaceW", 1000) + setField("surfaceH", 600) + val surface = SessionActivity::class.java.getDeclaredField("surfaceView").apply { isAccessible = true }.get(activity) as SurfaceView + activity.surfaceChanged(surface.holder, 0, 1000, 600) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + assertEquals(listOf(18 to true), keys) + } + + @Test fun inactiveSurfaceDestructionDoesNotInterruptCurrentGesture() { + startLongPress() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(350)) + val staleSurface = SurfaceView(RuntimeEnvironment.getApplication()) + activity.surfaceDestroyed(staleSurface.holder) + assertEquals(listOf(18 to true), keys) + assertTrue(SessionActivity::class.java.getDeclaredField("gesturePointerReady").apply { isAccessible = true }.getBoolean(activity)) + } + + @Test fun pausingCancelsPendingHoldsInBothTouchModes() { + for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_DIRECT)) { + prepare(mode, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + ReflectionHelpers.callInstanceMethod(activity, "onPause") + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(700)) + assertTrue(keys.isEmpty()) + assertTrue(directButtons.isEmpty()) + } + } + + @Test fun pausingReleasesActiveHoldsInBothTouchModes() { + for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_DIRECT)) { + keys.clear() + directButtons.clear() + prepare(mode, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + val event = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + val gestureMode = mode == SessionPrefs.TOUCH_GESTURES + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(if (gestureMode) 350 else 500)) + assertEquals(if (gestureMode) listOf(18 to true) else emptyList(), keys) + assertEquals(if (gestureMode) emptyList() else listOf(PointerGestures.BTN_RIGHT to true), directButtons) + ReflectionHelpers.callInstanceMethod(activity, "onPause") + assertEquals(if (gestureMode) listOf(18 to true, 18 to false) else emptyList(), keys) + assertEquals(if (gestureMode) emptyList() else listOf(PointerGestures.BTN_RIGHT to true, PointerGestures.BTN_RIGHT to false), directButtons) + } + } + + private fun startLongPress() { + prepare(SessionPrefs.TOUCH_GESTURES, TouchGestureConfig(longPressEnabled = true, longPressAction = "key_E")) + val now = SystemClock.uptimeMillis() + MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 400f, 300f, 0).also { + try { assertTrue(activity.onTouchEvent(it)) } finally { it.recycle() } + } + setField("gesturePointerReady", true) + } + + private fun prepare(mode: String, config: TouchGestureConfig = TouchGestureConfig()) { + SessionState.mode = SessionService.MODE_STEAM + SessionState.steamUi = "bigpicture" + SessionState.suspended = false + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + steamTap = ThreeFingerTap(8f) + gestures = TouchscreenGestures(8f, object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) {} + override fun moveBy(dx: Float, dy: Float, relative: Boolean) {} + override fun button(code: Int, down: Boolean) { fail("Unexpected mouse button: $code $down") } + override fun key(code: Int, down: Boolean) { keys += code to down } + override fun wheel(steps: Int) { fail("Unexpected wheel input") } + override fun showKeyboard() { keyboards++ } + }).also { it.configure(config) } + setField("routedTouchMode", mode) + setField("padMouseCheckedAt", SystemClock.uptimeMillis()) + setField("threeFingerTap", steamTap) + setField("touchscreenGestures", gestures) + setField("surfaceView", SurfaceView(RuntimeEnvironment.getApplication()).apply { layout(0, 0, 1000, 600) }) + setField("touchpad", TouchpadGestures(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {} + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + })) + directHold = DirectTouchHold(8f, object : PointerGestures.Listener { + override fun onMove(x: Float, y: Float) {} + override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { directButtons += button to pressed } + override fun onWheel(steps: Int) {} + override fun onLongPress() {} + }) {} + setField("directTouchHold", directHold) + launcherScroll = ScrollTouchGestures(8f, 50f, object : ScrollTouchGestures.Listener { + override fun onMove(x: Float, y: Float) { scrollPositions += x to y } + override fun onButton(button: Int, pressed: Boolean) {} + override fun onScroll(dx: Float, dy: Float) { scrollDeltas += dx to dy } + override fun onLongPress() {} + }) + setField("scrollTouch", launcherScroll) + } + + private fun tapThreeFingers(expectSteamGesture: Boolean) { + val down = SystemClock.uptimeMillis() + fun send(action: Int, index: Int, count: Int) { + val properties = Array(count) { MotionEvent.PointerProperties().apply { id = it; toolType = MotionEvent.TOOL_TYPE_FINGER } } + val coordinates = Array(count) { MotionEvent.PointerCoords().apply { x = 300f + it * 100; y = 300f; pressure = 1f } } + val event = MotionEvent.obtain(down, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), + count, properties, coordinates, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0) + try { assertTrue(activity.onTouchEvent(event)) } finally { event.recycle() } + } + send(MotionEvent.ACTION_DOWN, 0, 1) + send(MotionEvent.ACTION_POINTER_DOWN, 1, 2) + send(MotionEvent.ACTION_POINTER_DOWN, 2, 3) + assertEquals(expectSteamGesture, steamTap.active) + send(MotionEvent.ACTION_POINTER_UP, 2, 3) + send(MotionEvent.ACTION_POINTER_UP, 1, 2) + send(MotionEvent.ACTION_UP, 0, 1) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(200)) + assertFalse(steamTap.active) + } + + private fun setField(name: String, value: Any) { + SessionActivity::class.java.getDeclaredField(name).apply { isAccessible = true }.set(activity, value) + } + + @Implements(value = WaylandCompositor::class, isInAndroidSdk = false) + class CompositorShadow { + companion object { + @JvmStatic @Implementation fun __staticInitializer__() {} + @JvmStatic @Implementation fun nativeSendTouch(action: Int, id: Int, x: Int, y: Int) {} + @JvmStatic @Implementation fun nativeSendSceneInput(type: Int, code: Int, value: Int) {} + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt new file mode 100644 index 000000000..ad85d6245 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchscreenGesturesTest.kt @@ -0,0 +1,1055 @@ +package com.droiddeck.launcher.input + +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import java.time.Duration +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class TouchscreenGesturesTest { + private val events = mutableListOf() + private data class ButtonEvent(val time: Long, val down: Boolean, val position: Pair) + private val leftClicks = mutableListOf() + private val rightClicks = mutableListOf() + private val timedKeys = mutableListOf>() + private val absoluteMoves = mutableListOf>() + private val relativeMoves = mutableListOf>() + private val recognized = mutableListOf() + private var cursor = 0f to 0f + private lateinit var gestures: TouchscreenGestures + private var downTime = 0L + private val a = 7 to (400f to 300f) + private val b = 12 to (600f to 300f) + private val c = 19 to (800f to 300f) + private val d = 24 to (1000f to 300f) + + @Before fun setUp() { + advance(1000) + gestures = TouchscreenGestures(ViewConfiguration.get(RuntimeEnvironment.getApplication()).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { + cursor = x to y; absoluteMoves += cursor; events += "move" + } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + if (relative) relativeMoves += dx to dy + events += "delta $relative" + } + override fun button(code: Int, down: Boolean) { + events += "button $code $down" + if (code == 272) leftClicks += ButtonEvent(SystemClock.uptimeMillis(), down, cursor) + if (code == 273) rightClicks += ButtonEvent(SystemClock.uptimeMillis(), down, cursor) + } + override fun key(code: Int, down: Boolean) { + events += "key $code $down" + timedKeys += Triple(SystemClock.uptimeMillis(), code, down) + } + override fun wheel(steps: Int) { events += "wheel $steps" } + override fun showKeyboard() { events += "keyboard" } + override fun prepareRelativePan() { events += "prepare pan" } + override fun gesture(gesture: TouchscreenGestures.Gesture) { recognized += gesture } + }) + } + + @Test fun tapHasOnePressAndADelayedRelease() { + tap() + assertEquals(listOf("button 272 true"), buttons()) + advance(60) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun recognizedFeedbackIsTypedAndOnlyEmittedWhenEnabled() { + tap(); advance(150) + assertTrue(recognized.isEmpty()) + gestures.configure(TouchGestureConfig(showGestureDebugOverlay = true)) + tap() + assertEquals(listOf(TouchscreenGestures.Gesture.TAP), recognized) + } + + @Test fun doubleTapProducesTwoClicksRatherThanThree() { + tap(); advance(80); tap(); advance(80) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + } + + @Test fun aVeryFastDoubleTapKeepsFullPressesAndAReleaseGap() { + tap(); tap(); advance(250) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks[1].time - leftClicks[0].time >= 50) + assertTrue(leftClicks[2].time - leftClicks[1].time >= 50) + assertTrue(leftClicks[3].time - leftClicks[2].time >= 50) + } + + @Test fun doubleTapUsesTheFirstClickLocationDespiteNormalFingerDrift() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (404f to 303f)) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (406f to 304f)) + advance(30) + touch(MotionEvent.ACTION_UP, 0, 7 to (406f to 304f)) + advance(200) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks.all { it.position == a.second }) + } + + @Test fun quickTapsOnVerticallyAdjacentIconsClickEachIconOnce() { + val below = 7 to (400f to 345f) + tap(); tap(below); advance(250) + assertEquals(listOf(a.second, below.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun quickTapsOnHorizontallyAdjacentIconsClickEachIconOnce() { + val beside = 7 to (445f to 300f) + tap(); tap(beside); advance(250) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun fastClicksAcrossThreeIconsDoNotSnapBackToEarlierIcons() { + val below = 7 to (400f to 345f) + val bottom = 7 to (400f to 390f) + tap(); tap(below); tap(bottom); advance(350) + assertEquals(listOf(a.second, below.second, bottom.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun aDoubleTapWithoutASingleTapHasAReleaseGapBetweenSynthesizedClicks() { + gestures.configure(TouchGestureConfig(tapEnabled = false)) + tap(); tap(); advance(250) + assertEquals(4, leftClicks.size) + assertTrue(leftClicks[2].time - leftClicks[1].time >= 50) + } + + @Test fun aRapidThirdTapDoesNotDiscardThePendingSecondClick() { + tap(); tap(); tap(); advance(350) + assertEquals(6, leftClicks.size) + assertEquals(listOf(true, false, true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun cancellingBetweenDoubleClicksStopsTheQueuedClick() { + tap(); tap() + gestures.cancel() + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun aTouchCancelDoesNotPrimeTheFollowingTapAsADoubleTap() { + tap(); advance(80) + touch(MotionEvent.ACTION_CANCEL, 0, a) + events.clear() + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(100) + assertTrue(buttons().isEmpty()) + } + + @Test fun firstTapNearOriginIsNotMistakenForADoubleTap() { + touch(MotionEvent.ACTION_DOWN, 0, 1 to (1f to 1f)) + assertTrue(buttons().isEmpty()) + } + + @Test fun doubleClickIsNotCommittedUntilTheSecondFingerLifts() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(30) + assertEquals(2, leftClicks.size) + touch(MotionEvent.ACTION_UP, 0, a) + advance(60) + assertEquals(listOf(true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun aTapFollowedByADragAtTheSamePlaceDoesNotSwallowTheDrag() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + assertEquals(2, leftClicks.size) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + assertEquals(3, leftClicks.size) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (520f to 300f)) + assertEquals(520f to 300f, cursor) + touch(MotionEvent.ACTION_UP, 0, 7 to (520f to 300f)) + advance(500) + assertEquals(listOf(true, false, true, false), leftClicks.map { it.down }) + } + + @Test fun aTapFollowedByARelativeDragStartsAtTheNewTouchOrigin() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + tap(); advance(80) + val second = 7 to (404f to 303f) + touch(MotionEvent.ACTION_DOWN, 0, second) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (484f to 303f)) + assertEquals(second.second, leftClicks.last().position) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (494f to 303f)) + assertEquals(10f to 0f, relativeMoves.last()) + gestures.cancel() + assertEquals(4, leftClicks.size) + } + + @Test fun aTapFollowedByALongPressUsesItsHoldBindingInsteadOfDoubleClicking() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "right_click")) + tap(); advance(80) + val second = 7 to (404f to 303f) + touch(MotionEvent.ACTION_DOWN, 0, second) + advance(350) + assertEquals(2, leftClicks.size) + assertEquals(listOf("button 273 true"), buttons().filter { it.contains("273") }) + assertEquals(second.second, rightClicks.single().position) + touch(MotionEvent.ACTION_UP, 0, second) + advance(500) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(2, leftClicks.size) + } + + @Test fun aLongSecondTouchIsNotASynthesizedDoubleTapWhenHoldsAreDisabled() { + gestures.configure(TouchGestureConfig(tapEnabled = false, longPressEnabled = false)) + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(ViewConfiguration.getLongPressTimeout().toLong() + 20) + touch(MotionEvent.ACTION_UP, 0, a) + advance(500) + assertTrue(buttons().isEmpty()) + } + + @Test fun addingAnotherFingerToAPotentialDoubleTapDoesNotClickLeftAgain() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(80) + assertEquals(2, leftClicks.size) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + } + + @Test fun movingTheSecondTouchToANeighboringIconKeepsThatTarget() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + val beside = 7 to (445f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, beside) + touch(MotionEvent.ACTION_UP, 0, beside) + advance(80) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun endingTheSecondTouchOnAnotherIconWithoutAMoveEventKeepsItsTarget() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + val beside = 7 to (445f to 300f) + touch(MotionEvent.ACTION_UP, 0, beside) + advance(80) + assertEquals(listOf(a.second, beside.second), leftClicks.filter { it.down }.map { it.position }) + } + + @Test fun cancellingAPotentialDoubleTapCannotLeaveAQueuedClick() { + tap(); advance(80) + touch(MotionEvent.ACTION_DOWN, 0, a) + gestures.cancel(); advance(1000) + assertEquals(listOf(true, false), leftClicks.map { it.down }) + } + + @Test fun doubleTapStillDoubleClicksWhenSingleTapIsDisabled() { + gestures.configure(TouchGestureConfig(tapEnabled = false)) + tap(); advance(80); tap(); advance(150) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + } + + @Test fun doubleTapStillDoubleClicksWhenSingleTapIsAKeyboardAction() { + gestures.configure(TouchGestureConfig(tapAction = "key_E")) + tap(); advance(80); tap(); advance(150) + assertEquals(listOf("button 272 true", "button 272 false", "button 272 true", "button 272 false"), buttons()) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + } + + @Test fun shortDragPressesBeforeLiftAndDoesNotPrimeADoubleTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + assertEquals(listOf("button 272 true"), buttons()) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (480f to 300f)) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun twoFingerTapIsRightClickWithoutALeftClickOnFinalLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(80) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(80) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun twoFingerTapKeepsItsPressAfterTheLastFingerLifts() { + twoFingerTap() + assertEquals(listOf("button 273 true"), buttons()) + advance(49) + assertEquals(1, rightClicks.size) + advance(1) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + } + + @Test fun threeFingerKeyboardTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "key_E")) + threeFingerTap() + assertEquals(listOf("key 18 true"), events.filter { it.startsWith("key") }) + advance(50) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + assertEquals(50L, timedKeys.last().first - timedKeys.first().first) + } + + @Test fun twoFingerKeyboardTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(twoFingerTapAction = "key_E")) + twoFingerTap(); advance(50) + assertEquals(listOf(true, false), timedKeys.map { it.third }) + assertEquals(50L, timedKeys.last().first - timedKeys.first().first) + } + + @Test fun threeFingerMouseTapHasAnObservablePressAndReleases() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "right_click")) + threeFingerTap() + assertEquals(listOf("button 273 true"), buttons()) + advance(50) + assertEquals(listOf(true, false), rightClicks.map { it.down }) + assertEquals(50L, rightClicks[1].time - rightClicks[0].time) + } + + @Test fun cancellingAMultiFingerTapReleasesItsButtonAndStopsCallbacks() { + twoFingerTap(); advance(10) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun changingConfigDuringAThreeFingerKeyTapReleasesTheKey() { + gestures.configure(TouchGestureConfig(threeFingerTapAction = "key_E")) + threeFingerTap(); advance(10) + gestures.configure(TouchGestureConfig()) + assertEquals(listOf("key 18 true", "key 18 false"), events.filter { it.startsWith("key") }) + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun rapidRightClicksKeepAReleaseGapBetweenThem() { + gestures.configure(TouchGestureConfig(tapAction = "right_click", doubleTapEnabled = false)) + tap(); tap(); advance(250) + assertEquals(listOf(true, false, true, false), rightClicks.map { it.down }) + assertTrue(rightClicks[2].time - rightClicks[1].time >= 50) + } + + @Test fun aFastTwoFingerSwipeDuringSettlingDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertTrue(buttons().isEmpty()) + assertFalse(events.any { it.startsWith("key") || it.startsWith("wheel") }) + } + + @Test fun aFastPinchDuringSettlingDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + val movedA = 7 to (370f to 300f) + val movedB = 12 to (630f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertTrue(buttons().isEmpty()) + } + + @Test fun earlySwipeMovementCancelsTheHoldEvenIfFingersThenStop() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + advance(600) + assertTrue(buttons().isEmpty()) + gestures.cancel() + } + + @Test fun movementOnlyReportedOnLiftCannotBecomeATwoFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + advance(600) + assertTrue(buttons().isEmpty()) + } + + @Test fun smallTwoFingerPlacementJitterStillAllowsATap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (596f to 297f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(80) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun smallTwoFingerPlacementJitterStillAllowsAHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (596f to 297f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + advance(350) + assertEquals(listOf("button 274 true"), buttons()) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + assertEquals(listOf("button 274 true", "button 274 false"), buttons()) + } + + @Test fun smallThreeFingerPlacementJitterStillAllowsAHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(20) + val movedA = 7 to (404f to 303f) + val movedB = 12 to (604f to 303f) + val movedC = 19 to (804f to 303f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + advance(350) + assertEquals(listOf("key 1 true"), events.filter { it.startsWith("key") }) + touch(MotionEvent.ACTION_POINTER_UP, 2, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + assertEquals(listOf("key 1 true", "key 1 false"), events.filter { it.startsWith("key") }) + assertFalse(events.contains("keyboard")) + } + + @Test fun aFastThreeFingerSwipeDuringSettlingDoesNotTapOrHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(20) + val movedA = 7 to (460f to 300f) + val movedB = 12 to (660f to 300f) + val movedC = 19 to (860f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 2, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(1000) + assertFalse(events.any { it == "keyboard" || it.startsWith("key") || it.startsWith("button") }) + } + + @Test fun addingAThirdFingerResetsTheMovementOriginsForItsTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + val movedA = 7 to (435f to 300f) + val movedB = 12 to (635f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, movedA, movedB, c) + advance(20) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (440f to 300f), 12 to (640f to 300f), 19 to (805f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 2, 7 to (440f to 300f), 12 to (640f to 300f), 19 to (805f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (440f to 300f), 12 to (640f to 300f)) + touch(MotionEvent.ACTION_UP, 0, 7 to (440f to 300f)) + advance(80) + assertEquals(1, events.count { it == "keyboard" }) + assertTrue(buttons().isEmpty()) + } + + @Test fun replacingAFingerAfterAPanCannotRestartATapBeforeAllFingersLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, movedB) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, movedA, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, movedA, b) + touch(MotionEvent.ACTION_UP, 0, movedA) + advance(600) + assertTrue(buttons().isEmpty()) + assertEquals(listOf("key 106 true", "key 106 false"), events.filter { it.startsWith("key") }) + } + + @Test fun cancellingAnActiveLongPressReleasesItsButton() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "right_click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(350) + assertEquals(listOf("button 273 true"), buttons()) + gestures.cancel(); advance(2000) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun changingConfigurationReleasesAnActiveHold() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "left_click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(350) + gestures.configure(TouchGestureConfig()) + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun relativeDragEmitsDeltasInsteadOfAbsoluteMotion() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (490f to 300f)) + assertEquals(listOf("delta true"), events) + } + + @Test fun directSelectionBoxTracksEveryMoveBackThroughItsStartingPoint() { + checkDirectDragThroughOrigin("left_click_drag", 272) + } + + @Test fun directRightDragAlsoTracksEveryMoveBackThroughItsStartingPoint() { + checkDirectDragThroughOrigin("right_click_drag", 273) + } + + private fun checkDirectDragThroughOrigin(action: String, code: Int) { + gestures.configure(TouchGestureConfig(dragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 330f)) + absoluteMoves.clear() + val path = listOf(430f to 315f, 410f to 305f, 400f to 300f, 390f to 295f, 370f to 285f, 320f to 270f) + path.forEach { touch(MotionEvent.ACTION_MOVE, 0, 7 to it) } + assertEquals(path, absoluteMoves) + assertEquals(listOf("button $code true"), buttons()) + touch(MotionEvent.ACTION_UP, 0, 7 to path.last()) + advance(100) + assertEquals(listOf("button $code true", "button $code false"), buttons()) + } + + @Test fun relativeDragReportsSmallMovementsWhileCrossingItsStartingPoint() { + gestures.configure(TouchGestureConfig(mouseDragMovementMode = "relative")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f)) + relativeMoves.clear() + listOf(430f, 410f, 400f, 390f, 370f, 320f).forEach { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (it to 300f)) + } + assertEquals(listOf(-50f, -20f, -10f, -10f, -20f, -50f), relativeMoves.map { it.first }) + assertTrue(relativeMoves.all { it.second == 0f }) + gestures.cancel() + assertEquals(listOf("button 272 true", "button 272 false"), buttons()) + } + + @Test fun longPressCanSendAClickInsteadOfHolding() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressMouseBehavior = "click")) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(400) + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + touch(MotionEvent.ACTION_UP, 0, a); advance(80) + assertEquals(2, buttons().size) + } + + @Test fun aThirdFingerReleasesTheTwoFingerHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(350) + assertEquals(listOf("button 274 true"), buttons()) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + assertEquals(listOf("button 274 true", "button 274 false"), buttons()) + gestures.cancel() + } + + @Test fun threeFingerTapOpensKeyboardOnceAndDoesNotClick() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(80) + touch(MotionEvent.ACTION_POINTER_UP, 0, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 0, b, c) + touch(MotionEvent.ACTION_UP, 0, c) + advance(400) + assertEquals(1, events.count { it == "keyboard" }) + assertTrue(buttons().isEmpty()) + } + + @Test fun fourToThreeFingersDoesNotReleaseTheThreeFingerHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(350) + assertTrue(events.contains("key 1 true")) + touch(MotionEvent.ACTION_POINTER_DOWN, 3, a, b, c, d) + touch(MotionEvent.ACTION_POINTER_UP, 3, a, b, c, d) + assertFalse(events.contains("key 1 false")) + gestures.cancel() + assertTrue(events.contains("key 1 false")) + } + + @Test fun cancelStopsPendingHoldAndTapCallbacks() { + gestures.configure(TouchGestureConfig(longPressEnabled = true)) + touch(MotionEvent.ACTION_DOWN, 0, a) + gestures.cancel() + events.clear(); advance(2000) + assertTrue(events.isEmpty()) + } + + @Test fun twoFingerPanHoldsTheArrowAndReleasesItOnLift() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("key 106 true")) + assertFalse(events.any { it.startsWith("wheel") }) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (480f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("key 106 false")) + touch(MotionEvent.ACTION_UP, 0, 7 to (480f to 300f)) + advance(400) + assertTrue(buttons().isEmpty()) + } + + @Test fun pinchZoomDoesNotAlsoStartAPan() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (320f to 300f), 12 to (680f to 300f)) + assertTrue(events.contains("wheel -1")) + assertFalse(events.any { it.startsWith("key") }) + gestures.cancel(); advance(400) + assertTrue(buttons().isEmpty()) + } + + @Test fun threeFingerRightDragReleasesOnCancel() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + assertEquals(listOf("button 273 true"), buttons()) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun slowMiddlePanRetainsOnePixelMovesAfterRecognition() { + startTwoFingerPan("middle_mouse_pan") + relativeMoves.clear() + for (offset in 81..90) { + advance(8) + moveTwoFingerPan(offset.toFloat()) + } + assertEquals(List(10) { 1f to 0f }, relativeMoves) + gestures.cancel() + assertEquals("button 274 false", buttons().last()) + } + + @Test fun slowInvertedMiddlePanRetainsInvertedOnePixelMoves() { + startTwoFingerPan("inverted_middle_mouse_pan") + relativeMoves.clear() + for (offset in 81..90) moveTwoFingerPan(offset.toFloat()) + assertEquals(List(10) { -1f }, relativeMoves.map { it.first }) + assertTrue(relativeMoves.all { it.second == 0f }) + gestures.cancel() + } + + @Test fun slowThreeFingerMiddlePanRetainsSmallMoves() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + relativeMoves.clear() + for (offset in 81..85) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f + offset to 300f), 12 to (600f + offset to 300f), 19 to (800f + offset to 300f)) + } + assertEquals(List(5) { 1f to 0f }, relativeMoves) + gestures.cancel() + } + + @Test fun slowKeyboardPanCanStartUsingOnlySmallMovementEvents() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + for (offset in 1..45) { advance(8); moveTwoFingerPan(offset.toFloat()) } + assertTrue(events.contains("key 106 true")) + gestures.cancel() + assertEquals("key 106 false", events.filter { it.startsWith("key") }.last()) + } + + @Test fun slowKeyboardPanReversalReleasesTheOldDirection() { + startTwoFingerPan("arrow_keys") + events.clear() + for (offset in 79 downTo 75) moveTwoFingerPan(offset.toFloat()) + assertEquals(listOf("key 106 false", "key 105 true"), events.filter { it.startsWith("key") }) + gestures.cancel() + assertEquals("key 105 false", events.filter { it.startsWith("key") }.last()) + } + + @Test fun slowDiagonalKeyboardPanCanHoldBothDirections() { + startTwoFingerPan("arrow_keys") + events.clear() + for (i in 1..4) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f + i to 300f + i), 12 to (680f + i to 300f + i)) + } + assertEquals(listOf("key 108 true"), events.filter { it.startsWith("key") }) + gestures.cancel() + assertTrue(events.contains("key 106 false")) + assertTrue(events.contains("key 108 false")) + } + + @Test fun keyboardPanRemainderDoesNotSurviveCancellation() { + startTwoFingerPan("arrow_keys") + for (offset in 79 downTo 76) moveTwoFingerPan(offset.toFloat()) + gestures.cancel() + startTwoFingerPan("arrow_keys") + events.clear() + moveTwoFingerPan(79f) + assertTrue(events.none { it.startsWith("key") }) + gestures.cancel() + } + + @Test fun slowPinchAccumulatesFractionalMovementIntoAWheelStep() { + startPinch() + events.clear() + for (i in 1..20) { advance(8); movePinch(i * .25f) } + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun slowPinchCanStartUsingOnlySmallMovementEvents() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + for (offset in 1..25) { + advance(8) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f - offset to 300f), 12 to (600f + offset to 300f)) + } + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + assertTrue(events.none { it.startsWith("key") }) + gestures.cancel() + } + + @Test fun pinchReversalDoesNotConsumeOldDirectionRemainder() { + startPinch() + events.clear() + movePinch(4.5f) // Nine pixels toward zooming in, short of a step. + assertTrue(events.none { it.startsWith("wheel") }) + for (i in 1..10) movePinch(4.5f - i * .5f) + assertEquals(listOf("wheel 1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun pinchRemainderDoesNotLeakIntoTheNextGesture() { + startPinch() + movePinch(4.5f) + gestures.cancel() + startPinch() + events.clear() + movePinch(.5f) + assertTrue(events.none { it.startsWith("wheel") }) + gestures.cancel() + } + + @Test fun aLargePinchStillEmitsOneStepWithoutQueuedZoomAfterRelease() { + startPinch() + events.clear() + movePinch(40f) + assertEquals(listOf("wheel -1"), events.filter { it.startsWith("wheel") }) + gestures.cancel() + events.clear(); advance(1000) + assertTrue(events.isEmpty()) + } + + @Test fun slowPinchKeyboardBindingsReleaseTheirKeys() { + startPinch("plus_minus") + events.clear(); timedKeys.clear() + for (i in 1..20) { advance(8); movePinch(i * .25f) } + assertEquals(listOf("key 13 true"), events.filter { it.startsWith("key") }) + advance(50) + assertEquals(listOf("key 13 true", "key 13 false"), events.filter { it.startsWith("key") }) + gestures.cancel() + events.clear(); advance(1000) + assertTrue(events.isEmpty()) + } + + @Test fun middlePanDoesNotJumpBackToTheTouchAnchorAfterTheClientRecenters() { + checkRecenteredPan("middle_mouse_pan", 1040f) + } + + @Test fun invertedMiddlePanUsesOnlyInvertedFingerDeltasAfterRecentering() { + checkRecenteredPan("inverted_middle_mouse_pan", 880f) + } + + private fun checkRecenteredPan(action: String, expected: Float) { + var cachedX = 0f + var clientX = 0f + var middleDown = false + gestures = TouchscreenGestures(ViewConfiguration.get(RuntimeEnvironment.getApplication()).scaledTouchSlop.toFloat(), object : TouchscreenGestures.Output { + override fun moveTo(x: Float, y: Float) { cachedX = x; clientX = x } + override fun prepareRelativePan() { cachedX = 960f; clientX = 960f } + override fun moveBy(dx: Float, dy: Float, relative: Boolean) { + // Before pointer lock, the compositor delivers an absolute + // position even when we inject a relative delta. The game + // only requests lock after seeing this first pan movement. + cachedX += dx + clientX = cachedX + } + override fun button(code: Int, down: Boolean) { + if (code == 274) { + middleDown = down + if (down) clientX = 960f // The client warps to the middle when pan starts. + } + } + override fun key(code: Int, down: Boolean) {} + override fun wheel(steps: Int) {} + override fun showKeyboard() {} + }) + gestures.configure(TouchGestureConfig(twoFingerDragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, 7 to (1200f to 300f)) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, 7 to (1200f to 300f), 12 to (1400f to 300f)) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (1280f to 300f), 12 to (1480f to 300f)) + assertTrue(middleDown) + assertEquals(expected, clientX, 0.01f) + touch(MotionEvent.ACTION_POINTER_UP, 1, 7 to (1280f to 300f), 12 to (1480f to 300f)) + assertFalse(middleDown) + } + + @Test fun middlePanPreparesItsOriginBeforePressAndOnlyOncePerDrag() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (490f to 300f), 12 to (690f to 300f)) + assertEquals(listOf("prepare pan", "button 274 true", "delta true", "delta true"), events) + gestures.cancel() + assertEquals("button 274 false", events.last()) + } + + @Test fun leftClickDragDoesNotPrepareARelativePanOrigin() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "left_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + events.clear() + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertFalse(events.contains("prepare pan")) + assertTrue(buttons().contains("button 272 true")) + } + + @Test fun keyboardActionsUseEvdevCodesAndReleaseOnCancel() { + gestures.configure(TouchGestureConfig(longPressEnabled = true, longPressAction = "key_CTRL_R")) + touch(MotionEvent.ACTION_DOWN, 0, a); advance(350) + assertTrue(events.contains("key 97 true")) + gestures.cancel() + assertTrue(events.contains("key 97 false")) + assertEquals(96, GestureKeys.code("KP_ENTER")) + assertEquals(99, GestureKeys.code("PRTSCN")) + } + + + @Test fun replacingAnOriginalFingerDoesNotInjectThreeFingerPanMotion() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "middle_mouse_pan")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + val movedC = 19 to (880f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + touch(MotionEvent.ACTION_POINTER_DOWN, 3, movedA, movedB, movedC, d) + relativeMoves.clear() + touch(MotionEvent.ACTION_POINTER_UP, 0, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_MOVE, 0, movedB, movedC, d) + assertTrue("No finger moved; observed deltas: $relativeMoves", relativeMoves.isEmpty()) + touch(MotionEvent.ACTION_MOVE, 0, 12 to (690f to 300f), 19 to (890f to 300f), 24 to (1010f to 300f)) + assertEquals(listOf(10f to 0f), relativeMoves) + assertEquals(1, events.count { it == "prepare pan" }) + gestures.cancel() + } + + @Test fun twoFingerDirectDragStartsAtItsOwnCentroid() { + gestures.configure(TouchGestureConfig(twoFingerDragAction = "left_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f)) + assertEquals(500f to 300f, leftClicks.single().position) + gestures.cancel() + } + + @Test fun threeFingerDirectDragStartsAtItsOwnCentroid() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + touch(MotionEvent.ACTION_MOVE, 0, 7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + assertEquals(600f to 300f, rightClicks.single().position) + gestures.cancel() + } + + @Test fun batchedTwoFingerExcursionCannotBecomeATap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + batchedReturn(7 to (480f to 300f), 12 to (680f to 300f)) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(1000) + assertTrue("An out-and-back swipe generated: ${buttons()}", buttons().isEmpty()) + } + + @Test fun batchedSingleFingerExcursionCannotBecomeATapWithDragDisabled() { + gestures.configure(TouchGestureConfig(dragEnabled = false)) + touch(MotionEvent.ACTION_DOWN, 0, a) + advance(20) + batchedReturn(7 to (480f to 300f)) + touch(MotionEvent.ACTION_UP, 0, a) + advance(1000) + assertTrue("An out-and-back swipe generated: ${buttons()}", buttons().isEmpty()) + } + + @Test fun replacingAFingerDuringDirectDragPreservesPositionAndContinuesMoving() { + gestures.configure(TouchGestureConfig(threeFingerDragEnabled = true, threeFingerDragAction = "right_click_drag")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(100) + val movedA = 7 to (480f to 300f) + val movedB = 12 to (680f to 300f) + val movedC = 19 to (880f to 300f) + touch(MotionEvent.ACTION_MOVE, 0, movedA, movedB, movedC) + val before = cursor + touch(MotionEvent.ACTION_POINTER_DOWN, 3, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_POINTER_UP, 0, movedA, movedB, movedC, d) + touch(MotionEvent.ACTION_MOVE, 0, movedB, movedC, d) + assertEquals(before, cursor) + touch(MotionEvent.ACTION_MOVE, 0, 12 to (692f to 300f), 19 to (892f to 300f), 24 to (1012f to 300f)) + assertEquals(before.first + 12f to before.second, cursor) + gestures.cancel() + assertEquals(listOf("button 273 true", "button 273 false"), buttons()) + } + + @Test fun batchedPinchExcursionCancelsTapEvenWhenEachFingerStaysInsidePanTolerance() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(20) + batchedReturn(7 to (375f to 300f), 12 to (625f to 300f)) + advance(350) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(100) + assertTrue(events.none { it.startsWith("button") || it.startsWith("key") || it == "keyboard" }) + } + + @Test fun batchedSingleFingerExcursionCancelsPendingLongPress() { + gestures.configure(TouchGestureConfig(dragEnabled = false, longPressEnabled = true, longPressAction = "key_E")) + touch(MotionEvent.ACTION_DOWN, 0, a) + batchedReturn(7 to (480f to 300f)) + advance(350) + assertTrue(timedKeys.isEmpty()) + gestures.cancel() + } + + @Test fun batchedJitterPreservesTwoFingerHold() { + gestures.configure(TouchGestureConfig(twoFingerHoldAction = "key_E")) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + batchedReturn(7 to (403f to 302f), 12 to (603f to 302f)) + advance(350) + assertTrue(events.contains("key 18 true")) + gestures.cancel() + assertEquals("key 18 false", events.last()) + } + + @Test fun batchedThreeFingerExcursionCannotBecomeATapOrHold() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + batchedReturn(7 to (480f to 300f), 12 to (680f to 300f), 19 to (880f to 300f)) + advance(350) + touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + advance(100) + assertTrue(events.none { it.startsWith("button") || it.startsWith("key") || it == "keyboard" }) + } + + private fun batchedReturn(vararg excursion: Pair>) { + val originals = listOf(a, b, c).take(excursion.size) + val properties = excursion.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + fun coords(points: List>>) = points.map { (_, pos) -> MotionEvent.PointerCoords().apply { x = pos.first; y = pos.second; pressure = 1f; size = 1f } }.toTypedArray() + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_MOVE, excursion.size, + properties, coords(excursion.toList()), 0, 0, 1f, 1f, 0, 0, 0, 0) + advance(10) + event.addBatch(SystemClock.uptimeMillis(), coords(originals), 0) + assertEquals(1, event.historySize) + gestures.onTouch(event) + event.recycle() + } + + private fun buttons() = events.filter { it.startsWith("button") } + private fun startTwoFingerPan(action: String) { + gestures.configure(TouchGestureConfig(twoFingerDragAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + moveTwoFingerPan(80f) + advance(110) + } + private fun moveTwoFingerPan(offset: Float) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (400f + offset to 300f), 12 to (600f + offset to 300f)) + } + private fun startPinch(action: String = "scroll_wheel") { + gestures.configure(TouchGestureConfig(pinchAction = action)) + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(150) + movePinch(0f) + advance(110) + } + private fun movePinch(offset: Float) { + touch(MotionEvent.ACTION_MOVE, 0, 7 to (370f - offset to 300f), 12 to (630f + offset to 300f)) + } + private fun twoFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + } + private fun threeFingerTap() { + touch(MotionEvent.ACTION_DOWN, 0, a) + touch(MotionEvent.ACTION_POINTER_DOWN, 1, a, b) + touch(MotionEvent.ACTION_POINTER_DOWN, 2, a, b, c) + advance(30) + touch(MotionEvent.ACTION_POINTER_UP, 2, a, b, c) + touch(MotionEvent.ACTION_POINTER_UP, 1, a, b) + touch(MotionEvent.ACTION_UP, 0, a) + } + private fun tap(point: Pair> = a) { + touch(MotionEvent.ACTION_DOWN, 0, point) + advance(30) + touch(MotionEvent.ACTION_UP, 0, point) + } + private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) + private fun touch(action: Int, index: Int, vararg points: Pair>) { + if (action == MotionEvent.ACTION_DOWN) downTime = SystemClock.uptimeMillis() + val properties = points.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = points.map { (_, pos) -> MotionEvent.PointerCoords().apply { x = pos.first; y = pos.second; pressure = 1f; size = 1f } }.toTypedArray() + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), points.size, + properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0).also { event -> + gestures.onTouch(event); event.recycle() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt new file mode 100644 index 000000000..4111aec79 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GestureActionsTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.* +import org.junit.Test + +class GestureActionsTest { + @Test fun categoryActionsAreAlphabeticalIncludingFilteredResults() { + val actions = listOf("Z", "C", "A", "B").map { GestureAction("key_$it", it, GestureActionGroup.LETTERS) } + + listOf("Right click", "Middle click", "Left click").map { GestureAction(it, it, GestureActionGroup.MOUSE) } + assertEquals(listOf("A", "B", "C", "Z"), filterGestureActions(actions, "", GestureActionGroup.LETTERS).map { it.title }) + assertEquals(listOf("Left click", "Middle click", "Right click"), filterGestureActions(actions, "click", null).map { it.title }) + } + + @Test fun numberedFunctionKeysStayInNumericOrder() { + val actions = listOf(12, 2, 10, 1).map { GestureAction("key_F$it", "F$it", GestureActionGroup.FUNCTION) } + assertEquals(listOf("F1", "F2", "F10", "F12"), filterGestureActions(actions, "", null).map { it.title }) + } + + @Test fun actionGroupsKeepKeysAndMouseBindingsInTheirOwnCategories() { + assertEquals(GestureActionGroup.MOUSE, gestureActionGroup("middle_mouse_pan")) + assertEquals(GestureActionGroup.LETTERS, gestureActionGroup("key_F")) + assertEquals(GestureActionGroup.FUNCTION, gestureActionGroup("key_F12")) + assertEquals(GestureActionGroup.NUMPAD, gestureActionGroup("key_KP_ENTER")) + assertEquals(GestureActionGroup.MODIFIERS, gestureActionGroup("key_CTRL_R")) + } + + @Test fun searchCombinesWordsAndCategoryWithoutChangingTheSelection() { + val actions = listOf( + GestureAction("key_ENTER", "Enter", GestureActionGroup.NAVIGATION), + GestureAction("key_KP_ENTER", "Numpad Enter", GestureActionGroup.NUMPAD), + GestureAction("key_KP_1", "Numpad 1", GestureActionGroup.NUMPAD), + ) + assertEquals(listOf(actions[1]), filterGestureActions(actions, " ENTER numpad ", null)) + assertEquals(listOf(actions[1]), filterGestureActions(actions, "enter", GestureActionGroup.NUMPAD)) + assertTrue(filterGestureActions(actions, "not a key", null).isEmpty()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt new file mode 100644 index 000000000..e70caf7dd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GestureLocalizationTest.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.* +import androidx.compose.ui.unit.Density +import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.GesturePrefs +import com.droiddeck.launcher.input.TouchGestureConfig +import com.droiddeck.launcher.input.TouchscreenGestures.Gesture +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, qualifiers = "w400dp-h850dp-port") +class GestureLocalizationTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + + @Before fun clearPrefs() { + context.getSharedPreferences("touch_gestures", 0).edit().clear().commit() + } + + @Test + @Config(qualifiers = "fr-rFR-w400dp-h850dp-port") + fun pageKeysAndModifierNamesUseLocalizedResources() { + compose.setContent { + DroidDeckTheme { + Column { + Text(gestureActionTitle("page_up_down")) + Text(gestureActionTitle("key_SHIFT_L")) + } + } + } + compose.onNodeWithText("Page précédente / Page suivante").assertIsDisplayed() + compose.onNodeWithText("Maj gauche").assertIsDisplayed() + } + + @Test + @Config(qualifiers = "de-rDE-w400dp-h850dp-port") + fun longTranslationsKeepDefaultRestorationAndSaveUsable() { + GesturePrefs.save(context, "123", TouchGestureConfig(tapAction = "key_E")) + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { + DroidDeckTheme { TouchGestureDialog("123", {}, {}) } + } + } + val restore = compose.onNodeWithTag("gesture-inherit") + restore.assertIsDisplayed() + val scope = compose.onNodeWithTag("gesture-scope-game").fetchSemanticsNode().boundsInRoot + assertTrue("The restore action must have room for its translated label", restore.fetchSemanticsNode().boundsInRoot.width > scope.width) + restore.performClick() + compose.onNodeWithTag("gesture-save").assertIsDisplayed().performClick() + compose.runOnIdle { assertFalse(GesturePrefs.hasOverride(context, "123")) } + } + + @Test + @Config(qualifiers = "ar-rEG-w400dp-h850dp-port") + fun arabicPickerSearchPreservesBindingsAndMirrorsFooter() { + assertEquals(View.LAYOUT_DIRECTION_RTL, context.resources.configuration.layoutDirection) + compose.setContent { DroidDeckTheme { TouchGestureDialog(null, {}, {}) } } + compose.onNodeWithText("إعدادات إيماءات اللمس").assertIsDisplayed() + val save = compose.onNodeWithTag("gesture-save").fetchSemanticsNode().boundsInRoot + val cancel = compose.onNodeWithText(context.getString(R.string.gesture_cancel)).fetchSemanticsNode().boundsInRoot + assertTrue("Save $save and Cancel $cancel should follow the right-to-left layout", save.left < cancel.left) + compose.onNodeWithTag("gesture-choice-tap").performScrollTo().performClick() + compose.onNodeWithTag("gesture-action-search").performTextInput("هروب") + compose.onNodeWithTag("gesture-action-key_ESC").assertIsDisplayed().performClick() + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { assertEquals("key_ESC", GesturePrefs.read(context).tapAction) } + } + + @Test + @Config(qualifiers = "ja-rJP-w400dp-h850dp-port") + fun recognizedGestureFeedbackUsesTheActiveLanguage() { + compose.setContent { DroidDeckTheme { GestureFeedback(Gesture.TWO_FINGER_HOLD) } } + compose.onNodeWithText("2本指で長押し").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/TouchGestureDialogTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/TouchGestureDialogTest.kt new file mode 100644 index 000000000..3189deba6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/TouchGestureDialogTest.kt @@ -0,0 +1,112 @@ +package com.droiddeck.launcher.ui + +import android.app.Application +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.test.* +import com.droiddeck.launcher.input.GesturePrefs +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, qualifiers = "w960dp-h640dp-land") +class TouchGestureDialogTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + + @Before fun clearPrefs() { + context.getSharedPreferences("touch_gestures", 0).edit().clear().commit() + } + + @Test fun searchSelectAndSaveAKeyboardAction() { + compose.setContent { DroidDeckTheme { TouchGestureDialog(null, {}, {}) } } + compose.onNodeWithTag("gesture-choice-tap").performScrollTo().performClick() + compose.onNodeWithTag("gesture-action-left_click").assertIsSelected() + compose.onNodeWithTag("gesture-action-search").performTextInput("numpad enter") + compose.onNodeWithTag("gesture-action-key_KP_ENTER").assertIsDisplayed().performClick() + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { assertEquals("key_KP_ENTER", GesturePrefs.read(context).tapAction) } + } + + @Test fun switchingTabsKeepsEditsAndLetsTwoFingerPanBeChanged() { + compose.setContent { DroidDeckTheme { TouchGestureDialog(null, {}, {}) } } + compose.onNodeWithTag("gesture-tab-1").performClick() + compose.onNodeWithTag("gesture-choice-two-drag").performScrollTo().performClick() + compose.onNodeWithTag("gesture-action-search").performTextInput("middle mouse pan") + compose.onNodeWithTag("gesture-action-middle_mouse_pan").performClick() + compose.onNodeWithTag("gesture-tab-3").performClick() + compose.onNodeWithTag("gesture-tab-1").performClick() + compose.onNodeWithText("Middle mouse pan").performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { assertEquals("middle_mouse_pan", GesturePrefs.read(context).twoFingerDragAction) } + } + + @Test fun closingWithoutSavingDiscardsTheDraft() { + val open = mutableStateOf(true) + compose.setContent { DroidDeckTheme { if (open.value) TouchGestureDialog(null, { open.value = false }, {}) } } + chooseTap("Escape", "key_ESC") + compose.onNodeWithContentDescription("Close").performClick() + compose.runOnIdle { assertEquals("left_click", GesturePrefs.read(context).tapAction) } + } + + @Test fun defaultsAndGameDraftsSurviveScopeChangesAndSaveTogether() { + compose.setContent { DroidDeckTheme { TouchGestureDialog("123", {}, {}) } } + chooseTap("numpad enter", "key_KP_ENTER") + compose.onNodeWithTag("gesture-scope-default").performClick() + chooseTap("Escape", "key_ESC") + compose.onNodeWithTag("gesture-scope-game").performClick() + compose.onNodeWithTag("gesture-choice-tap").performScrollTo() + compose.onNodeWithText("Numpad Enter").assertIsDisplayed() + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { + assertEquals("key_ESC", GesturePrefs.read(context).tapAction) + assertEquals("key_KP_ENTER", GesturePrefs.read(context, "123").tapAction) + } + } + + @Test fun aFocusChangeCannotRetargetAnOpenGameEditor() { + val game = mutableStateOf("123") + compose.setContent { DroidDeckTheme { TouchGestureDialog(game.value, {}, {}) } } + chooseTap("Escape", "key_ESC") + compose.runOnIdle { + game.value = "456" + Snapshot.sendApplyNotifications() + } + compose.onNodeWithTag("gesture-save").performClick() + compose.runOnIdle { + assertEquals("key_ESC", GesturePrefs.read(context, "123").tapAction) + assertFalse(GesturePrefs.hasOverride(context, "456")) + } + } + + @Test + @Config(qualifiers = "w400dp-h850dp-port") + fun aNarrowWindowWithLargeTextKeepsNavigationAndSaveReachable() { + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { + DroidDeckTheme { TouchGestureDialog("123", {}, {}) } + } + } + compose.onNodeWithContentDescription("Close").assertIsDisplayed() + compose.onNodeWithTag("gesture-save").assertIsDisplayed() + compose.onNodeWithTag("gesture-tab-3").performScrollTo().performClick() + compose.onNodeWithText("Mouse movement").performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("gesture-save").assertIsDisplayed() + } + + private fun chooseTap(search: String, id: String) { + compose.onNodeWithTag("gesture-choice-tap").performScrollTo().performClick() + compose.onNodeWithTag("gesture-action-search").performTextInput(search) + compose.onNodeWithTag("gesture-action-$id").performClick() + } +} diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index e836eaad1..863a1003b 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -1164,7 +1164,9 @@ class BundleTest(unittest.TestCase): bundle = steps["Bundle the droiddeck-esync packs"] self.assertNotIn("if", bundle) self.assertIn("tools/droiddeck-esync/release.env", bundle["run"]) - self.assertEqual(bundle["run"].count('-R "$SYNC_BUNDLE_REPO"'), 2) + self.assertIn('release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"', bundle["run"]) + self.assertIn('curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"', bundle["run"]) + self.assertIn('curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"', bundle["run"]) self.assertIn("sha256sum -c -", bundle["run"]) self.assertNotIn("revoked.txt", bundle["run"]) stage = next(step for step in apk["jobs"]["build"]["steps"] if "overlay/usr/local/bin/droiddeck-*" in step.get("run", "")) From 6b8ce9e675f76c91617ad56a59cc1043e0a1a641 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:40:30 -0400 Subject: [PATCH 037/156] fix: an interrupted package install can no longer break the Linux runtime (#1078) --- .../com/droiddeck/launcher/MainActivity.kt | 36 +++++- .../com/droiddeck/launcher/SessionActivity.kt | 58 +++++++-- .../droiddeck/launcher/core/DeviceReport.kt | 3 + .../launcher/runtime/DesktopCatalog.kt | 98 ++++++++++++--- .../runtime/LinuxRuntimeInstaller.java | 45 +++++-- .../launcher/runtime/RuntimeHealth.kt | 34 +++++ .../launcher/runtime/RuntimeInstallService.kt | 48 ++++++- .../com/droiddeck/launcher/ui/MainScreen.kt | 16 +++ .../droiddeck/launcher/ui/SessionLoading.kt | 11 +- app/src/main/res/values/strings.xml | 8 ++ .../launcher/runtime/DesktopCatalogTest.kt | 26 ++++ .../launcher/runtime/PackageExtractTest.kt | 118 ++++++++++++++++++ .../launcher/runtime/RuntimeHealthTest.kt | 36 ++++++ 13 files changed, 500 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ec5ff76c4..7aab33d1f 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() { private var frameGenLabel by mutableStateOf("") private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) + private var showRuntimeActions by mutableStateOf(null) private var pendingSwitch by mutableStateOf(null) private var switchingTo: Intent? = null private var switchPoll: Runnable? = null @@ -1124,6 +1125,13 @@ class MainActivity : ComponentActivity() { onDismiss = { pendingSwitch = null }, ) } + showRuntimeActions?.let { release -> + com.droiddeck.launcher.ui.RuntimeActionsDialog( + onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) }, + onRemove = { showRuntimeActions = null; showRemove = true }, + onDismiss = { showRuntimeActions = null }, + ) + } if (showRemove) ConfirmDialog( title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title), text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text), @@ -1280,6 +1288,7 @@ class MainActivity : ComponentActivity() { syncLossless() updates.onResume() drivers.checkOnOpen() + resumeInterruptedPackages() protons.checkUpdates() if (!SessionState.running) Thread({ val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) @@ -1533,6 +1542,27 @@ class MainActivity : ComponentActivity() { .putExtra(SessionService.EXTRA_PROGRAM, path)) } + private fun resumeInterruptedPackages() { + if (runtimeChangesBlocked()) return + val first = DesktopCatalog.interrupted(this).firstOrNull() + if (first == null) { + Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start() + return + } + pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1 + Thread({ + val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent -> + ui.post { pkgStage = stage; pkgPercent = percent } + } + ui.post { + pkgStage = null; pkgId = null + if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show() + refreshPackages() + refresh() + } + }, "resume-pkg").start() + } + private fun installPackage(id: String) { val entry = catalog?.firstOrNull { it.id == id } ?: return if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return @@ -1979,10 +2009,14 @@ class MainActivity : ComponentActivity() { private fun onRuntimeButton() { if (runtimeChangesBlocked()) return val release = available - if (removalPending || installed != null && release?.version == installed) { + if (removalPending) { showRemove = true return } + if (installed != null && release?.version == installed) { + showRuntimeActions = release + return + } if (release == null) { Thread({ checkCatalog() }, "catalog").start() return diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 510fd7795..d3f18ad6e 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -527,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM) if (!SessionState.running) { - val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null + val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false) + intent.removeExtra(EXTRA_REPAIR_RUNTIME) + val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null + val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty() val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP && - !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this) + (repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)) val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM && com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this) - if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton) + if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume) } hud = PerfHud(this) hud.onPresentingWindowChanged = { @@ -662,6 +665,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onCancel = { SessionService.stop(this@SessionActivity); finish() }, endedDetail = loading.endedDetail, onRetry = { retrySession() }, + onRepair = if (repairOffered) ({ repairRuntime() }) else null, onShareLogs = { shareCurrentSessionLogs() }, onClose = { finish() }, flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) }, @@ -1375,17 +1379,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { @Volatile private var installingRuntime = false + private var repairOffered by mutableStateOf(false) - private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) { + private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) { if (SessionState.stopRequested) return installingRuntime = true - SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton" + SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton" SessionEvents.transition( SessionPhase.INSTALLING_RUNTIME, "${SessionState.installing}.installing", mapOf("component" to SessionState.installing), ) - val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES + val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES loading.say(getString(first.downloading), first.topic, readable = true) loading.percent = -1 Thread({ @@ -1395,6 +1400,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { problem = installRuntime() if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime" } + if (problem == null && resume && !SessionState.stopRequested) { + SessionState.installing = "package" + SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package")) + problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs()) + ?.let { getString(R.string.session_package_resume_failed, it) } + if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package" + } if (problem == null && desktop && !SessionState.stopRequested) { SessionState.installing = "desktop" SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop")) @@ -1419,7 +1431,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return@post } if (problem != null) { - val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED" + val code = when (failedComponent) { + "runtime" -> "RUNTIME_INSTALL_FAILED" + "package" -> "PACKAGE_RESUME_FAILED" + else -> "DESKTOP_INSTALL_FAILED" + } SessionEvents.fail(code, problem) collectStartArtifacts("session start failed") loading.showEnded(problem) @@ -1467,9 +1483,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent -> + uiHandler.post { + loading.percent = percent + loading.say(stage, LoadingState.Topic.RUNTIME, readable = true) + } + } + private fun installRuntime(): String? { val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease() ?: return getString(R.string.session_runtime_catalog_unreachable) + runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) } val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release, progressFor(RUNTIME_LINES, release.size / 1_000_000)) return if (ok) null else getString(R.string.session_runtime_install_failed) @@ -1784,7 +1808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return } Thread({ - val hint = sessionEndHint(SessionState.logFile) + val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile) runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread showEnded(status, hint) @@ -1803,6 +1827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop) else -> getString(R.string.session_ended_named, "Steam") } + repairOffered = hint?.repair == true loading.showEnded( hint?.text ?: stopped, getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)), @@ -1821,6 +1846,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { uiHandler.post { sessionOverlay.requestFocus() } } + private fun repairRuntime() { + val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true) + closeAtOnce = true + finish() + startActivity(again) + } + private fun retrySession() { val again = Intent(intent) closeAtOnce = true @@ -1828,7 +1860,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { startActivity(again) } - private class EndHint(val text: String, val evenOnSuccess: Boolean = false) + private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false) + + private fun runtimeDamageHint(): EndHint? { + val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList()) + if (broken.isEmpty()) return null + Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}") + return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true) + } private fun sessionEndHint(log: File?): EndHint? { if (log == null || !log.isFile) return null @@ -3327,6 +3366,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { companion object { const val EXTRA_RETURN_HOME = "returnHome" const val EXTRA_STEAMVR = "steamVr" + const val EXTRA_REPAIR_RUNTIME = "repairRuntime" private const val LEAVE_TIMEOUT_MS = 1_500L private const val TAG = "SessionActivity" private var startedScreens = 0 diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index aad57eaef..2dfa6ea3f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -225,6 +225,9 @@ object DeviceReport { k("Installed version", LinuxRuntimeInstaller.installedVersion(context)) k("Runtime ready", LinuxRuntime.isInstalled(context)) k("Desktop installed", DesktopCatalog.desktopInstalled(context)) + k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" }) + val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList()) + k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}") k("Runtime root", LinuxRuntime.rootDir(context).path) h("Settings in effect") diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 3fad171f2..73e6a75e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -47,6 +47,53 @@ object DesktopCatalog { private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id") + private const val JOURNAL_SUFFIX = ".installing" + + private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX") + + /** Packages whose extraction over the runtime started and never finished. */ + fun interrupted(context: Context): List = interrupted(LinuxRuntime.rootDir(context)) + + fun interrupted(root: File): List = + root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty() + .sortedBy { it.name } + .mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } } + + private val installLock = Any() + + private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download") + + /** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */ + fun pruneDownloads(context: Context) = synchronized(installLock) { + val keep = interrupted(context).map { downloadFile(context, it).name }.toSet() + context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") } + ?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() } + } + + fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + for (entry in interrupted(context)) { + Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}") + install(context, entry, listener)?.let { return it } + } + null + } + + internal fun entryJson(e: Entry): JSONObject = JSONObject() + .put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind) + .put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes) + .put("icon", e.icon).put("category", e.category) + + internal fun entryFrom(o: JSONObject) = Entry( + o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"), + o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""), + o.optString("icon", "applications-games"), o.optString("category", "Game"), + ) + + /** A finished download is kept until its extraction completes, so resuming does not fetch it again. */ + private fun downloaded(file: File, entry: Entry): Boolean = + file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() && + (entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true) + fun installed(context: Context, id: String): String? = FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() } @@ -82,22 +129,38 @@ object DesktopCatalog { fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? = DesktopRemoval.remove(context, listener) - fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? { + fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) { + installLocked(context, entry, listener) + } + + private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? { val root = LinuxRuntime.rootDir(context) if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing) if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name) - val download = File(context.cacheDir, "pkg-${entry.id}.download") - try { - val downloading = context.getString(R.string.user_apps_downloading, entry.name) - listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0) - val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f -> - listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f)) + val download = downloadFile(context, entry) + val journal = journal(root, entry.id) + var keepDownload = false + val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name)) + val report = object : LinuxRuntimeInstaller.ProgressListener { + override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent) + override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) { + RuntimeInstallService.packageProgress(stage, percent) + listener?.onProgress(step, stage, percent) } - if (!ok) return context.getString(R.string.user_apps_download_failed) - listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1) - val actual = Hashes.sha256(download) - if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch) - listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1) + } + try { + if (!downloaded(download, entry)) { + val downloading = context.getString(R.string.user_apps_downloading, entry.name) + report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0) + val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f -> + report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f)) + } + if (!ok) return context.getString(R.string.user_apps_download_failed) + report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1) + val actual = Hashes.sha256(download) + if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch) + } + report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1) when (entry.kind) { "appimage" -> { val dir = File(root, "opt/appimages").apply { mkdirs() } @@ -112,23 +175,28 @@ object DesktopCatalog { "Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n") } else -> { + check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" } + keepDownload = true marker(context, entry.id).delete() - if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed) + if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed) } } if (entry.id == DESKTOP_ID) { - cleanLegacy(context, listener) + cleanLegacy(context, report) val pending = File(root, DesktopRemoval.PENDING) check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" } } FileUtils.writeString(marker(context, entry.id), entry.version) check(installed(context, entry.id) == entry.version) { "Could not finish package installation" } + check(!journal.exists() || journal.delete()) { "Could not finish package installation" } + keepDownload = false return null } catch (e: Exception) { Log.e(TAG, "install ${entry.id}", e) return e.message ?: context.getString(R.string.deskpkg_install_failed) } finally { - download.delete() + if (!keepDownload) download.delete() + release() } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index efecb6fb2..8296217cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -456,9 +456,12 @@ public final class LinuxRuntimeInstaller { } } + // Packages extract over the live runtime and their archives list a link before the file it + // names, so a kill mid-way must leave neither a truncated file nor a link to a missing one. public static boolean extract(Context context, File archive, File destination, ProgressListener listener) { long entries = 0; String extracting = context.getString(R.string.rtinst_extracting); + java.util.LinkedHashMap links = new java.util.LinkedHashMap<>(); try (InputStream in = new ZstdCompressorInputStream( new BufferedInputStream(new FileInputStream(archive), 1 << 16)); TarArchiveInputStream tar = new TarArchiveInputStream(in)) { @@ -477,29 +480,35 @@ public final class LinuxRuntimeInstaller { if (parent != null && !parent.isDirectory()) parent.mkdirs(); if (entry.isSymbolicLink()) { - file.delete(); - FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath()); + links.remove(file); + links.put(file, entry.getLinkName()); } else if (entry.isLink()) { File target = ArchivePaths.inside(destination, entry.getLinkName()); if (target == null) { Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName()); continue; } - file.delete(); + links.remove(file); + File part = partFile(file); + part.delete(); try { - Files.createLink(file.toPath(), target.toPath()); + Files.createLink(part.toPath(), target.toPath()); } catch (IOException | UnsupportedOperationException e) { if (target.isFile()) { - Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING); } } + if (part.exists()) place(part, file); } else if (entry.isFile()) { - try (OutputStream out = new FileOutputStream(file)) { + links.remove(file); + File part = partFile(file); + try (OutputStream out = new FileOutputStream(part)) { byte[] buffer = new byte[1 << 16]; int read; while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read); } - if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false); + if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false); + place(part, file); } else { continue; } @@ -507,10 +516,32 @@ public final class LinuxRuntimeInstaller { listener.onProgress(extracting, -1); } } + for (java.util.Map.Entry link : links.entrySet()) { + File file = link.getKey(); + File part = partFile(file); + try { + Files.deleteIfExists(part.toPath()); + Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue())); + place(part, file); + } catch (IOException e) { + Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e); + part.delete(); + } + } return true; } catch (Exception e) { Log.e(TAG, "extract", e); return false; } } + + static File partFile(File file) { + return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode())); + } + + private static void place(File part, File file) throws IOException { + java.nio.file.Path path = file.toPath(); + if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path); + Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt new file mode 100644 index 000000000..19ca93d5f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt @@ -0,0 +1,34 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +object RuntimeHealth { + private const val MAX_HOPS = 16 + + fun brokenLibraryLinks(context: Context): List = brokenLibraryLinks(LinuxRuntime.rootDir(context)) + + /** A complete runtime has no library link in usr/lib whose target is missing. */ + fun brokenLibraryLinks(root: File): List { + val lib = File(root, "usr/lib") + val names = lib.list() ?: return emptyList() + return names.sorted().filter { name -> + val path = File(lib, name).toPath() + Files.isSymbolicLink(path) && !resolves(root, path) + } + } + + private fun resolves(root: File, link: Path): Boolean = try { + var path = link + var hops = 0 + while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) { + val target = Files.readSymbolicLink(path) + path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target) + } + !Files.isSymbolicLink(path) && Files.exists(path) + } catch (e: Exception) { + false + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index 95a96b899..d2cee0435 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -19,6 +19,7 @@ class RuntimeInstallService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_PACKAGE) return holdPackage(startId) val removal = intent?.action == ACTION_REMOVE val release = intent?.let(::releaseFrom) startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1)) @@ -44,7 +45,27 @@ class RuntimeInstallService : Service() { return START_NOT_STICKY } - private fun notification(stage: String, percent: Int): Notification { + private fun holdPackage(startId: Int): Int { + startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle)) + Thread({ + val manager = getSystemService(NotificationManager::class.java) + var shown = "" + while (packageHolds.get() > 0) { + val now = "$packageStage:$packagePercent" + if (now != shown) { + shown = now + manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle)) + } + Thread.sleep(500) + } + @Suppress("DEPRECATION") + stopForeground(true) + stopSelf(startId) + }, "package-install").start() + return START_NOT_STICKY + } + + private fun notification(stage: String, percent: Int, title: String? = null): Notification { val manager = getSystemService(NotificationManager::class.java) manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime), NotificationManager.IMPORTANCE_LOW).apply { @@ -58,7 +79,7 @@ class RuntimeInstallService : Service() { PendingIntent.FLAG_IMMUTABLE) return Notification.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_session) - .setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing)) + .setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing)) .setContentText(stage) .setProgress(100, percent.coerceIn(0, 100), percent < 0) .setContentIntent(open) @@ -72,6 +93,11 @@ class RuntimeInstallService : Service() { private const val CHANNEL_ID = "runtime-install" private const val NOTIFICATION_ID = 2 private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME" + private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE" + private val packageHolds = java.util.concurrent.atomic.AtomicInteger() + @Volatile private var packageTitle = "" + @Volatile private var packageStage = "" + @Volatile private var packagePercent = -1 private const val EXTRA_VERSION = "version" private const val EXTRA_URL = "url" private const val EXTRA_SHA256 = "sha256" @@ -91,6 +117,24 @@ class RuntimeInstallService : Service() { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } + /** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */ + fun holdForPackage(context: Context, title: String): () -> Unit { + packageTitle = title + packageStage = title + packagePercent = -1 + packageHolds.incrementAndGet() + val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE) + runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } + .onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") } + val released = java.util.concurrent.atomic.AtomicBoolean() + return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() } + } + + fun packageProgress(stage: String, percent: Int) { + packageStage = stage + packagePercent = percent + } + private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? { val version = intent.getStringExtra(EXTRA_VERSION) ?: return null val url = intent.getStringExtra(EXTRA_URL) ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt index ac9a0524a..0f4483c0a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt @@ -66,6 +66,22 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> ) } +@Composable +fun RuntimeActionsDialog(onRepair: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.runtime_actions_title)) }, + text = { Text(stringResource(R.string.runtime_actions_text)) }, + confirmButton = { TextButton(onClick = onRepair) { Text(stringResource(R.string.runtime_repair)) } }, + dismissButton = { + Row { + TextButton(onClick = onRemove) { Text(stringResource(R.string.store_remove)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } + } + }, + ) +} + @Composable fun ResetSettingsDialog(onConfirm: (gameProfiles: Boolean) -> Unit, onDismiss: () -> Unit) { var gameProfiles by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt index 3cb47b070..bf41dcf0b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -122,6 +122,7 @@ fun LoadingOverlay( title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null, endedDetail: String? = null, onRetry: (() -> Unit)? = null, + onRepair: (() -> Unit)? = null, onShareLogs: (() -> Unit)? = null, onClose: (() -> Unit)? = null, flood: Color? = null, @@ -187,7 +188,7 @@ fun LoadingOverlay( textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp), ) - if (ended) EndedActions(onRetry, onShareLogs, onClose) + if (ended) EndedActions(onRetry, onRepair, onShareLogs, onClose) } if (!ended && hint.isNotEmpty()) Text( hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, @@ -200,7 +201,7 @@ fun LoadingOverlay( } @Composable -private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) { +private fun EndedActions(onRetry: (() -> Unit)?, onRepair: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) { val first = remember { FocusRequester() } LaunchedEffect(Unit) { androidx.compose.runtime.withFrameNanos { } @@ -209,7 +210,11 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) { var firstUsed = false fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) } - if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + if (onRepair != null) PrimaryButton(stringResource(R.string.runtime_repair), modifier = claim(), onClick = onRepair) + if (onRetry != null) { + if (onRepair != null) SecondaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + else PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry) + } if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs) if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 021c3217c..ff90ecb9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1247,6 +1247,9 @@ Install anyway Remove Linux runtime This deletes the runtime, the Steam client inside it, and every game installed there. + Linux runtime + Repair reinstalls the runtime\'s system files and keeps Steam, your games and settings. Extras such as the desktop install again when you next use them. Remove deletes everything. + Repair runtime Applied: %1$s Could not open %1$s on the primary screen Could not open %1$s on %2$s @@ -1376,6 +1379,11 @@ Could not reach the runtime catalog. Check the connection and press Play again. The Linux runtime did not install. Check the runtime in Setup and press Play again. The desktop did not install (%1$s). Check the connection and press Desktop again. + An install that was interrupted could not be finished (%1$s). Check the connection and space, then try again. + + The Linux runtime is damaged: %1$d library link points to a missing file (%2$s), usually after an install was interrupted. Repair keeps your games and settings. + The Linux runtime is damaged: %1$d library links point to missing files (%2$s and others), usually after an install was interrupted. Repair keeps your games and settings. + %1$s stopped unexpectedly. Share the logs with a bug report, or try again. The program stopped unexpectedly. Share the logs with a bug report, or try again. The desktop stopped unexpectedly. Share the logs with a bug report, or try again. diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt index f59263ac5..5738b50c4 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/DesktopCatalogTest.kt @@ -2,12 +2,16 @@ package com.droiddeck.launcher.runtime import com.droiddeck.launcher.BuildConfig import java.io.File +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class DesktopCatalogTest { @get:Rule val temporary = TemporaryFolder() @@ -30,4 +34,26 @@ class DesktopCatalogTest { kwin.delete() assertFalse(DesktopCatalog.desktopInstalled(root)) } + + @Test fun anInterruptedInstallIsFoundAgainWithEverythingNeededToFinishIt() { + val root = temporary.root + val entry = DesktopCatalog.Entry( + DesktopCatalog.DESKTOP_ID, "Desktop", 1, "desktop-kde-r2", "tar", + "https://example.invalid/desktop-kde.tar.zst", "a".repeat(64), 846933224L, "", "", "", + ) + assertTrue(DesktopCatalog.interrupted(root).isEmpty()) + File(root, ".droiddeck-pkg-${entry.id}.installing").writeText(DesktopCatalog.entryJson(entry).toString()) + File(root, ".droiddeck-pkg-emulators.installing").writeText("not json") + File(root, ".droiddeck-pkg-emulators").writeText("1") + + val found = DesktopCatalog.interrupted(root).single() + + assertEquals(entry.id, found.id) + assertEquals(entry.version, found.version) + assertEquals(entry.url, found.url) + assertEquals(entry.sha256, found.sha256) + assertEquals(entry.size, found.size) + assertEquals(entry.kind, found.kind) + } } + diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt new file mode 100644 index 000000000..fb4e908c1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/PackageExtractTest.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.runtime + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class PackageExtractTest { + private val dir = Files.createTempDirectory("package-extract").toFile() + private val root = File(dir, "linuxfs").apply { mkdirs() } + private val context = RuntimeEnvironment.getApplication() + + @After fun tearDown() { dir.deleteRecursively() } + + private sealed class Item(val name: String) { + class Link(name: String, val target: String) : Item(name) + class Data(name: String, val text: String) : Item(name) + } + + private fun archive(vararg items: Item): File { + val tar = ByteArrayOutputStream() + TarArchiveOutputStream(tar).use { out -> + for (item in items) { + when (item) { + is Item.Link -> out.putArchiveEntry(TarArchiveEntry(item.name, TarArchiveEntry.LF_SYMLINK).apply { linkName = item.target }) + is Item.Data -> { + val bytes = item.text.toByteArray() + out.putArchiveEntry(TarArchiveEntry(item.name).apply { size = bytes.size.toLong(); mode = 0b111_101_101 }) + out.write(bytes) + } + } + out.closeArchiveEntry() + } + } + return File(dir, "pkg-${System.nanoTime()}.tar.zst").apply { + ZstdCompressorOutputStream(outputStream()).use { it.write(tar.toByteArray()) } + } + } + + private fun lib(name: String) = File(root, "usr/lib/$name") + + private fun installOld() { + lib("").mkdirs() + lib("libvulkan.so.1.4.357").writeText("old") + Files.createSymbolicLink(lib("libvulkan.so.1").toPath(), Paths.get("libvulkan.so.1.4.357")) + } + + @Test fun linksListedBeforeTheirTargetsResolveAfterExtraction() { + installOld() + val pkg = archive( + Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"), + Item.Data("usr/lib/libvulkan.so.1.4.363", "new"), + ) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertEquals("new", lib("libvulkan.so.1").readText()) + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(root)) + assertFalse(lib("").list()!!.any { it.startsWith(".dd-part-") }) + } + + @Test fun anExtractionStoppedBetweenALinkAndItsTargetLeavesTheOldLinkWorking() { + installOld() + val pkg = archive( + Item.Link("usr/lib/libvulkan.so.1", "libvulkan.so.1.4.363"), + Item.Data("usr/lib/libharfbuzz.so.0", "harfbuzz"), + Item.Data("usr/lib/libharfbuzz.so.0/cannot-be-written", "stops here"), + Item.Data("usr/lib/libvulkan.so.1.4.363", "new"), + ) + + assertFalse(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertEquals("old", lib("libvulkan.so.1").readText()) + assertFalse(lib("libvulkan.so.1.4.363").exists()) + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(root)) + } + + @Test fun aFileReplacesAnExistingLinkInsteadOfWritingThroughIt() { + installOld() + val pkg = archive(Item.Data("usr/lib/libvulkan.so.1", "now a file")) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertFalse(Files.isSymbolicLink(lib("libvulkan.so.1").toPath())) + assertEquals("now a file", lib("libvulkan.so.1").readText()) + assertEquals("old", lib("libvulkan.so.1.4.357").readText()) + assertTrue(lib("libvulkan.so.1").canExecute()) + } + + @Test fun theLastEntryForAPathWins() { + lib("").mkdirs() + lib("target").writeText("t") + val pkg = archive( + Item.Link("usr/lib/libdup.so", "target"), + Item.Data("usr/lib/libdup.so", "file"), + Item.Data("usr/lib/libother.so", "file"), + Item.Link("usr/lib/libother.so", "target"), + ) + + assertTrue(LinuxRuntimeInstaller.extract(context, pkg, root, null)) + + assertFalse(Files.isSymbolicLink(lib("libdup.so").toPath())) + assertEquals("file", lib("libdup.so").readText()) + assertTrue(Files.isSymbolicLink(lib("libother.so").toPath())) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt new file mode 100644 index 000000000..4f0b95264 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeHealthTest.kt @@ -0,0 +1,36 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class RuntimeHealthTest { + @get:Rule val temporary = TemporaryFolder() + + private fun link(root: File, name: String, target: String) = + Files.createSymbolicLink(File(root, "usr/lib/$name").toPath(), Paths.get(target)) + + @Test fun reportsOnlyLibraryLinksWhoseTargetIsMissingInsideTheRuntime() { + val root = temporary.root + val lib = File(root, "usr/lib").apply { mkdirs() } + File(lib, "libvulkan.so.1.4.357").writeText("lib") + File(root, "etc").mkdirs() + File(root, "etc/ld.conf").writeText("conf") + link(root, "libvulkan.so.1", "libvulkan.so.1.4.357") + link(root, "libvulkan.so", "libvulkan.so.1") + link(root, "libabsolute.so", "/etc/ld.conf") + link(root, "libharfbuzz.so.0", "libharfbuzz.so.0.61460.0") + link(root, "libchain.so", "libharfbuzz.so.0") + link(root, "libhost.so", File(lib, "libvulkan.so.1.4.357").absolutePath) + + assertEquals(listOf("libchain.so", "libharfbuzz.so.0", "libhost.so"), RuntimeHealth.brokenLibraryLinks(root)) + } + + @Test fun aMissingLibraryFolderIsNotReportedAsDamage() { + assertEquals(emptyList(), RuntimeHealth.brokenLibraryLinks(temporary.root)) + } +} From 68b4c87d9a68a72f9eb3317b279c35c0e1e9ec19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:50:29 +0300 Subject: [PATCH 038/156] fix: keep held Steam Touch controls pressed across action-set and layer changes (#1143) --- .../launcher/input/SteamTouchConfig.kt | 5 ++++ .../launcher/input/SteamTouchControls.kt | 13 ++++++++--- .../launcher/input/SteamTouchConfigTest.kt | 23 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt index 4025db934..12692bfa9 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt @@ -333,6 +333,11 @@ object SteamTouchConfig { }.sortedBy { it.type } } + fun remapHeld(held: Map, next: List): Map { + val byType = next.associateBy { it.type } + return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap() + } + fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt index 5a717150e..dee510787 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -154,7 +154,7 @@ class SteamTouchControls( actionSet = state.actionSet layers = state.layers appId = app - rebuild() + rebuild(keepHeld = true) } } @@ -175,7 +175,7 @@ class SteamTouchControls( refreshState() } - private fun rebuild() { + private fun rebuild(keepHeld: Boolean = false) { elements = SteamTouchConfig.elementsFor(config, actionSet, layers) val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options() if (nextOptions != options) { options = nextOptions; onOptions(options) } @@ -183,7 +183,7 @@ class SteamTouchControls( rebuildVisuals() if (!editing) { Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls") - releaseAll() + if (keepHeld) keepHeldFingers() else releaseAll() } invalidate() } @@ -590,6 +590,13 @@ class SteamTouchControls( invalidate() } + private fun keepHeldFingers() { + val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live()) + fingers.keys.retainAll(moved.keys) + fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) } + publish() + } + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { diff --git a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt index 8addf24e0..f34e0fc8a 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt @@ -65,4 +65,27 @@ class SteamTouchConfigTest { val config = SteamTouchConfig.Config(null, null, null, emptyMap(), kv) assertEquals(listOf(1 to "Desktop", 2 to "Gamepad"), SteamTouchConfig.actionSets(config)) } + + @Test + fun remapHeldKeepsSurvivingControlsAndDropsRemovedOrNullOnes() { + val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved)) + val baseConfig = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to setOf(SteamTouchConfig.A, SteamTouchConfig.JOYSTICK_LEFT))) + val layerConfig = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to setOf(SteamTouchConfig.A), 1 to setOf(SteamTouchConfig.B))) + val after = SteamTouchConfig.elementsFor(layerConfig, 0, listOf(2)) + + val remapped = SteamTouchConfig.remapHeld( + mapOf( + 7 to SteamTouchConfig.A, + 8 to SteamTouchConfig.JOYSTICK_LEFT, + 9 to null, + 10 to SteamTouchConfig.B, + ), + after, + ) + + assertEquals(setOf(7, 10), remapped.keys) + assertEquals(after.first { it.type == SteamTouchConfig.A }, remapped[7]) + assertEquals(after.first { it.type == SteamTouchConfig.B }, remapped[10]) + assertFalse(baseConfig.availableFor(0, emptyList()).isEmpty()) + } } From 0953a38765c434d32df325ed83be285f3054526d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:51:11 +0300 Subject: [PATCH 039/156] fix: confirm before bulk-removing unused components in Storage (#1140) --- .../com/droiddeck/launcher/ui/StoragePage.kt | 18 +++- .../ui/StorageComponentsBulkConfirmTest.kt | 95 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/StorageComponentsBulkConfirmTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index 7c1e45ffa..8b1ec5340 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -781,17 +781,18 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack: } @Composable -private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) { +internal fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) { val ctx = LocalContext.current val pal = LocalPalette.current val colors = MaterialTheme.colorScheme val busy = sessionRunning || StorageState.cleaning var confirm by remember { mutableStateOf(null) } + var confirmAll by remember { mutableStateOf(false) } val unused = report.unusedComponents SubHeader(stringResource(R.string.storage_components), stringResource(R.string.storage_tools_detail, gb(report.components.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack) { if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true, enabled = !busy) { - StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } + confirmAll = true } } ActionStatus() @@ -824,6 +825,19 @@ private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBac stringResource(if (c.packageFile != null) R.string.storage_component_confirm else R.string.storage_original_confirm, gb(c.bytes)), stringResource(R.string.storage_remove), onConfirm = { StorageState.removeComponent(ctx, c) }, onDismiss = { confirm = null }) } + if (confirmAll) { + val total = gb(unused.sumOf { it.bytes }) + val lines = unused.map { "• " + componentLabel(it) + " " + gb(it.bytes) }.joinToString("\n") + val warning = stringResource( + if (unused.any { it.packageFile != null }) R.string.storage_component_confirm else R.string.storage_original_confirm, total) + ConfirmDialog( + stringResource(R.string.storage_remove_unused, total), + lines + "\n\n" + warning, + stringResource(R.string.storage_remove), + onConfirm = { StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { } }, + onDismiss = { confirmAll = false }, + ) + } } @Composable diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StorageComponentsBulkConfirmTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StorageComponentsBulkConfirmTest.kt new file mode 100644 index 000000000..3b47a8490 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/StorageComponentsBulkConfirmTest.kt @@ -0,0 +1,95 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import com.droiddeck.launcher.storage.ComponentUsage +import com.droiddeck.launcher.storage.StorageReport +import com.droiddeck.launcher.storage.StorageState +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class StorageComponentsBulkConfirmTest { + @get:Rule val compose = createRobolectricComposeRule() + + private fun reportWith(components: List) = StorageReport( + at = 1L, + total = 64L * 1024 * 1024 * 1024, + free = 32L * 1024 * 1024 * 1024, + buckets = emptyMap(), + prefixes = 0L, + shaders = 0L, + workshop = 0L, + flatpak = 0L, + games = emptyList(), + tools = emptyList(), + leftovers = emptyList(), + logs = emptyList(), + logBytes = 0L, + components = components, + flatpakApps = emptyList(), + flatpakRuntimes = 0L, + otherPrefixes = 0L, + leftoversChecked = true, + ) + + @Test fun bulkRemoveWithUnusedPackageShowsImportedWarningAndCancelKeepsIdle() { + val context = RuntimeEnvironment.getApplication() + val mb10 = 10L * 1024 * 1024 + val report = reportWith( + listOf( + ComponentUsage(comp = "dxvk", version = "2.4.1", bytes = mb10, inUse = false, packageFile = "dxvk-2.4.1.wcp"), + ComponentUsage(comp = "vkd3d-proton", version = "2.13", bytes = mb10, inUse = false, protonId = "proton-9", protonName = "Proton 9"), + ), + ) + compose.setContent { + DroidDeckTheme { + Column { + ComponentsView(report = report, sessionRunning = false, onBack = {}) + } + } + } + val removeUnusedBtn = context.getString(R.string.storage_remove_unused, "20 MB") + compose.onNodeWithText(removeUnusedBtn).performClick() + assertFalse(StorageState.cleaning) + + val expectedWarning = context.getString(R.string.storage_component_confirm, "20 MB") + compose.onNodeWithText(expectedWarning, substring = true).assertExists() + compose.onNodeWithText("• DXVK 2.4.1 10 MB", substring = true).assertExists() + + compose.onNodeWithText(context.getString(R.string.common_cancel)).performClick() + compose.onNodeWithText(expectedWarning, substring = true).assertDoesNotExist() + assertFalse(StorageState.cleaning) + } + + @Test fun bulkRemoveWithOnlyUnusedOriginalsShowsOriginalConfirmText() { + val context = RuntimeEnvironment.getApplication() + val mb10 = 10L * 1024 * 1024 + val report = reportWith( + listOf( + ComponentUsage(comp = "dxvk", version = "2.4", bytes = mb10, inUse = false, protonId = "proton-9", protonName = "Proton 9"), + ), + ) + compose.setContent { + DroidDeckTheme { + Column { + ComponentsView(report = report, sessionRunning = false, onBack = {}) + } + } + } + val removeUnusedBtn = context.getString(R.string.storage_remove_unused, "10 MB") + compose.onNodeWithText(removeUnusedBtn).performClick() + assertFalse(StorageState.cleaning) + + val expectedWarning = context.getString(R.string.storage_original_confirm, "10 MB") + compose.onNodeWithText(expectedWarning, substring = true).assertExists() + } +} From eaa46e1fd61de1cf59bb05b38bc7df593e696e44 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:28:14 -0400 Subject: [PATCH 040/156] fix: show the app's cursor in sessions started from the front end (#1154) gamescope makes its cursor surfaces by attaching an shm buffer, committing, destroying the buffer and only then calling wl_pointer.set_cursor. The compositor kept that buffer only as a reference, so set_cursor found nothing to publish: apps run from the front end (store apps, Flatpaks, emulators) showed DroidDeck's fallback arrow, or no cursor at all after a desktop session had hidden its own. Copy a role-less surface's pixels when its held buffer is destroyed and publish that copy when the surface becomes the cursor, for as long as the surface lives. --- app/src/main/cpp/waylandcomp/src/compositor.c | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 99bbbef02..95a8bf83a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -218,6 +218,8 @@ struct surface { enum surface_role role; struct wl_resource *idle_buffer; struct wl_listener idle_buffer_destroy; + uint32_t *idle_px; + int idle_w, idle_h; struct wl_resource *xdg_surface, *xdg_toplevel; struct wl_resource *viewport; @@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) g_cursor_shown = s; } +static void surface_forget_idle_px(struct surface *s) { + free(s->idle_px); + s->idle_px = NULL; + s->idle_w = s->idle_h = 0; +} + static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) { struct surface *s = wl_container_of(l, s, idle_buffer_destroy); + struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer); + if (shm) { + int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm); + int32_t stride = wl_shm_buffer_get_stride(shm); + uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4 + ? realloc(s->idle_px, (size_t)w * h * 4) : NULL; + if (px) { + wl_shm_buffer_begin_access(shm); + const uint8_t *src = wl_shm_buffer_get_data(shm); + for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4); + wl_shm_buffer_end_access(shm); + s->idle_px = px; + s->idle_w = w; + s->idle_h = h; + } + } wl_list_remove(&s->idle_buffer_destroy.link); wl_list_init(&s->idle_buffer_destroy.link); s->idle_buffer = NULL; @@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) { } static void surface_drop_idle(struct surface *s) { + surface_forget_idle_px(s); struct wl_resource *buffer = surface_take_idle(s); if (buffer) wl_buffer_send_release(buffer); } @@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) { if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); } if (g_cursor_shown == s) g_cursor_shown = NULL; surface_take_idle(s); + surface_forget_idle_px(s); if (g_grab == s) g_grab = NULL; if (g_key_target == s) g_key_target = NULL; if (g_ime_click == s) g_ime_click = NULL; @@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3 wl_buffer_send_release(buffer); } else if (g_cursor_shown == s) { cursor_publish_shown(); + } else if (s->idle_px) { + cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy); + g_cursor_shown = s; } } static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } From b924dc02de94d6d95f5b72ff724ae5383691611e Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:29:21 -0400 Subject: [PATCH 041/156] fix: add games picked inside a folder game as their own games (#1155) Picking an exe anywhere under an added folder treated it as that game, so a folder added with "Add all games in this folder" (or a removed one) swallowed every game below it: picking another game's exe switched the folder game's target, or brought the removed folder game back under its old name and art. Only treat a pick as the same game when it sits in the folder root, in the same top-level subfolder as the game's current exe, or is another build of that exe, and prefer the deepest matching folder. --- .../droiddeck/launcher/frontend/AddedGames.kt | 24 +++++++++- .../launcher/frontend/AddedExesTest.kt | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 0c72812d1..aa10c0705 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -204,8 +204,10 @@ object AddedGames { GamesFolderMigration.run(context) val path = canonical(exe) fun holds(f: File) = inside(path, canonical(f)) - AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it } - SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it } + val entries = AddedExes.list(context) + fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe) + entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } + SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it } val dirs = listOfNotNull( GameStorage.effective(context)?.let { File(it.path, "steamapps/common") }, @@ -220,6 +222,24 @@ object AddedGames { return null } + private fun currentExe(context: Context, entries: List, folder: File): File? = + SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File) + ?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) } + ?: rank(folder).exes.firstOrNull() + + private fun sameGame(folder: File, current: File?, pick: File): Boolean { + val base = canonical(folder) + if (current == null || pick.parentFile?.let(::canonical) == base) return true + val known = canonical(current) + if (!inside(known, base) || known.substringBeforeLast('/') == base) return true + val picked = canonical(pick) + fun top(p: String) = p.removePrefix("$base/").substringBefore('/') + fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() } + return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) } + } + + private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$") + fun addExe(context: Context, exe: File): AddResult { val app = context.applicationContext if (!exe.isFile) return AddResult.NotFound diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 6cc36e909..65949fec6 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -85,6 +85,54 @@ class AddedExesTest { assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder }) } + @Test fun aGameInsideAFolderAddedAsOneGameIsAddedOnItsOwn() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Pro Evolution Soccer 2017/PES2017.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals(pick.parentFile!!.path, (added as AddedGames.AddResult.Added).game.folder.path) + assertEquals(setOf(held.path, pick.path), AddedGames.scan(app).map { it.exe.path }.toSet()) + } + + @Test fun aRemovedFolderGameDoesNotComeBackForAnotherGame() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Blur Game/Blur.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + SessionPrefs.setAddedGameName(app, container.path, "Death Stranding Directors Cut") + AddedGames.remove(app, container) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals("Blur Game", (added as AddedGames.AddResult.Added).game.name) + assertTrue(container.path in SessionPrefs.removedAddedGames(app)) + assertEquals(listOf(pick.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun anotherBuildOfTheSameExeInASiblingFolderSwitchesTheGame() { + noGamesFolders() + val held = onStorage("Games2/Oddworld/x64/Oddworld.exe") + val other = onStorage("Games2/Oddworld/x86/Oddworld.exe") + val folder = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(folder.path, held.path, picked = false)) + val switched = AddedGames.addExe(app, other) + assertTrue(switched is AddedGames.AddResult.Switched) + assertEquals(folder.path, (switched as AddedGames.AddResult.Switched).game.folder.path) + assertEquals(listOf(other.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun aDifferentGameWithANumberedNameIsNotTakenAsAnotherBuild() { + noGamesFolders() + val held = onStorage("Games2/id/Doom/Doom.exe") + val pick = onStorage("Games2/id/Doom64/Doom64.exe") + AddedExes.put(app, AddedExes.Entry(held.parentFile!!.parentFile!!.path, held.path, picked = false)) + assertTrue(AddedGames.addExe(app, pick) is AddedGames.AddResult.Added) + assertEquals(2, AddedGames.scan(app).size) + } + @Test fun anExeTheSessionCannotSeeIsNotAdded() { noGamesFolders() val outside = exe(File(tmp.newFolder("elsewhere"), "Game/Game.exe")) From 6ba159a2f298cbda21458c39f0b5660b02ce6073 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:31:03 -0400 Subject: [PATCH 042/156] fix: let Wine write hooks into code FEX has translated (Ubisoft "Error at hooking API") (#1163) fix: let Wine write hooks into code FEX has translated Wine 11's wineserver writes another process's memory with process_vm_writev, which the kernel refuses on a page that is not writable. FEX's WoW64 layer keeps the pages it has translated read-only to catch self-modifying code and re-protects them right after a program unprotects them, so WriteProcessMemory and NtWriteVirtualMemory into ntdll failed with a partial copy and Ubisoft games stopped at "Error at hooking API NtProtectVirtualMemory". Wine only falls back to /proc//mem on ENOSYS or EPERM. libblsession now completes a single-buffer write that wineserver was refused with EFAULT through /proc//mem, the way Wine wrote before Wine 11. Other programs and every other error keep the kernel's answer. The iovecs are read with process_vm_readv, so a bad pointer still returns EFAULT, and offsets are 64-bit in the i386 build. --- README.md | 2 +- tools/linuxfs/preload/vmwrite.c | 55 ++++++++++++++ tools/tests/test_vmwrite_preload.py | 108 ++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tools/linuxfs/preload/vmwrite.c create mode 100644 tools/tests/test_vmwrite_preload.py diff --git a/README.md b/README.md index f07237abe..2ab977bb6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/preload/vmwrite.c b/tools/linuxfs/preload/vmwrite.c new file mode 100644 index 000000000..7737632c0 --- /dev/null +++ b/tools/linuxfs/preload/vmwrite.c @@ -0,0 +1,55 @@ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include +#include +#include +#include +#include +#include +#include +#include + +typedef ssize_t (*vm_writev_fn)(pid_t, const struct iovec *, unsigned long, const struct iovec *, unsigned long, + unsigned long); + +static int in_wineserver(void) { + static int known = -1; + if (known < 0) { + char exe[512]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + exe[n > 0 ? n : 0] = 0; + const char *name = strrchr(exe, '/'); + known = !strcmp(name ? name + 1 : exe, "wineserver"); + } + return known; +} + +ssize_t process_vm_writev(pid_t pid, const struct iovec *local, unsigned long liovcnt, const struct iovec *remote, + unsigned long riovcnt, unsigned long flags) { + static vm_writev_fn real; + if (!real) real = (vm_writev_fn)dlsym(RTLD_NEXT, "process_vm_writev"); + if (!real) { + errno = ENOSYS; + return -1; + } + ssize_t result = real(pid, local, liovcnt, remote, riovcnt, flags); + if (result >= 0 || errno != EFAULT || liovcnt != 1 || riovcnt != 1 || flags || !in_wineserver()) return result; + struct iovec ends[2]; + struct iovec into = {ends, sizeof ends}; + struct iovec from[2] = {{(void *)local, sizeof *local}, {(void *)remote, sizeof *remote}}; + if (process_vm_readv(getpid(), &into, 1, from, 2, 0) != (ssize_t)sizeof ends || ends[0].iov_len != ends[1].iov_len) { + errno = EFAULT; + return -1; + } + char path[32]; + snprintf(path, sizeof path, "/proc/%d/mem", (int)pid); + int fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) { + errno = EFAULT; + return -1; + } + ssize_t written = pwrite(fd, ends[0].iov_base, ends[0].iov_len, (off_t)(uintptr_t)ends[1].iov_base); + close(fd); + if (written < 0) errno = EFAULT; + return written; +} diff --git a/tools/tests/test_vmwrite_preload.py b/tools/tests/test_vmwrite_preload.py new file mode 100644 index 000000000..0c6dd27f6 --- /dev/null +++ b/tools/tests/test_vmwrite_preload.py @@ -0,0 +1,108 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + unsigned char *code = mmap((void *)0xc0000000UL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (sizeof(void *) == 4 && (uintptr_t)code <= 0x7fffffffU) return 3; + unsigned char *data = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + memset(code, 0x90, 4096); + mprotect(code, 4096, PROT_READ | PROT_EXEC); + int ready[2]; + if (pipe(ready)) return 2; + pid_t child = fork(); + if (child == 0) { + prctl(PR_SET_PDEATHSIG, SIGKILL); + close(ready[0]); + if (write(ready[1], "", 1) != 1) _exit(1); + pause(); + _exit(0); + } + char c; + close(ready[1]); + if (read(ready[0], &c, 1) != 1) return 2; + unsigned char patch[4] = {0xB8, 0x78, 0x56, 0x34}; + struct iovec mine = {patch, sizeof patch}; + struct iovec theirs[2] = {{code, sizeof patch}, {data, sizeof patch}}; + for (int i = 0; i < 2; i++) { + ssize_t n = process_vm_writev(child, &mine, 1, &theirs[i], 1, 0); + printf("%s %zd %d", i ? "data" : "code", n, n < 0 ? errno : 0); + unsigned char back[4] = {0}; + struct iovec got = {back, sizeof back}; + process_vm_readv(child, &got, 1, &theirs[i], 1, 0); + printf(" %s\n", memcmp(back, patch, sizeof patch) ? "old" : "new"); + } + struct iovec split[2] = {{patch, 2}, {patch + 2, 2}}; + ssize_t n = process_vm_writev(child, split, 2, &theirs[0], 1, 0); + printf("split %zd %d\n", n, n < 0 ? errno : 0); + n = process_vm_writev(child, (const struct iovec *)8, 1, &theirs[0], 1, 0); + printf("invalid %zd %d\n", n, n < 0 ? errno : 0); + kill(child, SIGKILL); + waitpid(child, NULL, 0); + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc") and os.path.exists("/proc/self/mem"), "needs cc and /proc") +class VmWritePreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = Path(tempfile.mkdtemp()) + cls.addClassCleanup(shutil.rmtree, cls.tmp, ignore_errors=True) + source = cls.tmp / "program.c" + source.write_text(PROGRAM) + cls.bits32 = True + for flags, sub in (([], "64"), (["-m32"], "32")): + (cls.tmp / sub).mkdir() + try: + subprocess.run(["cc", *flags, "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-o", + str(cls.tmp / sub / "libvmwrite.so"), str(PRELOAD / "vmwrite.c"), "-ldl"], + check=True, capture_output=True) + for name in ("wineserver", "game"): + subprocess.run(["cc", *flags, "-O2", "-o", str(cls.tmp / sub / name), str(source)], + check=True, capture_output=True) + except subprocess.CalledProcessError: + if not flags: + raise + cls.bits32 = False + + def run_as(self, name, preload=True, bits="64"): + lib = self.tmp / bits / "libvmwrite.so" + env = dict(os.environ, LD_PRELOAD=str(lib)) if preload else {k: v for k, v in os.environ.items() if k != "LD_PRELOAD"} + result = subprocess.run([str(self.tmp / bits / name)], env=env, capture_output=True, text=True, check=True) + return result.stdout.splitlines() + + def test_wineserver_writes_a_page_the_kernel_refused(self): + self.assertEqual(self.run_as("wineserver"), ["code 4 0 new", "data 4 0 new", "split -1 14", "invalid -1 14"]) + + def test_a_32_bit_wineserver_writes_above_2_gib(self): + if not self.bits32: + self.skipTest("needs cc -m32") + self.assertEqual(self.run_as("wineserver", bits="32"), ["code 4 0 new", "data 4 0 new", "split -1 14", "invalid -1 14"]) + + def test_everyone_else_keeps_the_kernel_answer(self): + for refused in (self.run_as("game"), self.run_as("wineserver", preload=False)): + self.assertEqual(refused, ["code -1 14 old", "data 4 0 new", "split -1 14", "invalid -1 14"]) + + +if __name__ == "__main__": + unittest.main() From 6ef042487a06f5febb70984f494574e53a467003 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:34:01 -0400 Subject: [PATCH 043/156] fix: find and save Steam touch layouts of non-Steam shortcuts (#1171) The touch layout editor opened "App - Layout" for a non-Steam shortcut (an added game, the Epic Games Launcher) with LB, LT, RT, RB, both sticks and LS/RS greyed out, so they could not be placed. Steam keys a shortcut's controller config by the shortcut's lowercase name and logs its app id unsigned, but the editor looked both up by the signed app id, found neither, and fell back to a blank layout with nothing bound. SteamTouchConfig now reads the shortcut's name from shortcuts.vdf, loads and saves its layout under that name the way Steam does (autosave folder and configset entry), and matches Steam's log line by the unsigned id. The editor is titled with the game's name, and the app id in the panel is shown unsigned. A name that is not a plain folder name (a path separator, "." or "..", a quote, a backslash or a control character) keeps the numeric app id. Existing configset entries are matched without regard to case, as Steam reads them. --- .../launcher/frontend/SteamAppInfo.kt | 2 +- .../launcher/input/SteamTouchConfig.kt | 70 +++++++++++++------ .../launcher/input/SteamTouchControls.kt | 2 +- .../input/SteamTouchConfigFilesTest.kt | 56 +++++++++++++++ 4 files changed, 106 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt index d43c4bafb..ba1a42420 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt @@ -153,7 +153,7 @@ object SteamAppInfo { return String(bytes, Charsets.UTF_8) } - private fun readMap(b: ByteBuffer, strings: List?): Map { + internal fun readMap(b: ByteBuffer, strings: List?): Map { val out = LinkedHashMap() while (b.hasRemaining()) { val type = b.get().toInt() and 0xFF diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt index 12692bfa9..f1479588c 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt @@ -2,10 +2,14 @@ package com.droiddeck.launcher.input import android.content.Context import android.util.Log +import com.droiddeck.launcher.frontend.SteamAppInfo import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.ByteArrayOutputStream import java.io.File import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Locale object SteamTouchConfig { private const val TAG = "SteamTouch" @@ -125,17 +129,37 @@ object SteamTouchConfig { private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START) private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD) + fun steamId(appId: Int): String = Integer.toUnsignedString(appId) + fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1 - fun gameName(context: Context, appId: Int): String? = try { + fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try { val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText()) (kv.child("AppState") ?: kv).string("name") } catch (_: Exception) { null } + fun steamKey(context: Context, appId: Int): String = + if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT) + ?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId) + else steamId(appId) + + private fun shortcutName(context: Context, appId: Int): String? { + val account = accountId(context) ?: return null + return try { + val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes() + val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null) + (root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance>() + ?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId } + ?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context) fun configDir(context: Context): File? { @@ -158,10 +182,11 @@ object SteamTouchConfig { fun load(context: Context, appId: Int): Config { val dir = configDir(context) - val file = dir?.let { deviceConfig(context, it, appId) } - ?: dir?.let { autosaveConfig(it, appId) } + val key = steamKey(context, appId) + val file = dir?.let { deviceConfig(context, it, key) } + ?: dir?.let { autosaveConfig(it, key) } ?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) } - ?: dir?.let { configsetConfig(context, it, appId) } + ?: dir?.let { configsetConfig(context, it, key) } if (file == null) { Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout") return Config(null, null, null, emptyMap()) @@ -193,7 +218,7 @@ object SteamTouchConfig { } val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1) if (touchIndex == null) null else { - val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""") + val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""") .findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim() loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) } } @@ -201,22 +226,22 @@ object SteamTouchConfig { null } - private fun autosaveConfig(dir: File, appId: Int): File? = try { - val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString()) - if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null + private fun autosaveConfig(dir: File, key: String): File? = try { + val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key) + if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null } catch (_: Exception) { null } - private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId) + private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key) - private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId) + private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key) - private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try { - val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString()) + private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try { + val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key) when { set == null -> null - set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf") + set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf") set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!) set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template")) else -> null @@ -364,7 +389,8 @@ object SteamTouchConfig { val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) -> SteamTouchBindings.rewrite(text, ref, change) ?: text } - val target = File(dir, "$appId/controller_mobile_touch.vdf") + val key = steamKey(context, appId) + val target = File(dir, "$key/controller_mobile_touch.vdf") val old = config.layouts val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty() val written = sets.map { (id, elements) -> @@ -382,27 +408,27 @@ object SteamTouchConfig { val tmp = File(target.path + ".tmp") tmp.writeText(text) if (!tmp.renameTo(target)) return null - selectAutosave(dir, appId) - dropDeviceEntry(dir, appId) + selectAutosave(dir, key) + dropDeviceEntry(dir, key) Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}") return target } - private fun dropDeviceEntry(dir: File, appId: Int) { + private fun dropDeviceEntry(dir: File, key: String) { val file = File(dir, DEVICE_CONFIGSET) if (!file.isFile) return val text = file.readText() - val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""") + val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE) if (!block.containsMatchIn(text)) return File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file) } - private fun selectAutosave(dir: File, appId: Int) { + private fun selectAutosave(dir: File, key: String) { val file = File(dir, CONFIGSET) val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n" - val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}" - val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""") - val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry") + val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}" + val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE) + val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" } else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n" File(file.path + ".tmp").apply { writeText(next) }.renameTo(file) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt index dee510787..1046e3b34 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -965,7 +965,7 @@ class SteamTouchControls( BIG_PICTURE -> "Steam" else -> gameNames[appId] ?: run { val app = appId - gameNames[app] = context.getString(R.string.touch_app_id, app) + gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app)) loader.execute { val name = SteamTouchConfig.gameName(context, app) ?: return@execute handler.post { gameNames[app] = name; invalidate() } diff --git a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt index eb48d0964..96ff9a1c4 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigFilesTest.kt @@ -57,4 +57,60 @@ class SteamTouchConfigFilesTest { assertTrue(device.contains("\"8400\"")) assertEquals(saved, SteamTouchConfig.load(context, 204360).file) } + + private val template get() = File(steam, "controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf") + + private fun shortcut(name: String): Int { + val shortcut = 2702387189L.toInt() + template.apply { parentFile!!.mkdirs(); writeText(touch("Gamepad with Joystick")) } + File(steam, "logs/console_log.txt").apply { parentFile!!.mkdirs() }.writeText( + "!! Controller 0 attributes:\n Type: 43\n" + + "Loaded Config for Last Resort Path for App ID 2702387189, Controller 0: /root/.local/share/Steam//controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf\n", + ) + File(steam, "config/loginusers.vdf").apply { parentFile!!.mkdirs() } + .writeText("\"users\"\n{\n\t\"76561197960265851\"\n\t{\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + val vdf = java.io.ByteArrayOutputStream().apply { + write("\u0000shortcuts\u0000\u00000\u0000\u0002appid\u0000".toByteArray()) + write(java.nio.ByteBuffer.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(shortcut).array()) + write("\u0001AppName\u0000$name\u0000\u0008\u0008\u0008".toByteArray()) + } + File(steam, "userdata/123/config/shortcuts.vdf").apply { parentFile!!.mkdirs() }.writeBytes(vdf.toByteArray()) + return shortcut + } + + @Test fun aShortcutFindsSteamsConfigAndSavesUnderItsName() { + val shortcut = shortcut("Epic Games Launcher") + val config = SteamTouchConfig.load(context, shortcut) + assertEquals(template, config.file) + assertEquals("Epic Games Launcher", SteamTouchConfig.gameName(context, shortcut)) + val saved = SteamTouchConfig.saveLayouts(context, shortcut, config, mapOf(1 to emptyList())) + assertEquals(File(dir, "epic games launcher/controller_mobile_touch.vdf"), saved) + assertTrue(File(dir, "configset_controller_mobile_touch.vdf").readText().contains("\"epic games launcher\"")) + assertEquals(saved, SteamTouchConfig.load(context, shortcut).file) + } + + @Test fun aShortcutNameThatIsNoPlainFolderNameUsesTheAppId() { + for (name in listOf("../escape", "a/b", "..", "Tab\tName")) { + assertEquals(name, "2702387189", SteamTouchConfig.steamKey(context, shortcut(name))) + } + } + + @Test fun aShortcutNameWithADollarSavesAgain() { + val shortcut = shortcut("Game $1") + val config = SteamTouchConfig.load(context, shortcut) + SteamTouchConfig.saveLayouts(context, shortcut, config, mapOf(1 to emptyList())) + val saved = SteamTouchConfig.saveLayouts(context, shortcut, SteamTouchConfig.load(context, shortcut), mapOf(1 to emptyList())) + assertEquals(File(dir, "game $1/controller_mobile_touch.vdf"), saved) + assertEquals(1, Regex("\"game \\$1\"").findAll(File(dir, "configset_controller_mobile_touch.vdf").readText()).count()) + } + + @Test fun aMixedCaseEntryOfTheShortcutIsReplacedNotDuplicated() { + val shortcut = shortcut("Epic Games Launcher") + configset("configset_controller_mobile_touch.vdf", "Epic Games Launcher" to "\"template\"\t\t\"controller_mobile_touch_gamepad_joystick.vdf\"") + configset("configset_MT-DROIDDECK0001.vdf", "Epic Games Launcher" to "\"workshop\"\t\t\"1\"") + SteamTouchConfig.saveLayouts(context, shortcut, SteamTouchConfig.load(context, shortcut), mapOf(1 to emptyList())) + val entries = Regex("\"epic games launcher\"", RegexOption.IGNORE_CASE) + assertEquals(1, entries.findAll(File(dir, "configset_controller_mobile_touch.vdf").readText()).count()) + assertEquals(0, entries.findAll(File(dir, "configset_MT-DROIDDECK0001.vdf").readText()).count()) + } } From 79f43d8defca8353df4167b14f4cdc59308c43be Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:38:08 -0400 Subject: [PATCH 044/156] fix: GOG games miss the registry entries their install script sets (#1159) A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch. --- README.md | 2 +- .../usr/local/bin/droiddeck-store-launch | 121 ++++++++++++++++++ tools/tests/test_store_launch.py | 93 ++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ab977bb6..dd9866aef 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index 0b6f64ee4..48d39813e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -28,8 +28,11 @@ build wrote is removed - with it the game exited while loading. droiddeck-store-launch """ +import base64 +import binascii import json import os +import re import sys import time import subprocess @@ -364,6 +367,123 @@ def prepare_gog(sidecar, compat_data, launch_dir): return state == "ready" +GOG_SCRIPTS = "goggame-*.script" +GOG_REGISTRY_MARK = ".droiddeck-gog-registry" +GOG_ROOTS = {"HKEY_LOCAL_MACHINE": "HKEY_LOCAL_MACHINE", "HKLM": "HKEY_LOCAL_MACHINE", + "HKEY_CURRENT_USER": "HKEY_CURRENT_USER", "HKCU": "HKEY_CURRENT_USER", + "HKEY_CLASSES_ROOT": "HKEY_CLASSES_ROOT", "HKCR": "HKEY_CLASSES_ROOT"} +WINE_USER = "C:\\users\\steamuser" + + +def gog_paths(folder, product): + app = windows_path(str(folder)) + support = app + "\\support\\" + product + return {"app": app, "productid": product, "support": support, "supportdir": support, + "localappdata": WINE_USER + "\\AppData\\Local", "userdocs": WINE_USER + "\\Documents"} + + +def gog_expand(text, paths): + return re.sub(r"\{([A-Za-z]+)\}", lambda m: paths[m.group(1).lower()], text) + + +def gog_number(data): + if isinstance(data, bool): + raise ValueError + if isinstance(data, int): + return data + text = str(data).strip() + try: + return int(text, 0) + except ValueError: + return int(text, 10) + + +def gog_value(kind, data): + kind = str(kind).lower() + if kind == "string": + text = str(data) + if any(c in text for c in "\r\n\0"): + return None + return '"%s"' % reg_escape(text) + if kind == "dword": + return "dword:%08x" % (gog_number(data) & 0xFFFFFFFF) + if kind == "binary": + return "hex:" + ",".join("%02x" % b for b in base64.b64decode(str(data), validate=True)) + return None + + +def gog_entry(arguments, paths): + if not isinstance(arguments, dict): + return None + try: + root = GOG_ROOTS.get(str(arguments.get("root") or "").upper()) + subkey = gog_expand(str(arguments.get("subkey") or ""), paths).strip("\\") + if root is None or not subkey or any(c in subkey for c in "\r\n\0"): + return None + section = "[%s\\%s]" % (root, subkey) + if not arguments.get("valueType"): + return section + data = arguments.get("valueData") + value = gog_value(arguments["valueType"], gog_expand(data, paths) if isinstance(data, str) else data) + name = gog_expand(str(arguments.get("valueName") or ""), paths) + if value is None or any(c in name for c in "\r\n\0"): + return None + return section + "\r\n" + ('"%s"' % reg_escape(name) if name else "@") + "=" + value + except (KeyError, ValueError, TypeError, binascii.Error): + return None + + +def gog_registry(folder): + entries, skipped = [], 0 + for script in sorted(Path(folder).glob(GOG_SCRIPTS)): + product = script.name[len("goggame-"):-len(".script")] + try: + actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions") + except (OSError, ValueError, AttributeError): + continue + paths = gog_paths(folder, product) + for action in actions if isinstance(actions, list) else []: + install = action.get("install") if isinstance(action, dict) else None + if not isinstance(install, dict) or install.get("action") != "setRegistry": + continue + entry = gog_entry(install.get("arguments"), paths) + if entry is None: + skipped += 1 + else: + entries.append(entry) + return entries, skipped + + +def apply_gog_registry(folder, compat_data, proton, timeout=90): + entries, skipped = gog_registry(folder) + note = " skipped=%d" % skipped if skipped else "" + if not entries: + return "none" + note + if not compat_data or not proton or not Path(proton).is_file(): + return "no-proton" + note + text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join(entry + "\r\n\r\n" for entry in entries) + stamp = hashlib.sha256(text.encode("utf-8")).hexdigest() + pfx = Path(compat_data) / "pfx" + marker = pfx / GOG_REGISTRY_MARK + try: + if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file(): + return "ready" + note + Path(compat_data).mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="droiddeck-gog-") as tmp: + source = Path(tmp) / "registry.reg" + source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le")) + run = subprocess.run([proton, "run", "reg.exe", "import", windows_path(str(source)), "/reg:32"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout) + if run.returncode or not (pfx / "system.reg").is_file(): + return "import-failed" + note + marker.write_text(stamp) + return "written=%d%s" % (len(entries), note) + except subprocess.TimeoutExpired: + return "timeout" + note + except OSError: + return "import-failed" + note + + def main(argv): if argv and argv[0] == "--exited": rest, code = argv[1:], 0 @@ -384,6 +504,7 @@ def main(argv): launch_dir = os.environ.get("BL_LAUNCH_DIR", "") if store == "gog": prepare_gog(sidecar, compat_data, launch_dir) + log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) if store == "epic": handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() if not handled: diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 1acf493ac..1c4ac4bba 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -314,3 +314,96 @@ class GogAchievements(unittest.TestCase): with patch.object(store, 'COMET_DIR', base), patch.object(store.subprocess, 'run', return_value=__import__('subprocess').CompletedProcess([], 1)): self.assertEqual('service-unavailable', store.provision_galaxy(str(base / 'compat'), str(base / 'proton'))) self.assertFalse((base / 'compat/pfx/.droiddeck-comet-service').exists()) + + +def gog_script(folder, product, actions): + (folder / ('goggame-%s.script' % product)).write_text(json.dumps({'actions': actions, 'baseProductId': product, 'version': 1})) + + +def set_registry(**arguments): + return {'install': {'action': 'setRegistry', 'arguments': arguments}, 'name': 'setRegistry'} + + +class GogRegistry(unittest.TestCase): + def test_the_scripts_registry_values_are_read_with_the_games_paths(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='1454587428') + gog_script(folder, '1454587428', [ + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Bethesda Softworks\\FalloutNV', + valueName='Installed Path', valueData='{app}\\', valueType='string'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\GOG.com\\Games\\{productId}', + valueName='buildId', valueData='42', valueType='dword'), + set_registry(root='HKEY_CURRENT_USER', subkey='Software\\Game', valueName='', valueData='AAH/', valueType='binary'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Empty'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Game', valueName='x', valueData='1', valueType='qword'), + set_registry(root='HKEY_LOCAL_MACHINE', subkey='Software\\Game', valueName='x', valueData='{nowhere}', valueType='string'), + set_registry(root='HKEY_USERS', subkey='S-1-5', valueName='x', valueData='1', valueType='string'), + {'install': {'action': 'Execute', 'arguments': {'executable': 'setup.exe'}}}, + 'nonsense', + ]) + entries, skipped = store.gog_registry(folder) + app = store.windows_path(str(folder)).replace('\\', '\\\\') + self.assertEqual([ + '[HKEY_LOCAL_MACHINE\\Software\\Bethesda Softworks\\FalloutNV]\r\n"Installed Path"="%s\\\\"' % app, + '[HKEY_LOCAL_MACHINE\\Software\\GOG.com\\Games\\1454587428]\r\n"buildId"=dword:0000002a', + '[HKEY_CURRENT_USER\\Software\\Game]\r\n@=hex:00,01,ff', + '[HKEY_LOCAL_MACHINE\\Software\\Empty]', + ], entries) + self.assertEqual(3, skipped) + + def test_a_game_without_a_script_starts_without_proton(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog') + with patch.object(store.subprocess, 'run') as run: + self.assertEqual('none', store.apply_gog_registry(folder, root + '/compat', root + '/proton')) + run.assert_not_called() + + def test_values_are_imported_into_the_32_bit_view_once_until_the_script_changes(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='1454587428') + proton = Path(root) / 'proton' + proton.write_text('selected Proton') + data = Path(root) / 'compat' + action = set_registry(root='HKLM', subkey='Software\\Bethesda Softworks\\FalloutNV', + valueName='Installed Path', valueData='{app}', valueType='string') + gog_script(folder, '1454587428', [action]) + imported = [] + def run(argv, **kwargs): + self.assertEqual([str(proton), 'run', 'reg.exe', 'import'], argv[:4]) + self.assertEqual('/reg:32', argv[5]) + raw = Path(argv[4][2:].replace('\\', '/')).read_bytes() + self.assertTrue(raw.startswith(b'\xff\xfe')) + imported.append(raw[2:].decode('utf-16-le')) + (data / 'pfx').mkdir(parents=True, exist_ok=True) + (data / 'pfx/system.reg').write_text('Wine registry') + return __import__('subprocess').CompletedProcess(argv, 0) + with patch.object(store.subprocess, 'run', side_effect=run): + self.assertEqual('written=1', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertTrue(imported[0].startswith('Windows Registry Editor Version 5.00\r\n')) + self.assertIn('[HKEY_LOCAL_MACHINE\\Software\\Bethesda Softworks\\FalloutNV]', imported[0]) + self.assertEqual('ready', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertEqual(1, len(imported)) + action['install']['arguments']['valueData'] = '{app}\\' + gog_script(folder, '1454587428', [action]) + self.assertEqual('written=1', store.apply_gog_registry(folder, str(data), str(proton))) + self.assertEqual(2, len(imported)) + + def test_a_failed_import_is_tried_again_next_launch(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='7') + proton = Path(root) / 'proton' + proton.write_text('selected Proton') + gog_script(folder, '7', [set_registry(root='HKLM', subkey='Software\\Game', valueName='a', valueData='b', valueType='string')]) + with patch.object(store.subprocess, 'run', return_value=__import__('subprocess').CompletedProcess([], 1)): + self.assertEqual('import-failed', store.apply_gog_registry(folder, root + '/compat', str(proton))) + self.assertFalse((Path(root) / 'compat/pfx' / store.GOG_REGISTRY_MARK).exists()) + self.assertEqual('no-proton', store.apply_gog_registry(folder, root + '/compat', root + '/missing')) + + def test_every_gog_launch_brings_the_registry_in(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog') + with patch.dict(os.environ, {'BL_LAUNCH_DIR': root, 'STEAM_COMPAT_DATA_PATH': root + '/compat/', 'BL_STORE_PROTON': '/chosen/proton'}), \ + patch.object(store, 'prepare_gog', return_value=False), patch.object(store, 'ask', return_value=None), \ + patch.object(store, 'apply_gog_registry', return_value='ready') as apply: + store.main([str(folder / store.LAUNCHER)]) + apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton') From bc3715aad69adbe8bb2469e05782e0143186ba01 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:44:56 -0400 Subject: [PATCH 045/156] fix: route ALSA to PulseAudio for x86 Linux games under FEX (no game audio) (#1166) fix: route ALSA to PulseAudio for x86 Linux games under FEX --- README.md | 2 +- tools/linuxfs/overlay/usr/local/bin/droiddeck-fex | 2 ++ tools/tests/test_droiddeck_fex.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dd9866aef..25ea3ee8a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index c5931ab17..aa0db0fd3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -51,6 +51,7 @@ FAULT_REPORT = "libfaultreport.so" VULKAN_PIN = "libvulkan-thunk.so" VULKAN_PIN_DIR = "lib/x86_64-linux-gnu/droiddeck/vulkan" VULKAN_THUNK = "libvulkan-guest.so" +ASOUND_CONF = "pcm.!default {\n type pulse\n}\nctl.!default {\n type pulse\n}\n" RUNTIME_LIBRARIES = (("libc.so.6", b"GLIBC_"), ("libstdc++.so.6", b"GLIBCXX_")) VERSION_NAME = re.compile(rb"GLIBC(?:XX)?_[0-9][0-9.]*[0-9]") BUNDLE_DEPTH = 3 @@ -343,6 +344,7 @@ def install_preloads(root): if len(shipped.get(FAULT_REPORT, ())) == len(GUEST_LIBDIRS): wanted.append(FAULT_REPORT) write_if_changed(os.path.join(root, "etc", "ld.so.preload"), "".join("/usr/$LIB/droiddeck/%s\n" % name for name in wanted)) + write_if_changed(os.path.join(root, "etc", "asound.conf"), ASOUND_CONF) def write_config(fex, root): diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index 79a653020..3bfbabc86 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -251,6 +251,8 @@ class PrepareTest(FexTestCase): self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libblsession.so\n/usr/$LIB/droiddeck/libfakeinput.so\n") self.assertEqual((root / "lib/i386-linux-gnu/droiddeck/libfakeinput.so").read_bytes(), b"i386libfakeinput.so") + self.assertEqual((root / "etc/asound.conf").read_text(), + "pcm.!default {\n type pulse\n}\nctl.!default {\n type pulse\n}\n") self.write(self.tmp / "ld.so.preload", "/usr/local/lib/libblsession.so\n") FEX["install_preloads"](str(root)) self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libblsession.so\n") From a5c587bd712dbeb33b9773bbfca1a74675508b8c Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:45:56 -0400 Subject: [PATCH 046/156] fix: add a picked exe the session cannot see by binding its folder (#1173) Adding a game from a single exe outside internal storage and the game library, such as a microSD card, only showed "This location is not available in the session" and added nothing. #1153 made "Add all games in this folder" bind such a folder under /root/Games, but a picked exe still had no guest path. addExe now binds the folder the same way when the session cannot see the exe (the game it belongs to, or else the exe's own folder), then adds the game. The bind is mounted from the next session start. A folder the session already sees is not bound again, and a folder the app cannot read is still refused. --- .../droiddeck/launcher/frontend/AddedGames.kt | 5 ++- .../launcher/frontend/AddedExesTest.kt | 43 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index aa10c0705..e59c8b723 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -248,7 +248,10 @@ object AddedGames { if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) { single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) } } - if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable + if (guestPath(app, exe) == null) { + (owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) } + if (guestPath(app, exe) == null) return AddResult.Unreachable + } val folder = owner ?: exe.parentFile ?: return AddResult.NotFound SessionPrefs.setAddedGameRemoved(app, folder.path, false) SessionPrefs.setAddedGameExe(app, folder.path, exe.path) diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 65949fec6..00b9988bd 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -133,12 +133,49 @@ class AddedExesTest { assertEquals(2, AddedGames.scan(app).size) } - @Test fun anExeTheSessionCannotSeeIsNotAdded() { + @Test fun anExeTheSessionCannotSeeBindsItsFolderAndIsAdded() { + noGamesFolders() + val outside = exe(File(tmp.newFolder("card"), "Hades/Hades.exe")) + assertNull(AddedGames.guestPath(app, outside)) + val game = (AddedGames.addExe(app, outside) as AddedGames.AddResult.Added).game + assertEquals(listOf(outside.parent to "${AddedGames.GUEST_DIR}/Hades"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Hades/Hades.exe", game.guestExe) + assertTrue(AddedGames.addExe(app, outside) is AddedGames.AddResult.Existing) + assertEquals(1, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun anotherExeOfAGameTheSessionCannotSeeBindsTheGameFolder() { + noGamesFolders() + val held = exe(File(tmp.newFolder("card"), "Hades/Hades.exe")) + val pick = exe(File(held.parentFile, "x64/Hades.exe")) + AddedExes.put(app, AddedExes.Entry(held.parent!!, held.path, picked = false)) + val switched = AddedGames.addExe(app, pick) as AddedGames.AddResult.Switched + assertEquals(listOf(held.parent to "${AddedGames.GUEST_DIR}/Hades"), SessionPrefs.gameFolderBinds(app)) + assertEquals("${AddedGames.GUEST_DIR}/Hades/x64/Hades.exe", switched.game.guestExe) + } + + @Test fun aGameFolderTheSessionAlreadySeesIsNotBoundAgain() { + noGamesFolders() + val held = onStorage("Games2/Hades/Hades.exe") + onStorage("Games2/Hades/x64/Hades.exe") + AddedExes.put(app, AddedExes.Entry(held.parent!!, held.path, picked = false)) + val alias = File(tmp.root, "alias").toPath() + java.nio.file.Files.createSymbolicLink(alias, held.parentFile!!.parentFile!!.toPath()) + assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, File(alias.toFile(), "Hades/x64/Hades.exe"))) + assertEquals(0, SessionPrefs.gameFolderBinds(app).size) + } + + @Test fun anExeInAFolderTheAppCannotReadIsNotAdded() { noGamesFolders() val outside = exe(File(tmp.newFolder("elsewhere"), "Game/Game.exe")) - assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, outside)) + outside.parentFile!!.setReadable(false) + try { + assertEquals(AddedGames.AddResult.Unreachable, AddedGames.addExe(app, outside)) + } finally { + outside.parentFile!!.setReadable(true) + } assertEquals(0, AddedExes.list(app).size) - assertEquals(0, AddedGames.scan(app).size) + assertEquals(0, SessionPrefs.gameFolderBinds(app).size) } @Test fun addingAFolderTheSessionCannotSeeBindsItAndAddsItsGames() { From ca352bf22562da8abeea3a807baf0836ed34b6b8 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 08:52:31 -0400 Subject: [PATCH 047/156] fix: RPCS3 opens from the emulator rail again (#1191) RPCS3's AppImage could not start: its own runtime unpacks the image with a dwarfsextract it runs from a memfd, which Android refuses (EACCES), and it has no other way to run it. Emulator AppImages for arm64 now start through the shipped uruntime, which falls back to a file in /tmp. RPCS3 then stopped with "Null object" in utils::shm (errno 13): it reads /proc/sys/vm/overcommit_memory to pick memfd for guest memory, and apps cannot read it. The guest now gets the kernel's default policy (0) there when the real file is unreadable, like file-max. --- README.md | 2 +- .../launcher/runtime/LinuxRuntime.java | 12 +++-- .../runtime/LinuxRuntimeProcFilesTest.kt | 38 ++++++++++++++ .../overlay/usr/local/bin/droiddeck-session | 4 ++ tools/tests/test_session_appimage.py | 49 +++++++++++++++++++ 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt create mode 100644 tools/tests/test_session_appimage.py diff --git a/README.md b/README.md index 25ea3ee8a..abb3beb3f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index c729850fc..869a6fdf1 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -187,10 +187,15 @@ public final class LinuxRuntime { } catch (java.io.IOException e) { } } - File fileMax = new File(fakeProc, "file-max"); - if (!fileMax.isFile()) { + String[][] seeded = { + {"file-max", "9223372036854775807\n"}, + {"overcommit_memory", "0\n"}, + }; + for (String[] entry : seeded) { + File fake = new File(fakeProc, entry[0]); + if (fake.isFile()) continue; try { - Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII)); + Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII)); } catch (IOException e) { } } @@ -205,6 +210,7 @@ public final class LinuxRuntime { {"overflowuid", "/proc/sys/kernel/overflowuid"}, {"overflowgid", "/proc/sys/kernel/overflowgid"}, {"file-max", "/proc/sys/fs/file-max"}, + {"overcommit_memory", "/proc/sys/vm/overcommit_memory"}, }; for (String[] entry : procFiles) { File fake = new File(fakeProc, entry[0]); diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt new file mode 100644 index 000000000..1638132af --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxRuntimeProcFilesTest.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.runtime + +import android.content.ContextWrapper +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class LinuxRuntimeProcFilesTest { + private val root = Files.createTempDirectory("guest-proc-files").toFile() + private val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) { + override fun getFilesDir() = File(root, "files").apply { mkdirs() } + override fun getCacheDir() = File(root, "cache").apply { mkdirs() } + override fun getNoBackupFilesDir() = File(root, "no_backup").apply { mkdirs() } + } + private val fakeProc get() = File(LinuxRuntime.rootDir(context), "etc/droiddeck/proc") + + @After fun tearDown() { + root.deleteRecursively() + } + + @Test fun theGuestGetsAnOvercommitPolicyItCanRead() { + LinuxRuntime.binds(context, null, null, null, null) + assertEquals("0\n", File(fakeProc, "overcommit_memory").readText()) + assertEquals("9223372036854775807\n", File(fakeProc, "file-max").readText()) + } + + @Test fun aSeededFileIsKept() { + File(fakeProc, "overcommit_memory").apply { parentFile!!.mkdirs(); writeText("1\n") } + LinuxRuntime.binds(context, null, null, null, null) + assertEquals("1\n", File(fakeProc, "overcommit_memory").readText()) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 1b2ae2f48..9b56c76bc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -331,6 +331,10 @@ case $mode in *.appimage) export APPIMAGE_EXTRACT_AND_RUN=1 chmod +x "$target" 2>/dev/null + if [ ${#fex[@]} -eq 0 ] && [ -x /usr/local/lib/droiddeck/uruntime ]; then + export TARGET_APPIMAGE=$target + run_program /usr/local/lib/droiddeck/uruntime "$@" + fi run_program "${fex[@]}" "$target" "$@" ;; *.sh) diff --git a/tools/tests/test_session_appimage.py b/tools/tests/test_session_appimage.py new file mode 100644 index 000000000..f1ba2d11a --- /dev/null +++ b/tools/tests/test_session_appimage.py @@ -0,0 +1,49 @@ +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" +URUNTIME = "/usr/local/lib/droiddeck/uruntime" + + +def target_case(): + match = re.search(r"^ case \$\{target,,\} in\n.*?^ esac\n", SESSION.read_text(), re.M | re.S) + return match.group(0) + + +class SessionAppImageTest(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, True) + self.image = self.dir / "rpcs3.AppImage" + self.image.write_bytes(b"\x7fELF") + self.uruntime = self.dir / "uruntime" + self.uruntime.write_text("#!/bin/sh\n") + self.uruntime.chmod(0o755) + + def launch(self, fex=()): + script = ("set -u\n" + "run_program() { printf '%%s\\n' \"TARGET=${TARGET_APPIMAGE:-} EXTRACT=${APPIMAGE_EXTRACT_AND_RUN:-}\" \"$@\"; exit 0; }\n" + "target=%s\nfex=(%s)\nset -- --no-gui\n" % (self.image, " ".join(fex)) + + target_case().replace(URUNTIME, str(self.uruntime))) + env = {k: v for k, v in os.environ.items() if k not in ("TARGET_APPIMAGE", "APPIMAGE_EXTRACT_AND_RUN")} + return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=30).stdout.splitlines() + + def test_an_arm64_image_runs_through_the_shipped_runtime(self): + self.assertEqual(["TARGET=%s EXTRACT=1" % self.image, str(self.uruntime), "--no-gui"], self.launch()) + + def test_without_the_shipped_runtime_the_image_runs_itself(self): + self.uruntime.unlink() + self.assertEqual(["TARGET= EXTRACT=1", str(self.image), "--no-gui"], self.launch()) + + def test_an_x86_image_still_starts_under_fex(self): + fex = ("/usr/local/bin/droiddeck-fex", "run", "--mode", "on", "--") + self.assertEqual(["TARGET= EXTRACT=1", *fex, str(self.image), "--no-gui"], self.launch(fex)) + + +if __name__ == "__main__": + unittest.main() From b8688631338305afaf43c2d10a54cb3ec351fcb4 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 09:19:46 -0400 Subject: [PATCH 048/156] perf: tile rendering for games, faster session start (#1188) Games launched from Steam get Turnip's own per-render-pass choice between tile memory and system memory again; TU_DEBUG=sysmem now stays with the Steam client, whose interface it was set for. A per-game TU_DEBUG, the debug override file and the Adreno 710/720 driver still win. On the REDMAGIC tablet (Adreno 840) PEAK runs 46.8 -> 52.3 fps over 7 alternating pairs and Valheim's 3D menu needs 12.6 % less GPU time per frame; light games are unchanged. droiddeck-seed-redists remembers the identity (inode, size, mtime) of each prefix registry it has verified and skips re-reading it while it is unchanged. Steam started after it on every session, ~1.8 s later. SessionFiles keeps a ledger of the files it staged for the installed APK and copies only the ones that changed, ~180 ms less per session start. Session start to Steam ready: 5.57 -> 3.57 s. --- README.md | 2 +- .../launcher/session/SessionFiles.kt | 49 +++++++++++++++++-- .../launcher/session/SessionService.kt | 23 +++++---- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-bg/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fi/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-ms/strings.xml | 2 +- app/src/main/res/values-nb/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rPT/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-th/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../overlay/usr/local/bin/droiddeck-fex | 1 + .../overlay/usr/local/bin/droiddeck-game-env | 15 +++++- .../usr/local/bin/droiddeck-seed-redists | 25 ++++++++-- tools/tests/test_game_environment.py | 11 +++++ tools/tests/test_seed_redists.py | 20 ++++++++ 41 files changed, 161 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index abb3beb3f..c8793fe6d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 46d84c50a..86da51e43 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -15,6 +15,7 @@ object SessionFiles { private const val TAG = "SessionFiles" private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio" + private const val STAGED_LEDGER = "usr/local/.droiddeck-staged" val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread") internal val DESKTOP_FILES = arrayOf( @@ -141,8 +142,10 @@ object SessionFiles { runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional + val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context)) for ((asset, relative) in all) { val target = File(root, relative) + if (ledger.current(relative, target)) continue val staged = File(target.parentFile, target.name + ".staged") var installed = false try { @@ -156,7 +159,7 @@ object SessionFiles { } finally { if (!installed) staged.delete() } - if (!installed) Log.e(TAG, "$relative NOT staged") + if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged") } File(root, "usr/local/bin/bannerlator-steam-compat").delete() LegacyNames.migrateRootfs(root) @@ -169,7 +172,9 @@ object SessionFiles { "version.txt", ) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) { - val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative") + val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative" + val target = File(root, key) + if (ledger.current(key, target)) continue val staged = File(target.parentFile, target.name + ".staged") var installed = false try { @@ -183,8 +188,9 @@ object SessionFiles { } finally { if (!installed) staged.delete() } - if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged") + if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged") } + ledger.save() for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively() val preload = StringBuilder("/usr/local/lib/libblsession.so\n") if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) { @@ -217,6 +223,43 @@ object SessionFiles { } } + private fun apkIdentity(context: Context): String { + val apk = File(context.applicationInfo.sourceDir) + return "${apk.path} ${apk.length()} ${apk.lastModified()}" + } + + private class StageLedger(private val file: File, private val identity: String) { + private val before: Map = FileUtils.readString(file)?.lines() + ?.takeIf { it.firstOrNull() == identity } + ?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap() + .orEmpty() + private val after = LinkedHashMap() + + fun current(relative: String, target: File): Boolean { + val mark = mark(target) ?: return false + if (before[relative] != mark) return false + after[relative] = mark + return true + } + + fun staged(relative: String, target: File) { + mark(target)?.let { after[relative] = it } + } + + fun save() { + if (after == before) return + val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n") + val staged = File(file.parentFile, file.name + ".staged") + if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) { + staged.delete() + file.delete() + } + } + + private fun mark(target: File): String? = + if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null + } + private fun stageStartupMovie(source: File, directory: File, name: String): Boolean { val movie = File(directory, name) val stamp = File(directory, ".$name.source") 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 ff1a7aeeb..d6825e700 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -294,14 +294,13 @@ class SessionService : Service() { return lines } - private fun tuDebug(linuxDriverId: String): String? { - val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH) - .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() } - if (!override.isNullOrEmpty()) return override - if (SessionPrefs.tuSysmem(this)) return "sysmem" - if (linuxDriverId.isEmpty()) return null + private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH) + .takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() } + + private fun sysmemDriver(linuxDriverId: String): Boolean { + if (linuxDriverId.isEmpty()) return false val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase() - return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null + return name.contains("710-720") || name.contains("710_720") } @@ -576,7 +575,9 @@ class SessionService : Service() { ) LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } - tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") } + val tuOverride = tuDebugOverride() + val tuDriver = sysmemDriver(linuxDriverId) + (tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") } com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") } if (SessionPrefs.zinkLazy(this)) { guest.add("ZINK_DESCRIPTORS=lazy") @@ -604,8 +605,9 @@ class SessionService : Service() { if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) && com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1") guest.add("BANNER_AUDIO_DIRECT_DECAY=0") - extraEnv().forEach { guest.add(it) } - com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) } + val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this) + if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else "")) + extra.forEach { guest.add(it) } val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this) if (clientPinned) { guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) @@ -1674,6 +1676,7 @@ class SessionService : Service() { private const val TAG = "SessionService" private const val TRACER_NICE = -6 private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug" + private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG" private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env" private const val ENV_SWITCH_NAME = "droiddeck-env" diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 24209cf92..45e898e19 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -546,7 +546,7 @@ لـ Adreno فقط. تقطّع أقل، مع استهلاك أكبر للبطارية والحرارة. إصلاحات الجلسة Turnip: التصيير عبر sysmem - أسرع لـ Steam ولمعظم الألعاب. مطلوب على Adreno 710/720/722. + أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722. تجاوز مساعد xalia في Steam droiddeck-ntsync (تجريبي) بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 58c46e41f..64a9038b3 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -533,7 +533,7 @@ Solo Adreno. Menos microcortes, más consumo de batería y calor. Correcciones de sesión Turnip: renderizado sysmem - Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722. + Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam droiddeck-ntsync (experimental) Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 910c6a994..d9b58d73f 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -538,7 +538,7 @@ Само за Adreno. По-малко засечки, по-голямо използване на батерията и повече топлина. Корекции на сесията Turnip: рендиране sysmem - По-бързо за Steam и повечето игри. Задължително на Adreno 710/720/722. + По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722. Пропускане на помощника xalia на Steam droiddeck-ntsync (експериментално) Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8b13a6ccd..5fd6a89f1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -542,7 +542,7 @@ Jen Adreno. Méně zadrhávání, více spotřeby baterie a tepla. Opravy relace Turnip: vykreslování sysmem - Rychlejší pro Steam a většinu her. Vyžadováno na Adreno 710/720/722. + Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722. Vynechat Steamova pomocníka xalia droiddeck-ntsync (experimentální) Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e5eebc3df..00b526836 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -538,7 +538,7 @@ Kun Adreno. Færre hak, mere batteribrug og varme. Sessionsrettelser Turnip: sysmem-rendering - Hurtigere til Steam og de fleste spil. Påkrævet på Adreno 710/720/722. + Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722. Spring Steams xalia-hjælper over droiddeck-ntsync (eksperimentel) En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 27564b23d..ccbb25f78 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -535,7 +535,7 @@ Nur Adreno. Weniger Ruckler, mehr Akkuverbrauch und Wärme. Sitzungskorrekturen Turnip: Sysmem-Rendering - Schneller für Steam und die meisten Spiele. Erforderlich bei Adreno 710/720/722. + Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722. Steams xalia-Helfer überspringen droiddeck-ntsync (experimentell) Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 70790dc77..bba71c05a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -538,7 +538,7 @@ Μόνο Adreno. Λιγότερα κολλήματα, μεγαλύτερη χρήση μπαταρίας και θερμότητα. Διορθώσεις συνεδρίας Turnip: απόδοση sysmem - Ταχύτερο για το Steam και τα περισσότερα παιχνίδια. Απαιτείται σε Adreno 710/720/722. + Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722. Παράβλεψη του βοηθού xalia του Steam droiddeck-ntsync (πειραματικό) Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0d3475efe..46ab87b35 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -537,7 +537,7 @@ Solo Adreno. Menos tirones, más consumo de batería y calor. Correcciones de sesión Turnip: renderizado sysmem - Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722. + Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722. Omitir el asistente xalia de Steam droiddeck-ntsync (experimental) Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index c207b4e4c..86560920f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -538,7 +538,7 @@ Vain Adreno. Vähemmän nykäyksiä, enemmän akun kulutusta ja lämpöä. Istunnon korjaukset Turnip: sysmem-renderöinti - Nopeampi Steamille ja useimmille peleille. Pakollinen Adreno 710/720/722:lla. + Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla. Ohita Steamin xalia-avustaja droiddeck-ntsync (kokeellinen) Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 49d302080..d81acc097 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -537,7 +537,7 @@ Uniquement pour Adreno. Moins de saccades, mais plus de consommation d\'énergie et de chaleur. Correctifs de session Turnip : rendu sysmem - Steam et la plupart des jeux seront plus rapides. Requis pour Adreno 710/720/722. + Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722. Ignorer l\'assistant xalia de Steam droiddeck-ntsync (expérimental) Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index b17009905..07bd570ae 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -538,7 +538,7 @@ सिर्फ़ Adreno के लिए। कम हिचकोले, लेकिन ज़्यादा बैटरी खर्च और गर्मी। सेशन फ़िक्स Turnip: sysmem रेंडरिंग - Steam और ज़्यादातर गेम के लिए तेज़। Adreno 710/720/722 पर ज़रूरी। + Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी। Steam के xalia हेल्पर को छोड़ें droiddeck-ntsync (एक्सपेरिमेंटल) जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a0dd71c18..ef37ac8ca 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -538,7 +538,7 @@ Csak Adreno. Kevesebb akadás, nagyobb akkumulátorhasználat és hőtermelés. Munkamenet-javítások Turnip: sysmem renderelés - Gyorsabb a Steamhez és a legtöbb játékhoz. Adreno 710/720/722-n szükséges. + Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges. A Steam xalia segítőjének kihagyása droiddeck-ntsync (kísérleti) A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 61be2c5f1..9673e06ee 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -536,7 +536,7 @@ Hanya Adreno. Lebih sedikit tersendat, lebih banyak penggunaan baterai dan panas. Perbaikan sesi Turnip: rendering sysmem - Lebih cepat untuk Steam dan sebagian besar gim. Diperlukan pada Adreno 710/720/722. + Lebih cepat untuk klien Steam; di gim, driver memilih rendering ubin atau memori sistem untuk setiap pass. Diperlukan pada Adreno 710/720/722. Lewati bantuan xalia milik Steam droiddeck-ntsync (eksperimental) Pengganti userspace untuk driver ntsync Linux, untuk pengujian. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 98567796a..0a807b72e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -537,7 +537,7 @@ Solo Adreno. Meno scatti, più consumo di batteria e più calore. Correzioni della sessione Turnip: rendering sysmem - Più veloce per Steam e la maggior parte dei giochi. Necessario su Adreno 710/720/722. + Più veloce per il client di Steam; nei giochi il driver sceglie per ogni passata tra rendering a tile e in memoria di sistema. Necessario su Adreno 710/720/722. Salta l\'assistente xalia di Steam droiddeck-ntsync (sperimentale) Un sostituto in userspace del driver ntsync di Linux, per i test. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9da8b70b6..149634e11 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -536,7 +536,7 @@ Adrenoのみ。スタッターは減りますが、消費電力と発熱が増えます。 セッション修正 Turnip:sysmemレンダリング - Steamとほとんどのゲームが高速になります。Adreno 710/720/722では必須です。 + Steamクライアントが高速になります。ゲームではドライバーがパスごとにタイルレンダリングとシステムメモリレンダリングを選びます。Adreno 710/720/722では必須です。 Steamのxalia補助をスキップ droiddeck-ntsync(実験的) Linuxのntsyncドライバーをユーザー空間で代替するテスト用機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0227531a5..d9cfa1501 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -536,7 +536,7 @@ Adreno에만 해당됩니다. 끊김이 줄어들지만 전력 소모와 발열이 증가합니다. 세션 수정 Turnip: sysmem 렌더링 - Steam과 대부분의 게임이 빨라집니다. Adreno 710/720/722에 필수입니다. + Steam 클라이언트가 빨라집니다. 게임에서는 드라이버가 패스마다 타일 렌더링과 시스템 메모리 렌더링 중 하나를 고릅니다. Adreno 710/720/722에 필수입니다. Steam의 xalia 도우미 건너뛰기 droiddeck-ntsync(실험적) 테스트용으로 Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 19c8a6d57..bf3e97a2d 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -536,7 +536,7 @@ Adreno sahaja. Kurang tersekat-sekat, lebih penggunaan bateri dan haba. Pembetulan sesi Turnip: rendering sysmem - Lebih pantas untuk Steam dan kebanyakan permainan. Diperlukan pada Adreno 710/720/722. + Lebih pantas untuk klien Steam; dalam permainan, pemacu memilih pemaparan jubin atau memori sistem bagi setiap laluan. Diperlukan pada Adreno 710/720/722. Langkau pembantu xalia Steam droiddeck-ntsync (eksperimen) Pengganti ruang pengguna untuk pemacu ntsync Linux, untuk pengujian. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 26f514ce6..73f552bfc 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -538,7 +538,7 @@ Bare Adreno. Færre hikk, mer batteribruk og varme. Sesjonsfikser Turnip: sysmem-rendering - Raskere for Steam og de fleste spill. Påkrevd på Adreno 710/720/722. + Raskere for Steam-klienten; i spill velger driveren flis- eller systemminnerendering for hvert pass. Påkrevd på Adreno 710/720/722. Hopp over Steams xalia-hjelper droiddeck-ntsync (eksperimentell) En erstatning i brukerrom for Linux-driveren ntsync, for testing. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6d38c5395..6cd75efa2 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -538,7 +538,7 @@ Alleen Adreno. Minder haperingen, meer batterijverbruik en warmte. Sessiefixes Turnip: sysmem-rendering - Sneller voor Steam en de meeste games. Vereist op Adreno 710/720/722. + Sneller voor de Steam-client; in games kiest het stuurprogramma per pass tussen tegel- en systeemgeheugenrendering. Vereist op Adreno 710/720/722. Sla de xalia-helper van Steam over droiddeck-ntsync (experimenteel) Een userspace-vervanger voor de ntsync-driver van Linux, voor tests. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7886e8f0d..e2ee25359 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -542,7 +542,7 @@ Tylko Adreno. Mniej przycięć, większe zużycie baterii i więcej ciepła. Poprawki sesji Turnip: renderowanie sysmem - Szybsze dla Steam i większości gier. Wymagane na Adreno 710/720/722. + Szybsze dla klienta Steam; w grach sterownik dla każdego przebiegu wybiera renderowanie kafelkowe lub w pamięci systemowej. Wymagane na Adreno 710/720/722. Pomijaj pomocnika xalia w Steam droiddeck-ntsync (eksperymentalne) Zastępnik sterownika Linux ntsync w przestrzeni użytkownika, do testów. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 35f2b5577..b1b6ccebd 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -537,7 +537,7 @@ Apenas Adreno. Menos soluços, mais consumo de bateria e mais calor. Correções da sessão Turnip: renderização em sysmem - Mais rápido para o Steam e a maioria dos jogos. Obrigatório no Adreno 710/720/722. + Mais rápido para o cliente Steam; nos jogos, o controlador escolhe em cada passagem entre renderização em mosaico ou na memória do sistema. Obrigatório no Adreno 710/720/722. Ignorar o assistente xalia do Steam droiddeck-ntsync (experimental) Um substituto em espaço de utilizador para o controlador ntsync do Linux, para testes. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0672620b8..28184bdaf 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -537,7 +537,7 @@ Só em Adreno. Menos engasgos, mais gasto de bateria e mais calor. Correções da sessão Turnip: renderização sysmem - Mais rápido para a Steam e a maioria dos jogos. Obrigatório nas Adreno 710/720/722. + Mais rápido para o cliente da Steam; nos jogos, o driver escolhe a cada passagem entre renderização em blocos ou na memória do sistema. Obrigatório nas Adreno 710/720/722. Pular o assistente xalia da Steam droiddeck-ntsync (experimental) Um substituto em espaço de usuário para o driver ntsync do Linux, para testes. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 329865e8f..6e4435df0 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -540,7 +540,7 @@ Doar Adreno. Mai puține întreruperi, consum mai mare de baterie și căldură. Remedieri de sesiune Turnip: randare sysmem - Mai rapidă pentru Steam și majoritatea jocurilor. Necesară pe Adreno 710/720/722. + Mai rapidă pentru clientul Steam; în jocuri, driverul alege la fiecare trecere între randarea pe dale și cea în memoria sistemului. Necesară pe Adreno 710/720/722. Omite asistentul xalia al Steam droiddeck-ntsync (experimental) Un substitut la nivel de utilizator pentru driverul Linux ntsync, pentru testare. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e25310312..ec52c1287 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -539,7 +539,7 @@ Только для Adreno. Меньше подтормаживаний, но больше энергопотребления и нагрева. Исправления сессии Turnip: рендеринг sysmem - Steam и большинство игр будут быстрее. Требуется для Adreno 710/720/722. + Клиент Steam будет быстрее; в играх драйвер для каждого прохода выбирает тайловый рендеринг или рендеринг в системной памяти. Требуется для Adreno 710/720/722. Пропустить помощник xalia в Steam droiddeck-ntsync (экспериментально) Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 0cf9b426c..2d1573175 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -538,7 +538,7 @@ Bara Adreno. Färre hack, högre batteriförbrukning och mer värme. Sessionsfixar Turnip: sysmem-rendering - Snabbare för Steam och de flesta spel. Krävs på Adreno 710/720/722. + Snabbare för Steam-klienten; i spel väljer drivrutinen kakel- eller systemminnesrendering för varje pass. Krävs på Adreno 710/720/722. Hoppa över Steams xalia-hjälp droiddeck-ntsync (experimentell) En ersättning i användarutrymmet för Linux ntsync-drivrutinen, för testning. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 8bdf43b37..ba1962129 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -536,7 +536,7 @@ สำหรับ Adreno เท่านั้น สะดุดน้อยลง แต่ใช้แบตเตอรี่และเกิดความร้อนมากขึ้น การแก้ไขเซสชัน Turnip: การเรนเดอร์ sysmem - เร็วขึ้นสำหรับ Steam และเกมส่วนใหญ่ จำเป็นสำหรับ Adreno 710/720/722 + เร็วขึ้นสำหรับไคลเอนต์ Steam ในเกมไดรเวอร์จะเลือกการเรนเดอร์แบบไทล์หรือในหน่วยความจำระบบในแต่ละพาส จำเป็นสำหรับ Adreno 710/720/722 ข้ามตัวช่วย xalia ของ Steam droiddeck-ntsync (ทดลอง) ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux สำหรับการทดสอบ เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a81213aef..d782c221b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -535,7 +535,7 @@ Yalnızca Adreno. Daha az takılma, daha fazla pil tüketimi ve ısınma. Oturum düzeltmeleri Turnip: sysmem işleme - Steam ve çoğu oyun için daha hızlı. Adreno 710/720/722\'de gereklidir. + Steam istemcisi için daha hızlı; oyunlarda sürücü her geçişte döşeme tabanlı veya sistem belleğinde işlemeyi seçer. Adreno 710/720/722\'de gereklidir. Steam\'in xalia yardımcısını atla droiddeck-ntsync (deneysel) Linux ntsync sürücüsünün test amaçlı, kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2af43de09..d22466ca7 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -542,7 +542,7 @@ Лише Adreno. Менше запинок, вища витрата заряду та нагрів. Виправлення сеансу Turnip: рендеринг через sysmem - Швидше для Steam і більшості ігор. Потрібно для Adreno 710/720/722. + Швидше для клієнта Steam; в іграх драйвер для кожного проходу вибирає тайловий рендеринг або рендеринг у системній пам’яті. Потрібно для Adreno 710/720/722. Пропускати помічник Steam xalia droiddeck-ntsync (експериментально) Програмна заміна драйвера ntsync ядра Linux у просторі користувача, для тестування. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 70026208a..800e33b10 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -536,7 +536,7 @@ Chỉ dành cho Adreno. Ít giật hơn, dùng nhiều pin và nhiệt hơn. Các bản sửa phiên Turnip: render sysmem - Nhanh hơn cho Steam và hầu hết game. Bắt buộc trên Adreno 710/720/722. + Nhanh hơn cho ứng dụng Steam; trong game, trình điều khiển chọn kết xuất theo ô hoặc trong bộ nhớ hệ thống cho từng lượt. Bắt buộc trên Adreno 710/720/722. Bỏ qua trình trợ giúp xalia của Steam droiddeck-ntsync (thử nghiệm) Một bản thay thế ở không gian người dùng cho driver ntsync của Linux, dùng để thử nghiệm. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7de45e1b8..cb8c9903b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -536,7 +536,7 @@ 仅限 Adreno。卡顿更少,但更耗电、更发热。 会话修复 Turnip:sysmem 渲染 - 对 Steam 和大多数游戏更快。Adreno 710/720/722 必需。 + Steam 客户端更快;游戏中驱动会为每个渲染通道选择分块渲染或系统内存渲染。Adreno 710/720/722 必需。 跳过 Steam 的 xalia 助手 droiddeck-ntsync(实验性) Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 66207ea8d..52207967c 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -536,7 +536,7 @@ 只限 Adreno。減少卡頓,但更耗電、更發熱。 會話修正 Turnip:sysmem 渲染 - Steam 和大部分遊戲會更快。Adreno 710/720/722 必須開啟。 + Steam 用戶端會更快;遊戲中驅動程式會為每個渲染通道選擇分塊渲染或系統記憶體渲染。Adreno 710/720/722 必須開啟。 略過 Steam 的 xalia 輔助程式 droiddeck-ntsync(實驗性) 在用戶空間代替 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7fdd8c25b..9eacff4a1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -536,7 +536,7 @@ 僅適用於 Adreno。卡頓更少,但耗電和發熱更多。 工作階段修復 Turnip:sysmem 渲染 - Steam 和大多數遊戲會更快。Adreno 710/720/722 必需。 + Steam 用戶端會更快;遊戲中驅動程式會為每個算繪通道選擇分塊算繪或系統記憶體算繪。Adreno 710/720/722 必需。 跳過 Steam 的 xalia 輔助程式 droiddeck-ntsync(實驗性) 以使用者空間替代 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff90ecb9a..10d12a620 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -542,7 +542,7 @@ Adreno only. Fewer hitches, more battery use and heat. Session fixes Turnip: sysmem rendering - Faster for Steam and most games. Required on Adreno 710/720/722. + Faster for the Steam client; in games the driver picks tile or system-memory rendering per pass. Required on Adreno 710/720/722. Skip Steam\'s xalia helper droiddeck-ntsync (experimental) A userspace stand-in for the Linux ntsync driver, for testing. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index aa0db0fd3..331dcfecc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -674,6 +674,7 @@ def launch(command, env, mode, decide=None): def game_environment(env): try: game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env")) + env = game_env["game_gpu"](env) app = game_env["game_id"](env) or env.get("SteamAppId") or env.get("SteamGameId") if not app: return env diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index a7fa3a083..21d0f8ebc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -7,6 +7,7 @@ import subprocess import sys from pathlib import Path +GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG" SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC") SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists" PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC") @@ -153,6 +154,18 @@ def win_components(compat, app_id): return "" +def game_gpu(env): + if GAME_TU_DEBUG not in env: + return env + result = env.copy() + value = result.pop(GAME_TU_DEBUG) + if value: + result["TU_DEBUG"] = value + else: + result.pop("TU_DEBUG", None) + return result + + def apply_config(env, config, app_id, fixes=None): if config.get("version") != 1: raise ValueError("unsupported environment configuration") @@ -283,7 +296,7 @@ def main(): command = sys.argv[1:] if not command: return 2 - env = os.environ.copy() + env = game_gpu(os.environ.copy()) mic_enabled = env.get("BL_MIC_ENABLED") mark_install_scripts(command, env) app_id = game_id(env) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index a9eaea65c..a13f6b417 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -188,6 +188,7 @@ present() { awk -F'\t' ' } ' - "$1"; } +regs=() for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ /mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ /root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ @@ -195,8 +196,21 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ /mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \ /root/Games/*/steamapps/compatdata/*/pfx/system.reg; do - [ -f "$reg" ] || continue + [ -f "$reg" ] && regs+=("$reg") +done +declare -A regid=() +if [ ${#regs[@]} -gt 0 ]; then + while IFS=$'\t' read -r id name; do + regid[$name]=$id + done < <(stat -c $'%i %s %.9Y\t%n' -- "${regs[@]}" 2>/dev/null) +fi + +for reg in "${regs[@]}"; do seeded=${reg%/*}/.droiddeck-redists + checked=${reg%/*}/.droiddeck-redists-checked + id=${regid[$reg]:-} + prev= + [ -f "$checked" ] && read -r prev <"$checked" legacy=${reg%/*}/.bannerlator-redists if [ -f "$legacy" ] && [ ! -f "$seeded" ]; then mv -f "$legacy" "$seeded" @@ -210,12 +224,13 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste if [ -n "${vulkan[$line]:-}" ]; then stale+=("$line"); else kept+=("$line"); fi done if [ ${#stale[@]} -gt 0 ] && unseed "$reg" "${stale[@]}"; then + id= had=() for line in "${kept[@]}"; do had[$line]=1; done printf '%s\n' "${kept[@]}" >"$seeded" echo "== took Vulkan runtime markers out of $reg" fi - if [ ${#had[@]} -gt 0 ]; then + if [ ${#had[@]} -gt 0 ] && { [ -z "$id" ] || [ "$id" != "$prev" ]; }; then declare -A held=() while IFS= read -r line; do [[ $line == *$'\t'* ]] && held[$line]=1; done < <(printf '%s\n' "${!had[@]}" | present "$reg") if [ ${#held[@]} -lt ${#had[@]} ]; then @@ -228,7 +243,11 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste fi missing=() for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done - [ ${#missing[@]} -eq 0 ] && continue + if [ ${#missing[@]} -eq 0 ]; then + [ -n "$id" ] && [ "$id" != "$prev" ] && printf '%s\n' "$id" >"$checked" + continue + fi + rm -f "$checked" dated=$(mktemp) || continue touch -r "$reg" "$dated" if printf '%s\n' "$(block_for "${missing[@]}")" >>"$reg"; then diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 5b2914419..908e72e97 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -101,6 +101,17 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(env["REMOVE"], "inherited") self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared") + def test_games_get_their_own_turnip_debug_and_a_game_profile_still_wins(self): + gpu = MODULE["game_gpu"] + self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "", "KEEP": "1"}), {"KEEP": "1"}) + self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"}) + self.assertEqual(gpu({"DROIDDECK_GAME_TU_DEBUG": "nolrz"}), {"TU_DEBUG": "nolrz"}) + self.assertEqual(gpu({"TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"}) + config = {"version": 1, "games": {"42": {"TU_DEBUG": "sysmem"}}} + env = gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""}) + self.assertEqual(MODULE["apply_config"](env, config, "42")["TU_DEBUG"], "sysmem") + self.assertNotIn("TU_DEBUG", MODULE["apply_config"](env, config, "43")) + def test_a_games_wine_sync_choice_also_reaches_its_setup_runs(self): env = {"BL_SYNC": "1", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "0", "OTHER": "kept"} config = {"version": 1, "games": {"42": {"BL_SYNC": "0", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "FEX_TSOENABLED": "1"}}} diff --git a/tools/tests/test_seed_redists.py b/tools/tests/test_seed_redists.py index e23672318..27c5d42a9 100644 --- a/tools/tests/test_seed_redists.py +++ b/tools/tests/test_seed_redists.py @@ -155,6 +155,26 @@ class SeedRedistsTest(unittest.TestCase): subprocess.run(["bash", str(SCRIPT), str(self.steam)], env=env, capture_output=True, text=True, check=True) self.assertIn('"x64 14.99.1.0"=dword:00000001', self.reg.read_text()) + def test_an_unchanged_registry_is_not_read_again(self): + self.game() + self.run_seed() + self.run_seed() + shim = self.tmp / "bin" + shim.mkdir() + log = self.tmp / "awk.log" + (shim / "awk").write_text('#!/bin/sh\nprintf "%%s\\n" "$*" >> "%s"\nexec %s "$@"\n' % (log, shutil.which("awk"))) + (shim / "awk").chmod(0o755) + env = dict(os.environ, DROIDDECK_LIBRARIES="", PATH="%s:%s" % (shim, os.environ["PATH"])) + once = self.reg.read_text() + subprocess.run(["bash", str(SCRIPT), str(self.steam)], env=env, capture_output=True, text=True, check=True) + self.assertEqual(once, self.reg.read_text()) + self.assertNotIn(str(self.reg), log.read_text() if log.exists() else "") + replaced = self.reg.with_name("system.reg.new") + replaced.write_text("WINE REGISTRY Version 2\n") + os.replace(replaced, self.reg) + subprocess.run(["bash", str(SCRIPT), str(self.steam)], env=env, capture_output=True, text=True, check=True) + self.assertEqual(self.reg.read_text().count('"VCRedist2022"=dword:00000001'), 2) + def test_the_prefix_date_is_kept(self): os.utime(self.reg, (1_000_000_000, 1_000_000_000)) self.run_seed() From e53232b30191ea0541ac88e31e29dc789d2c2051 Mon Sep 17 00:00:00 2001 From: FX1922 <120828496+FX192@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:36:25 +0300 Subject: [PATCH 049/156] feat: Better boot movies system; Added steamdeckrepo support (#1036) --- .../com/droiddeck/launcher/MainActivity.kt | 4 +- .../droiddeck/launcher/session/BootMovie.kt | 172 ++++- .../launcher/session/BootMovieLibrary.kt | 410 +++++++++++ .../launcher/session/BootMovieReport.kt | 160 ++++ .../launcher/session/BootMovieTranscoder.kt | 94 ++- .../launcher/session/SessionFiles.kt | 201 ++++- .../launcher/session/SessionPrefs.kt | 11 + .../launcher/session/SessionService.kt | 2 +- .../launcher/session/SettingsReset.kt | 3 +- .../launcher/session/SteamDeckRepo.kt | 218 ++++++ .../com/droiddeck/launcher/ui/AddAppDialog.kt | 3 +- .../droiddeck/launcher/ui/BootMovieSection.kt | 695 ++++++++++++++++++ .../launcher/ui/ModeSettingsDialog.kt | 38 +- app/src/main/res/values-ar/strings.xml | 49 +- app/src/main/res/values-b+es+419/strings.xml | 49 +- app/src/main/res/values-bg/strings.xml | 49 +- app/src/main/res/values-cs/strings.xml | 49 +- app/src/main/res/values-da/strings.xml | 49 +- app/src/main/res/values-de/strings.xml | 49 +- app/src/main/res/values-el/strings.xml | 49 +- app/src/main/res/values-es/strings.xml | 49 +- app/src/main/res/values-fi/strings.xml | 49 +- app/src/main/res/values-fr/strings.xml | 49 +- app/src/main/res/values-hi/strings.xml | 49 +- app/src/main/res/values-hu/strings.xml | 49 +- app/src/main/res/values-in/strings.xml | 49 +- app/src/main/res/values-it/strings.xml | 49 +- app/src/main/res/values-ja/strings.xml | 49 +- app/src/main/res/values-ko/strings.xml | 49 +- app/src/main/res/values-ms/strings.xml | 49 +- app/src/main/res/values-nb/strings.xml | 49 +- app/src/main/res/values-nl/strings.xml | 49 +- app/src/main/res/values-pl/strings.xml | 49 +- app/src/main/res/values-pt-rPT/strings.xml | 49 +- app/src/main/res/values-pt/strings.xml | 49 +- app/src/main/res/values-ro/strings.xml | 49 +- app/src/main/res/values-ru/strings.xml | 49 +- app/src/main/res/values-sv/strings.xml | 49 +- app/src/main/res/values-th/strings.xml | 49 +- app/src/main/res/values-tr/strings.xml | 49 +- app/src/main/res/values-uk/strings.xml | 49 +- app/src/main/res/values-vi/strings.xml | 49 +- app/src/main/res/values-zh-rCN/strings.xml | 49 +- app/src/main/res/values-zh-rHK/strings.xml | 49 +- app/src/main/res/values-zh-rTW/strings.xml | 49 +- app/src/main/res/values/strings.xml | 49 +- .../launcher/session/BootMovieLibraryTest.kt | 310 ++++++++ .../launcher/session/BootMoviePlanTest.kt | 59 ++ .../session/BootMovieTranscoderTest.kt | 17 + .../session/StartupMovieConfigTest.kt | 66 ++ .../launcher/session/SteamDeckRepoTest.kt | 92 +++ docs/development/boot-movies.md | 38 + 52 files changed, 4040 insertions(+), 170 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt create mode 100644 docs/development/boot-movies.md diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 7aab33d1f..98b04c4f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1716,12 +1716,12 @@ class MainActivity : ComponentActivity() { SessionPrefs.setRunSteamAtStartup(this, on) runSteamAtStartup = on }, - onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() }, + onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() }, onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on }, onImportBootMovie = { pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick))) }, - onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() }, + onBootMovieChanged = { loadBootMovie() }, onDeckyInstall = { release -> decky.installDecky(release) }, onDeckyCheck = { decky.refreshDecky() }, onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled }, diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt index 3641caec5..8267edd57 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt @@ -30,7 +30,7 @@ object BootMovie { internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS) sealed class Import { - data class Done(val name: String, val audioDropped: Boolean) : Import() + data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import() object TooLong : Import() data class Unsupported(val codec: String?) : Import() object Failed : Import() @@ -39,6 +39,21 @@ object BootMovie { private fun dir(context: Context) = File(context.filesDir, "boot-movie") private fun customFile(context: Context) = File(dir(context), "custom.webm") + /** A movie imported before movies were kept in Steam's own folder. */ + internal fun legacyCustomFile(context: Context) = customFile(context) + + /** Unpacks one of DroidDeck's own movies on demand; null for anything else. */ + internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) } + + /** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */ + fun select(context: Context, id: String) { + SessionPrefs.setBootMovie(context, id) + SessionPrefs.setBootMovieApplyNext(context, true) + BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}") + // Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later. + SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } + } + fun customName(context: Context): String? = if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null @@ -46,21 +61,127 @@ object BootMovie { val chosen = SessionPrefs.bootMovie(context) return when { chosen == CUSTOM && customFile(context).isFile -> CUSTOM + isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen chosen in ASSETS -> chosen else -> DROIDDECK_30 } } + /** The file the current choice plays from, before any volume or length change. */ + fun currentFile(context: Context): File? = sourceOf(context, choice(context)) + + private fun sourceOf(context: Context, choice: String): File? = when { + choice == CUSTOM -> customFile(context) + isLibraryId(choice) -> BootMovieLibrary.file(context, choice) + else -> ASSETS[choice]?.let { extractAsset(context, it) } + } + + private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX) + fun prepare(context: Context): File? { - val choice = choice(context) - val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null - if (SessionPrefs.bootMovieSound(context)) return source - val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm") - if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted - if (remux(source, muted, audio = false)) return muted - if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted - Log.e(TAG, "could not strip audio from ${source.name}; not staging it") - return null + BootMovieLibrary.restore(context) + val source = sourceOf(context, choice(context)) ?: return null + return variant(context, source) + } + + /** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */ + private fun variant(context: Context, source: File): File? { + val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0 + val seconds = SessionPrefs.bootMovieSeconds(context) + val folder = File(dir(context), "variants") + val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds) + if (plan.passthrough) { + prune(folder, null) + return source + } + folder.mkdirs() + val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE + val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm") + if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) { + prune(folder, out.name) + return out + } + val staged = File(folder, out.name + ".staged") + val ok = runCatching { + plan.steps.any { step -> + when (step) { + Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs) + Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs) + Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs) + Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null + Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs) + } + } + }.getOrDefault(false) + if (ok && staged.renameTo(out)) { + prune(folder, out.name) + return out + } + staged.delete() + Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)") + return if (plan.sourceOnFailure) source else null + } + + internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN } + + /** + * How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and + * whether it still plays as it is when every step fails. + */ + internal class Plan(val passthrough: Boolean, val steps: List, val sourceOnFailure: Boolean) + + internal fun plan(volume: Int, seconds: Int): Plan = when { + volume == 100 && seconds == 0 -> Plan(true, emptyList(), true) + volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false) + // Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder. + volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true) + else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false) + } + + /** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */ + internal const val MAX_LONG_SIDE = 1920 + internal const val MAX_SHORT_SIDE = 1080 + + internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE + + private fun oversized(format: MediaFormat) = + format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) && + oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT)) + + private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true + + /** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */ + internal fun oversizedPlan(volume: Int): Plan = + Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false) + + private fun transcodeSilent(source: File, target: File, limitUs: Long) = + BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null + + /** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */ + private fun prune(folder: File, keep: String?) { + folder.listFiles()?.forEach { if (it.name != keep) it.delete() } + } + + /** + * Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before, + * that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed. + */ + fun syncFromSteam(context: Context): Boolean { + val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false + if (path == SessionPrefs.bootMovieSteamSeen(context)) return false + SessionPrefs.setBootMovieSteamSeen(context, path) + if (path == SessionFiles.BOOT_MOVIE_PATH) return false + val prefix = "/uioverrides/movies/" + // The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder. + val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else { + val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) } + listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path + } + if (BootMovieLibrary.file(context, id) == null) return false + val changed = SessionPrefs.bootMovie(context) != id + BootMovieReport.note(context, "Steam names $path, taken over as $id") + SessionPrefs.setBootMovie(context, id) + return changed } private fun extractAsset(context: Context, name: String): File? { @@ -82,11 +203,15 @@ object BootMovie { } } - fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import { + fun import( + context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}, + title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null, + cover: ByteArray? = null, use: Boolean = true, + ): Import { val (format, audio) = probe(file) ?: return Import.Unsupported(null) val video = format.getString(MediaFormat.KEY_MIME) val audioPlayable = audio == null || audio in AUDIO_CODECS - val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES + val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format) val copy = playable && audioPlayable && isWebm(file) val remux = !copy && playable && video in REMUX_CODECS if (!copy && !remux) { @@ -95,11 +220,12 @@ object BootMovie { if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong onStart() } - val target = customFile(context) - val staged = File(dir(context), "custom.webm.staged") + val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension + val library = BootMovieLibrary.libraryDir(context) + val staged = File(library, ".import-${System.nanoTime()}.staged") var audioDropped = remux && !audioPlayable return try { - target.parentFile?.mkdirs() + library.mkdirs() val ok = when { copy -> { file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } } @@ -108,11 +234,10 @@ object BootMovie { remux -> remux(file, staged, audio = true) else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null } - if (!ok || !staged.renameTo(target)) return Import.Failed - File(target.parentFile, "custom-muted.webm").delete() - SessionPrefs.setBootMovieCustomName(context, file.name) - SessionPrefs.setBootMovie(context, CUSTOM) - Import.Done(file.name, audioDropped) + if (!ok || !staged.setReadable(true, false)) return Import.Failed + val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed + if (use) select(context, BootMovieLibrary.idOf(name)) + Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name)) } catch (e: Exception) { Log.w(TAG, "could not import ${file.name}", e) Import.Failed @@ -124,8 +249,9 @@ object BootMovie { fun removeCustom(context: Context) { customFile(context).delete() File(dir(context), "custom-muted.webm").delete() + File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() } SessionPrefs.setBootMovieCustomName(context, null) - if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30) + if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30) } private fun isWebm(file: File): Boolean { @@ -156,7 +282,7 @@ object BootMovie { } } - private fun remux(source: File, target: File, audio: Boolean): Boolean { + private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean { val staged = File(target.parentFile, target.name + ".remux") val extractor = MediaExtractor() var muxer: MediaMuxer? = null @@ -183,7 +309,7 @@ object BootMovie { val info = MediaCodec.BufferInfo() while (true) { val size = extractor.readSampleData(buffer, 0) - if (size < 0) break + if (size < 0 || extractor.sampleTime > limitUs) break val track = tracks[extractor.sampleTrackIndex] if (track != null) { val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt new file mode 100644 index 000000000..7c125e819 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt @@ -0,0 +1,410 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.media.MediaMetadataRetriever +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import java.io.File + +/** + * The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in + * Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own + * three startup movies and the movies bought in Steam's points shop (neither can be removed). + */ +object BootMovieLibrary { + private const val TAG = "BootMovieLibrary" + const val PREFIX = "file:" + + /** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:`. */ + const val STEAM_PREFIX = "steam:" + + /** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */ + private val lock = Any() + + /** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */ + internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm") + + const val SOURCE_DROIDDECK = "droiddeck" + const val SOURCE_IMPORT = "import" + const val SOURCE_REPO = "repo" + const val SOURCE_STEAM = "steam" + const val SOURCE_POINTSHOP = "pointshop" + + class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean) + + internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies") + + /** + * The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the + * x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file. + */ + internal fun customMovieDirs(context: Context): List = + listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path } + + /** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */ + internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library") + + private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json") + + internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + + /** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */ + internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf") + + private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs") + + fun idOf(name: String) = PREFIX + name + + /** Every Steam client folder: the usual one and the x86-64 client's. */ + internal fun steamRoots(context: Context): List = listOf( + steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"), + ).filter { it.isDirectory } + + private fun steamFile(context: Context, id: String): File? { + val path = id.removePrefix(STEAM_PREFIX) + if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null + return steamRoots(context).firstNotNullOfOrNull { root -> + listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L } + } + } + + fun file(context: Context, id: String): File? { + if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id) + if (!id.startsWith(PREFIX)) return null + val name = id.removePrefix(PREFIX) + if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null + return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L } + } + + private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) && + file.name !in STAGED && !file.name.startsWith(".") + + private fun readMeta(context: Context): JSONObject = + runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject()) + + /** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */ + private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) { + val meta = readMeta(context) + if (change(meta)) { + val file = metaFile(context) + file.parentFile?.mkdirs() + val staged = File(file.parentFile, file.name + ".staged") + runCatching { + staged.writeText(meta.toString()) + if (!staged.renameTo(file)) staged.delete() + }.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) } + } + } + + /** The post a movie was installed from, if it came from SteamDeckRepo. */ + fun repoId(context: Context, name: String): String? = + readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() } + + fun installedRepoIds(context: Context): Set { + val meta = readMeta(context) + return meta.keys().asSequence().filter { file(context, idOf(it)) != null } + .mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet() + } + + fun nameForRepo(context: Context, repoId: String): String? { + val meta = readMeta(context) + return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId } + } + + fun list(context: Context): List { + val out = ArrayList() + out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false)) + out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false)) + BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) } + restore(context) + val meta = readMeta(context) + val names = HashSet() + customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() } + .filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f -> + val info = meta.optJSONObject(f.name) + val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM + val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension + // Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand). + out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true)) + } + val steam = LinkedHashMap() + SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path -> + steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it } + } + val bought = HashMap() + for (root in steamRoots(context)) { + File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty() + .sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) } + val scan = pointShopMovies(root) + bought.putAll(scan.titles) + for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found) + } + val seen = HashSet() + for ((id, file) in steam) { + if (file.name in STAGED || !seen.add(file.canonicalPath)) continue + val native = file.nameWithoutExtension.lowercase() in NATIVE + // Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it. + val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension + out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false)) + } + return out + } + + /** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */ + private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup") + + private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache") + + private val scanLock = Any() + private class Scan(val at: Long, val files: List, val titles: Map) + + private val scans = HashMap() + + /** Forgets the last search for Steam's own movies. */ + internal fun forgetScan() = synchronized(scanLock) { scans.clear() } + + /** + * Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the + * client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder. + */ + private fun pointShopMovies(root: File): Scan = synchronized(scanLock) { + val now = System.currentTimeMillis() + scans[root.path]?.let { if (now - it.at < 15_000L) return it } + val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top -> + val base = File(root, top) + if (!base.isDirectory) return@flatMap emptyList() + base.walkTopDown().maxDepth(5) + .onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") } + .take(20_000).filter { isMovie(it) }.toList() + }.sortedBy { it.path.lowercase() } + val titles = HashMap() + for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } } + val scan = Scan(now, found, titles) + scans[root.path] = scan + scan + } + + /** The item number in the name Steam gives a bought movie, `_.webm`; null for any other name. */ + private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1) + + /** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */ + private fun boughtTitle(root: File, id: String): String? { + val key = "communityitemid\\\":\\\"$id\\\"" + val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)") + for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) { + val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue + val at = text.indexOf(key) + if (at < 0) continue + title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + + private const val OVERRIDES = "/uioverrides/movies/" + + /** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */ + internal fun restore(context: Context) = synchronized(lock) { + migrateLegacyMeta(context) + val meta = readMeta(context) + val library = libraryDir(context) + meta.keys().asSequence().toList().forEach { name -> + val kept = File(library, name) + if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach + val copy = File(moviesDir(context), name) + if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy) + } + } + + /** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */ + private fun migrateLegacyMeta(context: Context) { + val legacy = File(moviesDir(context), ".droiddeck-movies.json") + if (!legacy.isFile) return + val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return + editMeta(context) { meta -> + var changed = false + old.keys().asSequence().toList().forEach { name -> + val info = old.optJSONObject(name) ?: return@forEach + if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true } + } + changed + } + legacy.delete() + } + + /** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */ + private fun mirror(kept: File, copy: File): Boolean { + val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged") + return try { + copy.parentFile?.mkdirs() + kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } } + staged.setReadable(true, false) && staged.renameTo(copy) + } catch (e: Exception) { + Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e) + false + } finally { + staged.delete() + } + } + + fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id + + /** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */ + fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id) + + /** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */ + fun freeName(context: Context, title: String): String { + val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" } + val dirs = customMovieDirs(context) + val library = libraryDir(context) + var name = "$base.webm" + var n = 2 + while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ } + return name + } + + /** + * Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its + * title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time. + */ + internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? = + synchronized(lock) { + val name = freeName(context, title) + val kept = File(libraryDir(context), name) + if (!staged.renameTo(kept)) return null + if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null } + register(context, name, title, source, repoId, cover) + name + } + + /** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */ + fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) { + editMeta(context) { meta -> + val info = JSONObject().put("title", title).put("source", source) + if (repoId != null) info.put("repo", repoId) + meta.put(name, info) + true + } + if (cover != null) runCatching { + thumbsDir(context).mkdirs() + File(thumbsDir(context), coverName(name)).writeBytes(cover) + } + } + + fun remove(context: Context, id: String): Boolean { + val removed = when { + id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true } + else -> { + // Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone. + if (!id.startsWith(PREFIX)) return false + val name = id.removePrefix(PREFIX) + if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false + val copies = customMovieDirs(context).map { File(it, name) } + // A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted. + val target = copies.firstOrNull { it.exists() } + val managed = File(libraryDir(context), name) + if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false + synchronized(lock) { + val main = File(moviesDir(context), name) + val another = copies.any { it != target && it.exists() } + val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile) + var gone = target == null || target.delete() + if (gone && ownsRecord) { + gone = !managed.exists() || managed.delete() + if (gone) { + editMeta(context) { it.remove(name) != null } + File(thumbsDir(context), coverName(name)).delete() + thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() } + } + } + if (gone) { + val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) } + File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() } + } + gone + } + } + } + if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30) + return removed + } + + /** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */ + internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-" + + private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + + private fun coverName(name: String) = "cover-" + safe(name) + + /** How long a movie runs, in whole seconds; 0 when it cannot be read. */ + fun durationSeconds(file: File?): Int { + if (file == null || !file.isFile) return 0 + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.path) + val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + ((ms + 500) / 1000).toInt() + } catch (e: Exception) { + 0 + } finally { + runCatching { retriever.release() } + } + } + + /** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */ + private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? { + var best: Bitmap? = null + var bestLuma = -1 + for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) { + val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue + val luma = averageLuma(frame) + if (luma >= MIN_COVER_LUMA) return frame + if (luma > bestLuma) { best = frame; bestLuma = luma } + } + return best ?: runCatching { retriever.frameAtTime }.getOrNull() + } + + private fun averageLuma(bitmap: Bitmap): Int { + val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true) + var sum = 0L + for (y in 0 until small.height) for (x in 0 until small.width) { + val c = small.getPixel(x, y) + sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10 + } + return (sum / (small.width * small.height)).toInt() + } + + private const val MIN_COVER_LUMA = 24 + + /** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */ + fun cover(context: Context, entry: Entry): File? { + val file = fileOf(context, entry) ?: return null + val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id + // Points Shop covers once kept a picture that was not always the movie's own; those files are not used. + val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name)) + if (given.isFile && given.length() > 0L) return given + val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg" + val frame = File(thumbsDir(context), key) + if (frame.isFile && frame.length() > 0L) return frame + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.path) + val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + val bitmap = brightestFrame(retriever, ms) ?: return null + val width = 480 + val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap + thumbsDir(context).mkdirs() + thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() } + val staged = File(thumbsDir(context), "$key.staged") + staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) } + if (staged.renameTo(frame)) frame else null + } catch (e: Exception) { + Log.w(TAG, "no cover for ${file.name}", e) + null + } finally { + runCatching { retriever.release() } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt new file mode 100644 index 000000000..3a672dcfd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt @@ -0,0 +1,160 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Environment +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and + * where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config. + */ +object BootMovieReport { + private const val TAG = "BootMovieReport" + private const val MAX_TRACE_LINES = 200 + private const val MAX_HITS = 60 + private val KNOWN_TITLES = listOf("Turret Factory") + private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs") + private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak") + private val lock = Any() + + private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log") + + /** Adds a line to the short trace kept for the report; only the latest lines are kept. */ + fun note(context: Context, line: String) { + synchronized(lock) { + runCatching { + val file = traceFile(context) + file.parentFile?.mkdirs() + val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) + val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line" + file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n") + } + } + } + + /** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */ + fun write(context: Context): File? { + val text = runCatching { build(context) }.getOrElse { + Log.w(TAG, "could not build the report", it) + return null + } + val folders = listOfNotNull( + File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"), + context.getExternalFilesDir(null), + ) + for (folder in folders) { + val file = File(folder, "boot-movie-report.txt") + val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false) + if (saved) return file + Log.w(TAG, "could not write the report to ${file.path}") + } + return null + } + + private fun describe(file: File) = + "${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}" + + internal fun build(context: Context): String { + val out = StringBuilder() + fun line(text: String = "") { out.append(text).append('\n') } + fun listing(title: String, folder: File) { + line("-- $title: ${folder.path}") + val files = folder.listFiles()?.sortedBy { it.name.lowercase() } + if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)") + else files.forEach { line(" " + describe(it)) } + } + + line("DroidDeck boot movie report, ${Date()}") + line() + line("== DroidDeck state") + line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}") + line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}") + line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}") + line("session running=${SessionState.running}") + line() + line("== The movie list as shown") + BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") } + line() + line("== DroidDeck folders") + listing("library", BootMovieLibrary.libraryDir(context)) + listing("processed copies", File(context.filesDir, "boot-movie/variants")) + val meta = File(context.filesDir, "boot-movie/movies.json") + line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}") + + val roots = BootMovieLibrary.steamRoots(context) + val movieIds = HashSet() + for (root in roots) { + line() + line("== Steam folder ${root.path}") + val config = File(root, "config/config.vdf") + line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}") + val text = runCatching { config.readText() }.getOrNull() + if (text != null) { + line("-- StartupMovie block:") + (SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") } + line("-- Customization section with the lines before it:") + (SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") } + line("-- every line of config.vdf that mentions a movie:") + text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") } + Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) } + } + listing("custom movies", File(root, "config/uioverrides/movies")) + listing("Steam's own movies", File(root, "steamui/movies")) + listing("config/movies", File(root, "config/movies")) + line("-- every .webm below config, userdata, appcache and steamui:") + var shown = 0 + for (top in listOf("config", "userdata", "appcache", "steamui")) { + val base = File(root, top) + if (!base.isDirectory) continue + for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) { + if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue + if (shown++ >= 300) break + line(" " + describe(file)) + Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) } + } + } + } + + line() + line("== Where Steam keeps movie names (text found in Steam's files)") + val needles = (KNOWN_TITLES + movieIds).distinct() + line("looking for: ${needles.joinToString(", ")}") + search(roots, needles) { line(" $it") } + + line() + line("== What each session start did to Steam's config (latest last)") + val trace = traceFile(context) + if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)") + return out.toString() + } + + private fun search(roots: List, needles: List, report: (String) -> Unit) { + if (needles.isEmpty()) return + val deadline = System.currentTimeMillis() + 25_000L + var scanned = 0 + var hits = 0 + outer@ for (root in roots) { + for (top in listOf("config", "userdata", "appcache", "steamui")) { + val base = File(root, top) + if (!base.isDirectory) continue + for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) { + if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue + if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer + val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue + for (needle in needles) { + val at = text.indexOf(needle) + if (at < 0) continue + hits++ + val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length)) + report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString("")) + } + } + } + } + report("(searched $scanned files, $hits matches)") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt index ff1275d85..92bbbae4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt @@ -37,15 +37,77 @@ internal object BootMovieTranscoder { MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null }.getOrDefault(false) - fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? { + fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? { val hadAudio = keepAudio && trackFormat(source, "audio/") != null - val audio = if (hadAudio) runCatching { encodeAudio(source) } + val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) } .onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null - val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) } + val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) } .onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null return Result(audioDropped = hadAudio && !muxedAudio) } + /** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */ + fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean { + val audio = runCatching { encodeAudio(source, gain, limitUs) } + .onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false + val extractor = MediaExtractor() + var muxer: MediaMuxer? = null + val staged = File(target.parentFile, target.name + ".regain") + return try { + extractor.setDataSource(source.path) + var videoTrack = -1 + var videoFormat: MediaFormat? = null + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) ?: continue + if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) { + videoTrack = i + videoFormat = format + } + } + if (videoTrack < 0 || videoFormat == null) return false + extractor.selectTrack(videoTrack) + val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it } + val outVideo = mux.addTrack(videoFormat) + val outAudio = mux.addTrack(audio.format) + mux.start() + val info = MediaCodec.BufferInfo() + var audioIndex = 0 + fun writeAudioUpTo(timeUs: Long) { + while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) { + val sample = audio.samples[audioIndex++] + info.set(0, sample.data.size, sample.timeUs, 0) + mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info) + } + } + var bufferSize = 8 shl 20 + if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)) + val buffer = ByteBuffer.allocate(bufferSize) + while (true) { + val size = extractor.readSampleData(buffer, 0) + if (size < 0 || extractor.sampleTime > limitUs) break + val time = extractor.sampleTime + writeAudioUpTo(time) + val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0 + info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0) + mux.writeSampleData(outVideo, buffer, info) + extractor.advance() + } + writeAudioUpTo(Long.MAX_VALUE) + mux.stop() + mux.release() + muxer = null + staged.renameTo(target) + } catch (e: Exception) { + Log.w(TAG, "could not change the volume of ${source.name}", e) + false + } finally { + runCatching { muxer?.release() } + extractor.release() + staged.delete() + } + } + private fun trackFormat(source: File, prefix: String): Pair? { val extractor = MediaExtractor() return try { @@ -75,6 +137,14 @@ internal object BootMovieTranscoder { return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch) } + /** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */ + internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray { + val frames = pcm.size / channels + val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt() + if (gain == 1f && kept == frames) return pcm + return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() } + } + internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray { if (rate == OPUS_RATE) return pcm val frames = pcm.size / channels @@ -94,7 +164,10 @@ internal object BootMovieTranscoder { return out } - private fun encodeAudio(source: File): Audio? { + /** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */ + private const val MAX_PCM_BYTES = 64 shl 20 + + private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? { val (track, inFormat) = trackFormat(source, "audio/") ?: return null val extractor = MediaExtractor() var decoder: MediaCodec? = null @@ -139,13 +212,16 @@ internal object BootMovieTranscoder { } dec.releaseOutputBuffer(index, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break + // Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped. + if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break + if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break } } } if (channels !in 1..2) return null val raw = pcm.toByteArray() val shorts = ShortArray(raw.size / 2) ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts) - val samples = resample(shorts, channels, rate) + val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs) val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels) format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000) @@ -201,7 +277,7 @@ internal object BootMovieTranscoder { private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() } - private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean { + private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean { val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track") val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360) val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation) @@ -278,7 +354,8 @@ internal object BootMovieTranscoder { val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US) if (index >= 0) { val timeUs = decInfo.presentationTimeUs - val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs) + val pastLimit = timeUs > limitUs + val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs) dec.releaseOutputBuffer(index, keep) if (keep) { if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out") @@ -290,9 +367,10 @@ internal object BootMovieTranscoder { if (percent != lastProgress) { lastProgress = percent; onProgress(percent) } } } - if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { enc.signalEndOfInputStream() decoderDone = true + inputDone = true } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 86da51e43..3b0af016d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -28,7 +28,8 @@ object SessionFiles { "etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop", ) - fun stage(context: Context, root: File) { + /** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */ + fun stage(context: Context, root: File, startsSession: Boolean = false) { if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) } catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) } GameEnvironmentStore.publish(context) @@ -208,18 +209,72 @@ object SessionFiles { Log.e(TAG, "could not write steam-compatibility-label") } + BootMovie.syncFromSteam(context) val startupMovie = BootMovie.prepare(context) - // The x86-64 client's config is left alone until its first start creates it. - val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam")) - .filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory } - for (steam in steamRoots) { - val startupMovieDir = File(steam, "config/uioverrides/movies") - val bigPictureMovieInstalled = startupMovie != null && - stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm") - if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm") - if (bigPictureMovieInstalled) { - ensureStartupMovieDefault(File(steam, "config/config.vdf")) + val force = SessionPrefs.bootMovieApplyNext(context) + val steamHomes = listOf(File(root, "root/.local/share/Steam")) + + listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory } + var allDone = true + var allInstalled = true + var seen: String? = null + val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64" + for (home in steamHomes) { + val movieDir = File(home, "config/uioverrides/movies") + val config = File(home, "config/config.vdf") + if (startupMovie == null) { + // Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either. + for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) { + File(movieDir, name).delete() + File(movieDir, ".$name.source").delete() + } } + val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm") + if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm") + val before = startupMoviePath(config) + val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force) + BootMovieReport.note( + context, + "stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " + + "staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}", + ) + if (!done) allDone = false + if (!bigPictureMovieInstalled) allInstalled = false + if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else "" + } + // A pick made in the app is done once a session started with every Steam client's config having it; guest commands run + // before that, and a Steam that is already running writes its own choice back when it exits. + if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false) + seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) } + } + + /** + * Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that + * could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config + * no longer names a missing movie. + */ + internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean { + if (!config.isFile) return true + val text = runCatching { config.readText() }.getOrElse { + Log.w(TAG, "could not read Steam config to reset the startup movie", it) + return false + } + val movie = activeStartupMovie(text) ?: return true + val block = text.substring(movie.keyStart, movie.close + 1) + val path = scalar(block, "LocalPath").orEmpty() + if (path.isEmpty()) return true + val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR)) + val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH) + if (!gone) return false + val newline = if (text.contains("\r\n")) "\r\n" else "\n" + val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline) + val staged = File(config.parentFile, config.name + ".staged") + return try { + staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated)) + (staged.renameTo(config)).also { if (!it) staged.delete() } + } catch (e: Exception) { + staged.delete() + Log.w(TAG, "could not reset Steam startup movie selection", e) + false } } @@ -273,7 +328,11 @@ object SessionFiles { staged.outputStream().use { output -> FileUtils.copy(input, output) } } installed = staged.setReadable(true, false) && staged.renameTo(movie) - if (installed) FileUtils.writeString(stamp, identity) + if (installed) { + FileUtils.writeString(stamp, identity) + // Steam's picker draws its preview from this picture and does not redraw it when the movie changes. + File(directory, name.removeSuffix(".webm") + ".jpg").delete() + } } catch (e: Exception) { Log.w(TAG, "could not stage Steam startup movie $name", e) } finally { @@ -285,43 +344,108 @@ object SessionFiles { private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int) - private fun ensureStartupMovieDefault(config: File) { - val path = "/uioverrides/movies/bigpicture_startup.webm" + internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/" + internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm" + + /** + * Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam + * settings is where older clients kept it, and DroidDeck still keeps it in step. + */ + private fun customizationMovie(text: String): VdfBlock? { + var from = 0 + while (true) { + val customization = findObject(text, "Customization", from, text.length) ?: return null + findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it } + from = customization.close + 1 + } + } + + /** The older `StartupMovie` section: the first one that is not inside `Customization`. */ + private fun olderMovie(text: String): VdfBlock? { + val inside = customizationMovie(text) + var from = 0 + while (true) { + val movie = findObject(text, "StartupMovie", from, text.length) ?: return null + if (inside == null || movie.keyStart != inside.keyStart) return movie + from = movie.close + 1 + } + } + + /** The section Steam reads: the one under `Customization` when there is one. */ + private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text) + + /** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */ + internal fun startupMoviePath(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val movie = activeStartupMovie(text) ?: return null + return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() } + } + + /** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */ + internal fun startupMovieBlock(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val parts = listOfNotNull( + customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) }, + olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) }, + ) + return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") + } + + /** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */ + internal fun customizationBlock(config: File): String? { + val text = runCatching { config.readText() }.getOrNull() ?: return null + val customization = findObject(text, "Customization", 0, text.length) ?: return null + val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n") + return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000) + } + + private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String { + val block = text.substring(movie.keyStart, movie.close + 1) + return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline)) + } + + /** True when Steam's config selects DroidDeck's staged movie once this returns. */ + internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean { + val path = BOOT_MOVIE_PATH val legacyPath = "/uioverrides/movies/droiddeck-startup.webm" var text = if (config.isFile) runCatching { config.readText() }.getOrElse { Log.w(TAG, "could not read Steam config for startup movie", it) - return + return false } else "" val newline = if (text.contains("\r\n")) "\r\n" else "\n" - val movie = findObject(text, "StartupMovie", 0, text.length) - if (movie != null) { - val block = text.substring(movie.keyStart, movie.close + 1) + val custom = customizationMovie(text) + val active = custom ?: olderMovie(text) + if (active != null) { + val block = text.substring(active.keyStart, active.close + 1) val selectedId = scalar(block, "MovieID") val selectedPath = scalar(block, "LocalPath") - if (selectedPath == path) { + if (selectedPath == path && custom != null) { Log.i(TAG, "Steam startup movie is set to DroidDeck") - return + return true } val legacyDefault = selectedId == "0" && selectedPath == legacyPath val hasExplicitSelection = (!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty() - if (!legacyDefault && hasExplicitSelection) { + if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) { Log.i(TAG, "Steam startup movie selection preserved") - return + return false } - val updated = setScalar(block, "MovieID", "0", newline) - .let { setScalar(it, "LocalPath", path, newline) } - text = text.replaceRange(movie.keyStart, movie.close + 1, updated) - } else { + } + // Later sections first, so the positions of earlier ones stay valid. + val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart } + for (target in targets) text = pointAt(text, target, path, newline) + if (olderMovie(text) == null) { val steam = findSteamBlock(text) if (steam == null) { - if (text.isNotBlank()) { + if (text.isNotBlank() && customizationMovie(text) == null) { Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set") - return + return false + } + if (text.isBlank()) { + text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}" + val section = startupMovieSection("\t\t\t\t", path, newline) + text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}" } - text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}" - val section = startupMovieSection("\t\t\t\t", path, newline) - text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}" } else { val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 } val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' } @@ -329,22 +453,35 @@ object SessionFiles { text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart) } } + if (customizationMovie(text) == null) text = withCustomization(text, path, newline) val staged = File(config.parentFile, config.name + ".staged") - try { + return try { config.parentFile?.mkdirs() staged.writeText(text) if (!staged.renameTo(config)) { staged.delete() Log.e(TAG, "could not write Steam startup movie selection") + false } else { Log.i(TAG, "Steam startup movie default selected") + true } } catch (e: Exception) { staged.delete() Log.w(TAG, "could not write Steam startup movie selection", e) + false } } + /** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */ + private fun withCustomization(text: String, path: String, newline: String): String { + val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text + val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 } + val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' } + val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline) + return text.substring(0, lineStart) + section + newline + text.substring(lineStart) + } + private fun findSteamBlock(text: String): VdfBlock? { val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null val software = findObject(text, "Software", install.open + 1, install.close) ?: return null diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 3cf155ccb..25be1b42f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -528,6 +528,17 @@ object SessionPrefs { fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() } fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true) fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() } + fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100) + fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() } + /** How long the boot movie plays, in seconds; 0 plays all of it. */ + fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0) + fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() } + /** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */ + fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false) + fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() } + /** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */ + fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null) + fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() } fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null) fun setBootMovieCustomName(context: Context, name: String?) { prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply() 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 d6825e700..0cfc0a301 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -319,7 +319,7 @@ class SessionService : Service() { val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() } val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() } killStragglers() - SessionFiles.stage(this, root) + SessionFiles.stage(this, root, startsSession = true) // Earlier sessions' reports still read the Steam logs and VAC marker this session replaces. SessionArtifacts.awaitCollections() SessionArtifacts.finishAbandoned(this) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index f5304ed1e..78bbc89cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -21,7 +21,8 @@ object SettingsReset { "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics", "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", - "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", + "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", + "bootMovieApplyNext", "bootMovieSteamSeen", "questDefaultsV1", ) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt new file mode 100644 index 000000000..2a940a96f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt @@ -0,0 +1,218 @@ +package com.droiddeck.launcher.session + +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */ +object SteamDeckRepo { + private const val TAG = "SteamDeckRepo" + const val BASE = "https://steamdeckrepo.com" + + /** The site shows twelve movies a page. */ + const val PAGE_SIZE = 12 + const val MAX_DOWNLOAD_BYTES = 120L shl 20 + private const val MAX_JSON_BYTES = 1 shl 20 + private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)" + private const val CACHE_MS = 10 * 60 * 1000L + + enum class Sort(val key: String) { + TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"), + } + + /** `null` is every device. */ + val DEVICES = listOf("steam_deck", "steam_machine") + + class Post( + val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String, + val seconds: Int, val likes: Int, val downloads: Int, val devices: List, val url: String, + ) + + sealed class Page { + class Ok(val posts: List) : Page() + object Failed : Page() + } + + /** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */ + private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key) + + fun parse(json: String): List? { + val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null + val out = ArrayList() + for (i in 0 until array.length()) { + val o = array.optJSONObject(i) ?: continue + val id = o.text("id") + val video = o.text("video") + if (id.isEmpty() || !video.startsWith("https://")) continue + val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty() + out.add( + Post( + id = id, title = o.text("title").ifBlank { id }, content = o.text("content"), + author = o.optJSONObject("user")?.text("steam_name").orEmpty(), + thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video, + seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"), + devices = devices, url = o.text("url"), + ), + ) + } + return out + } + + fun url(sort: Sort, device: String?, search: String, page: Int = 1): String { + val query = StringBuilder("sort=").append(sort.key) + if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8")) + val term = search.trim() + if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20")) + if (page > 1) query.append("&page=").append(page) + return "$BASE/api/posts?$query" + } + + private class Cached(val at: Long, val page: Page.Ok) + private val cache = HashMap() + + fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page { + val url = url(sort, device, search, page) + synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } } + val body = get(url) ?: return Page.Failed + val posts = parse(body) ?: return Page.Failed + val result = Page.Ok(posts.take(PAGE_SIZE)) + synchronized(cache) { cache[url] = Cached(now, result) } + return result + } + + /** + * The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a + * page past the end is empty and the last one is short. Null when the site cannot be reached. + */ + fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p -> + (fetch(sort, device, search, p) as? Page.Ok)?.posts?.size + }): Int? { + val first = count(1) ?: return null + if (first < PAGE_SIZE) return 1 + var good = 1 + var bad = -1 + var probe = 2 + while (probe <= cap) { + val n = count(probe) ?: return null + if (n == 0) { bad = probe; break } + if (n < PAGE_SIZE) return probe + good = probe + probe *= 2 + } + if (bad < 0) return good + while (bad - good > 1) { + val mid = (good + bad) / 2 + val n = count(mid) ?: return null + if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid + } + return good + } + + /** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */ + internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + if (out.size() + n > limit) return null + out.write(buffer, 0, n) + } + return out.toByteArray() + } + + private fun get(url: String): String? { + var connection: HttpURLConnection? = null + return try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/json") + } + if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) } + } catch (e: Exception) { + Log.w(TAG, "request failed: ${e.javaClass.simpleName}") + null + } finally { + runCatching { connection?.disconnect() } + } + } + + /** Fetches a small picture such as a cover; null when it cannot be had. */ + fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? { + if (!url.startsWith("https://")) return null + var connection: HttpURLConnection? = null + return try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("User-Agent", USER_AGENT) + } + if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null + else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } } + } catch (e: Exception) { + null + } finally { + runCatching { connection?.disconnect() } + } + } + + /** + * Downloads a movie to [target] through the site's own download address, so the site counts it like a download from + * its website, and falls back to the video's address. True when a file was saved. + */ + fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean { + for (url in listOf("$BASE/post/download/${post.id}", post.video)) { + if (save(url, target, onProgress)) return true + } + return false + } + + private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean { + var connection: HttpURLConnection? = null + val staged = File(target.parentFile, target.name + ".part") + return try { + target.parentFile?.mkdirs() + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", USER_AGENT) + } + if (connection.responseCode !in 200..299) return false + val type = connection.contentType.orEmpty().lowercase() + if (type.startsWith("text/") || type.contains("json")) return false + val total = connection.contentLengthLong + if (total > MAX_DOWNLOAD_BYTES) return false + var done = 0L + var lastPercent = -1 + connection.inputStream.use { input -> + staged.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + done += n + if (done > MAX_DOWNLOAD_BYTES) return false + output.write(buffer, 0, n) + if (total > 0) { + val percent = (done * 100 / total).toInt() + if (percent != lastPercent) { lastPercent = percent; onProgress(percent) } + } + } + } + } + done > 0 && (total <= 0 || done == total) && staged.renameTo(target) + } catch (e: Exception) { + Log.w(TAG, "download failed: ${e.javaClass.simpleName}") + false + } finally { + runCatching { connection?.disconnect() } + staged.delete() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt index bc9618c96..ccd7fd05e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -305,6 +305,7 @@ internal fun AppDialog( modifier: Modifier = Modifier, maxWidth: Dp? = null, width: Dp? = null, fitContent: Boolean = false, back: (() -> Unit)? = null, + scrollable: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { val pal = LocalPalette.current @@ -342,7 +343,7 @@ internal fun AppDialog( .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .controllerBack(back ?: close) .then(modifier) - .verticalScroll(rememberScrollState()) + .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) .padding(horizontal = 18.dp, vertical = 16.dp), content = content, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt new file mode 100644 index 000000000..3878d274b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BootMovieSection.kt @@ -0,0 +1,695 @@ +package com.droiddeck.launcher.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import android.graphics.SurfaceTexture +import android.media.MediaPlayer +import android.net.Uri +import android.view.Surface +import android.view.TextureView +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.viewinterop.AndroidView +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.BootMovie +import com.droiddeck.launcher.session.BootMovieLibrary +import com.droiddeck.launcher.session.BootMovieReport +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SteamDeckRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +private enum class MovieTab { INSTALLED, IMPORT, REPO, SOUND } + +/** Downloads run here rather than in a tab, so switching tabs or closing the settings does not cancel one halfway. */ +private val installScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + +/** The downloads under way, by post id and percent; kept here so they are still known when the tab or the settings come back. */ +private val installProgress = mutableStateMapOf() + +/** What the movie list looks like right now: every movie and the one Steam will play. */ +private class MovieSnapshot(val entries: List, val selected: String) + +/** The movie list, read again every couple of seconds so a movie picked, added or removed inside Steam shows up here too. */ +@Composable +private fun rememberMovies(refresh: Int, onSelectionChanged: () -> Unit): MovieSnapshot? { + val app = LocalContext.current.applicationContext + var snapshot by remember { mutableStateOf(null) } + val changed by androidx.compose.runtime.rememberUpdatedState(onSelectionChanged) + LaunchedEffect(refresh) { + while (true) { + val before = snapshot?.selected + snapshot = withContext(Dispatchers.IO) { + BootMovie.syncFromSteam(app) + MovieSnapshot(BootMovieLibrary.list(app), BootMovie.choice(app)) + } + if (before != null && before != snapshot?.selected) changed() + delay(2_000) + } + } + return snapshot +} + +@Composable +private fun TabChip(label: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(8.dp) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.clip(shape) + .background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f)) + .border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal) + .hoverable(src) + .clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = label; this.selected = selected } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text(label, fontSize = 12.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** The "Boot movie" settings group: Installed movies, Import your own movies, SteamDeckRepo and Sound. */ +@Composable +internal fun BootMovieGroup(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) { + var tab by rememberSaveable { mutableStateOf(MovieTab.INSTALLED) } + SettingsGroup(stringResource(R.string.boot_movie)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)) { + TabChip(stringResource(R.string.boot_movie_tab_installed), tab == MovieTab.INSTALLED, Modifier.weight(1f)) { tab = MovieTab.INSTALLED } + TabChip(stringResource(R.string.boot_movie_tab_import), tab == MovieTab.IMPORT, Modifier.weight(1f)) { tab = MovieTab.IMPORT } + TabChip(stringResource(R.string.boot_movie_tab_repo), tab == MovieTab.REPO, Modifier.weight(1f)) { tab = MovieTab.REPO } + TabChip(stringResource(R.string.boot_movie_tab_sound), tab == MovieTab.SOUND, Modifier.weight(1f)) { tab = MovieTab.SOUND } + } + when (tab) { + MovieTab.INSTALLED -> InstalledTab(a) + MovieTab.IMPORT -> ImportTab(s, a) + MovieTab.REPO -> RepoTab(a) + MovieTab.SOUND -> SoundTab(s, a, host) + } + } +} + +@Composable +private fun InstalledTab(a: ModeSettingsActions) { + var browsing by remember { mutableStateOf(false) } + val snapshot = rememberMovies(0, a.onBootMovieChanged) + val current = snapshot?.let { snap -> snap.entries.firstOrNull { it.id == snap.selected }?.title } + val hint = current ?: stringResource(R.string.common_working) + SettingsRow(stringResource(R.string.boot_movie_installed), hint) { + SecondaryButton(stringResource(R.string.boot_movie_browse), onClick = { browsing = true }) + } + Text( + stringResource(R.string.boot_movie_note), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp), + ) + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + var saving by remember { mutableStateOf(false) } + // The report is for bug reports, so it shows only while Debugging tools is on (read each time this tab opens). + val debugging = remember { com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(app) } + if (debugging) { + SettingsRow(stringResource(R.string.boot_movie_report), stringResource(R.string.boot_movie_report_hint)) { + SecondaryButton(stringResource(R.string.boot_movie_report_save), enabled = !saving, onClick = { + saving = true + scope.launch { + val file = withContext(Dispatchers.IO) { BootMovieReport.write(app) } + saving = false + Toast.makeText( + app, + file?.let { app.getString(R.string.boot_movie_report_saved, it.path) } ?: app.getString(R.string.boot_movie_report_failed), + Toast.LENGTH_LONG, + ).show() + } + }) + } + } + if (browsing) InstalledMoviesDialog(a) { browsing = false } +} + +@Composable +private fun MovieCover(entry: BootMovieLibrary.Entry, modifier: Modifier = Modifier) { + val app = LocalContext.current.applicationContext + val cover by androidx.compose.runtime.produceState(null, entry.id, entry.title) { + value = withContext(Dispatchers.IO) { BootMovieLibrary.cover(app, entry) } + } + Box(modifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp)).background(Color(0xFF0E1218))) { + cover?.let { AsyncImage(model = it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } + } +} + +@Composable +private fun movieSourceLabel(source: String): String = stringResource( + when (source) { + BootMovieLibrary.SOURCE_DROIDDECK -> R.string.boot_movie_source_droiddeck + BootMovieLibrary.SOURCE_REPO -> R.string.boot_movie_source_repo + BootMovieLibrary.SOURCE_IMPORT -> R.string.boot_movie_source_import + BootMovieLibrary.SOURCE_POINTSHOP -> R.string.boot_movie_source_pointshop + else -> R.string.boot_movie_source_steam + }, +) + +/** The installed movies as a list with covers; the one Steam plays is highlighted, and the list follows changes made in Steam. */ +@Composable +private fun InstalledMoviesDialog(a: ModeSettingsActions, onDismiss: () -> Unit) { + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var refresh by remember { mutableIntStateOf(0) } + val snapshot = rememberMovies(refresh, a.onBootMovieChanged) + var picked by remember { mutableStateOf(null) } + var removing by remember { mutableStateOf(null) } + var filter by rememberSaveable { mutableStateOf(null) } + val selected = picked ?: snapshot?.selected + LaunchedEffect(snapshot?.selected) { picked = null } + removing?.let { entry -> + RemoveMovieDialog( + entry, + onConfirm = { + removing = null + scope.launch { + withContext(Dispatchers.IO) { BootMovieLibrary.remove(app, entry.id) } + a.onBootMovieChanged() + refresh++ + } + }, + onDismiss = { removing = null }, + ) + } + val firstRow = remember { FocusRequester() } + val inputMode = LocalInputModeManager.current + LaunchedEffect(snapshot != null, filter) { + if (snapshot != null && inputMode.inputMode == InputMode.Keyboard) focusSoon { firstRow } + } + AppDialog(shown, close, "boot-movies", wide = false, scrollable = false) { + val filters = listOf( + null to stringResource(R.string.store_all), + BootMovieLibrary.SOURCE_DROIDDECK to movieSourceLabel(BootMovieLibrary.SOURCE_DROIDDECK), + BootMovieLibrary.SOURCE_STEAM to movieSourceLabel(BootMovieLibrary.SOURCE_STEAM), + BootMovieLibrary.SOURCE_POINTSHOP to movieSourceLabel(BootMovieLibrary.SOURCE_POINTSHOP), + BootMovieLibrary.SOURCE_REPO to movieSourceLabel(BootMovieLibrary.SOURCE_REPO), + BootMovieLibrary.SOURCE_IMPORT to movieSourceLabel(BootMovieLibrary.SOURCE_IMPORT), + ) + var filterOpen by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + Text( + stringResource(R.string.boot_movie_installed), fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + Box { + androidx.compose.runtime.CompositionLocalProvider(LocalChipMinWidth provides 100.dp) { + ValueChip(filters.first { it.first == filter }.second, filterOpen) { filterOpen = !filterOpen } + } + AnchoredMenu(filterOpen, onDismiss = { filterOpen = false }, compact = true) { firstItemFocus -> + filters.forEachIndexed { index, (source, label) -> + MenuItem(label, checked = filter == source, focusRequester = if (index == 0) firstItemFocus else null) { + filter = source + filterOpen = false + } + } + } + } + SmallIconButton(Icons.Filled.Close, stringResource(R.string.common_close), tint = MaterialTheme.colorScheme.error, onClick = close) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) { + itemsIndexed(snapshot?.entries.orEmpty().filter { filter == null || it.source == filter }, key = { _, entry -> entry.id }) { index, entry -> + ListRow(accent = entry.id == selected, tall = true, focus = if (index == 0) firstRow else null, onClick = { picked = entry.id; a.onBootMovie(entry.id) }) { + MovieCover(entry, Modifier.size(width = 96.dp, height = 54.dp)) + Column(Modifier.weight(1f)) { + Text(entry.title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(movieSourceLabel(entry.source), fontSize = 12.sp, color = if (entry.id == selected) LocalPalette.current.signal else MaterialTheme.colorScheme.onSurfaceVariant) + } + if (entry.removable) SmallIconButton(Icons.Filled.Close, stringResource(R.string.boot_movie_remove), MaterialTheme.colorScheme.error) { + removing = entry + } + } + } + } + } +} + +@Composable +private fun RemoveMovieDialog(entry: BootMovieLibrary.Entry, onConfirm: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.boot_movie_remove_confirm, entry.title)) }, + text = { Text(stringResource(R.string.boot_movie_remove_confirm_body)) }, + confirmButton = { androidx.compose.material3.TextButton(onClick = onConfirm) { Text(stringResource(R.string.boot_movie_remove)) } }, + dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +@Composable +private fun ImportTab(s: ModeSettings, a: ModeSettingsActions) { + SettingsRow( + stringResource(R.string.boot_movie_custom), + stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS), + ) { + SecondaryButton( + when { + s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress) + s.bootMovieImporting -> stringResource(R.string.common_working) + else -> stringResource(R.string.boot_movie_import) + }, + enabled = !s.bootMovieImporting, + progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f }, + onClick = a.onImportBootMovie, + ) + } +} + +@Composable +private fun SoundTab(s: ModeSettings, a: ModeSettingsActions, host: MenuHost) { + val app = LocalContext.current.applicationContext + var volume by remember { mutableIntStateOf(SessionPrefs.bootMovieVolume(app)) } + var seconds by remember { mutableIntStateOf(SessionPrefs.bootMovieSeconds(app)) } + val duration by androidx.compose.runtime.produceState(0, s.bootMovie) { + value = withContext(Dispatchers.IO) { BootMovieLibrary.durationSeconds(BootMovie.currentFile(app)) } + } + ToggleRow( + host, "boot-movie-sound", stringResource(R.string.boot_movie_sound), + stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound, + ) + Box(Modifier.padding(horizontal = 6.dp)) { + ValueSlider( + volume, 0..100, 5, s.bootMovieSound, stringResource(R.string.boot_movie_volume), { "$it%" }, + ) { volume = it; SessionPrefs.setBootMovieVolume(app, it) } + } + Box(Modifier.padding(horizontal = 6.dp)) { + val shownSeconds = if (seconds in 1 until duration) seconds else duration + ValueSlider( + shownSeconds, 1..duration.coerceAtLeast(2), 1, duration > 1, stringResource(R.string.boot_movie_length), + { value -> app.getString(R.string.boot_movie_seconds, value) }, + ) { next -> + val stored = if (next >= duration) 0 else next + seconds = stored + SessionPrefs.setBootMovieSeconds(app, stored) + } + } + Text( + stringResource(R.string.boot_movie_length_hint), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp).padding(bottom = 12.dp), + ) +} + +@Composable +private fun RepoTab(a: ModeSettingsActions) { + val progress = installProgress + val app = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + var sort by rememberSaveable { mutableStateOf(SteamDeckRepo.Sort.TRENDING) } + var device by rememberSaveable { mutableStateOf(null) } + var query by rememberSaveable { mutableStateOf("") } + var page by rememberSaveable { mutableIntStateOf(1) } + var retry by remember { mutableIntStateOf(0) } + var searching by remember { mutableStateOf(false) } + var jumping by remember { mutableStateOf(false) } + var previewing by remember { mutableStateOf(null) } + var installed by remember { mutableStateOf(setOf()) } + val chosen = rememberMovies(0, a.onBootMovieChanged)?.selected + val inUse by androidx.compose.runtime.produceState(null, chosen) { + value = withContext(Dispatchers.IO) { + chosen?.takeIf { it.startsWith(BootMovieLibrary.PREFIX) }?.let { BootMovieLibrary.repoId(app, it.removePrefix(BootMovieLibrary.PREFIX)) } + } + } + LaunchedEffect(progress.size) { installed = withContext(Dispatchers.IO) { BootMovieLibrary.installedRepoIds(app) } } + val result by androidx.compose.runtime.produceState(null, sort, device, query, page, retry) { + value = null + value = withContext(Dispatchers.IO) { SteamDeckRepo.fetch(sort, device, query, page) } + } + val last by androidx.compose.runtime.produceState(null, sort, device, query, retry) { + value = null + value = withContext(Dispatchers.IO) { SteamDeckRepo.lastPage(sort, device, query) } + } + LaunchedEffect(last) { last?.let { if (page > it) page = it } } + val installPost: (SteamDeckRepo.Post) -> Unit = { post -> + if (progress.isEmpty()) { + progress[post.id] = 0 + installScope.launch { + val outcome = try { + withContext(Dispatchers.IO) { install(app, post) { progress[post.id] = it } } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + app.getString(R.string.boot_movie_repo_download_failed) + } finally { + progress.remove(post.id) + } + Toast.makeText(app, outcome ?: app.getString(R.string.boot_movie_repo_installed, post.title), Toast.LENGTH_LONG).show() + a.onBootMovieChanged() + } + } + } + val usePost: (SteamDeckRepo.Post) -> Unit = { post -> + scope.launch { + val name = withContext(Dispatchers.IO) { BootMovieLibrary.nameForRepo(app, post.id) } + if (name != null) a.onBootMovie(BootMovieLibrary.idOf(name)) + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp).padding(bottom = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + Box(Modifier.weight(1f)) { + ListRow(tall = true, onClick = { searching = true }) { + Icon(Icons.Filled.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Text( + query.ifEmpty { stringResource(R.string.boot_movie_repo_search) }, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f), + ) + } + } + if (query.isNotEmpty()) SecondaryButton(stringResource(R.string.boot_movie_repo_clear), compact = true) { query = ""; page = 1 } + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TabChip(stringResource(R.string.boot_movie_repo_all), device == null) { device = null; page = 1 } + TabChip(stringResource(R.string.boot_movie_repo_deck), device == "steam_deck") { device = "steam_deck"; page = 1 } + TabChip(stringResource(R.string.boot_movie_repo_machine), device == "steam_machine") { device = "steam_machine"; page = 1 } + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SteamDeckRepo.Sort.values().forEach { option -> + TabChip( + stringResource( + when (option) { + SteamDeckRepo.Sort.TRENDING -> R.string.boot_movie_repo_trending + SteamDeckRepo.Sort.DOWNLOADS -> R.string.boot_movie_repo_downloads + SteamDeckRepo.Sort.LIKES -> R.string.boot_movie_repo_likes + SteamDeckRepo.Sort.NEWEST -> R.string.boot_movie_repo_newest + }, + ), + sort == option, + ) { sort = option; page = 1 } + } + } + when (val r = result) { + null -> Text(stringResource(R.string.common_working), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp)) + is SteamDeckRepo.Page.Failed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.boot_movie_repo_failed), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + SecondaryButton(stringResource(R.string.boot_movie_repo_retry), compact = true) { retry++ } + } + is SteamDeckRepo.Page.Ok -> { + if (r.posts.isEmpty()) Text(stringResource(R.string.boot_movie_repo_empty), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 12.dp)) + r.posts.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + row.forEach { post -> + RepoCard( + post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(), + modifier = Modifier.weight(1f), onPreview = { previewing = post }, onInstall = { installPost(post) }, onUse = { usePost(post) }, + ) + } + if (row.size == 1) Box(Modifier.weight(1f)) + } + } + RepoPager(page, last, hasMore = r.posts.size >= SteamDeckRepo.PAGE_SIZE, onPage = { page = it }, onJump = { jumping = true }) + } + } + } + if (searching) TextEntryDialog( + stringResource(R.string.boot_movie_repo_search), query, number = false, confirm = stringResource(R.string.stores_search_open), + onSubmit = { value -> if (value != query) { query = value; page = 1 } }, onDismiss = { searching = false }, + ) + if (jumping) TextEntryDialog( + stringResource(R.string.boot_movie_repo_go_to_page), page.toString(), number = true, confirm = stringResource(R.string.common_ok), + onSubmit = { value -> value.toIntOrNull()?.let { n -> page = n.coerceIn(1, maxOf(1, last ?: n)) } }, onDismiss = { jumping = false }, + ) + previewing?.let { post -> + PreviewDialog( + post, installed = post.id in installed, inUse = post.id == inUse, percent = progress[post.id], busy = progress.isNotEmpty(), + onInstall = { installPost(post) }, onUse = { usePost(post) }, onDismiss = { previewing = null }, + ) + } +} + +/** Downloads [post] and puts it among the installed movies; returns a message when it failed, null when it worked. */ +private fun install(app: android.content.Context, post: SteamDeckRepo.Post, onProgress: (Int) -> Unit): String? { + val folder = File(app.cacheDir, "boot-movie-download").apply { mkdirs() } + // Leftovers of a download that was cut short are cleared, and every download has a file of its own. + folder.listFiles()?.forEach { if (System.currentTimeMillis() - it.lastModified() > 60 * 60 * 1000L) it.delete() } + val file = File.createTempFile("movie-", ".webm", folder) + return try { + if (!SteamDeckRepo.download(post, file, onProgress)) return app.getString(R.string.boot_movie_repo_download_failed) + val cover = if (post.thumbnail.isNotEmpty()) SteamDeckRepo.bytes(post.thumbnail) else null + when (BootMovie.import(app, file, onProgress = onProgress, title = post.title, source = BootMovieLibrary.SOURCE_REPO, repoId = post.id, cover = cover, use = false)) { + is BootMovie.Import.Done -> null + BootMovie.Import.TooLong -> app.getString(R.string.boot_movie_too_long, BootMovie.MAX_CONVERT_SECONDS) + is BootMovie.Import.Unsupported -> app.getString(R.string.boot_movie_unsupported) + BootMovie.Import.Failed -> app.getString(R.string.boot_movie_import_failed) + } + } finally { + file.delete() + } +} + +@Composable +private fun RepoCard( + post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, + onPreview: () -> Unit, onInstall: () -> Unit, onUse: () -> Unit, +) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(10.dp) + val thumbShape = RoundedCornerShape(7.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val previewLabel = stringResource(R.string.boot_movie_repo_preview) + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + modifier = modifier.clip(shape) + .background(if (inUse) pal.signal.copy(alpha = 0.14f) else colors.surface.copy(alpha = 0.6f)) + .border(if (inUse) 2.dp else 1.dp, if (inUse) pal.signal else pal.line, shape).padding(6.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(thumbShape).background(Color(0xFF0E1218)) + .glideBorder(hot, thumbShape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = androidx.compose.foundation.LocalIndication.current, role = Role.Button, onClick = onPreview) + .controllerConfirm(onClick = onPreview) + .semantics { contentDescription = previewLabel }, + ) { + if (post.thumbnail.isNotEmpty()) AsyncImage(model = post.thumbnail, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) + } + } + Text(post.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(repoInfo(post), fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse) + } +} + +@Composable +private fun repoInfo(post: SteamDeckRepo.Post): String = + listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "") + .filter { it.isNotEmpty() }.joinToString(" · ") + +@Composable +private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) { + if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {}) + else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse) + else SecondaryButton( + if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install), + enabled = !busy, compact = true, modifier = modifier, onClick = onInstall, + ) +} + +/** A movie from SteamDeckRepo played straight from the site, so it can be checked before it is installed. */ +@Composable +private fun PreviewDialog( + post: SteamDeckRepo.Post, installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, + onInstall: () -> Unit, onUse: () -> Unit, onDismiss: () -> Unit, +) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + val action = remember { FocusRequester() } + val inputMode = LocalInputModeManager.current + LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } } + val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp + AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) { + DialogTitleRow(post.title, close) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f)) + } + Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse) + } +} + +@Composable +private fun MoviePreview(url: String, modifier: Modifier = Modifier) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(10.dp) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + var state by remember(url) { mutableIntStateOf(0) } + var ratio by remember(url) { mutableFloatStateOf(16f / 9f) } + val player = remember(url) { arrayOfNulls(1) } + DisposableEffect(url) { + onDispose { + runCatching { player[0]?.release() } + player[0] = null + } + } + val toggle = { runCatching { player[0]?.let { if (it.isPlaying) it.pause() else it.start() } }; Unit } + Box( + contentAlignment = Alignment.Center, + modifier = modifier.clip(shape).background(Color.Black) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) + .controllerConfirm(onClick = toggle), + ) { + AndroidView( + modifier = Modifier.aspectRatio(ratio), + factory = { viewContext -> + TextureView(viewContext).apply { + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { + val mp = MediaPlayer() + player[0] = mp + try { + mp.setDataSource(viewContext, Uri.parse(url), mapOf("User-Agent" to "DroidDeck")) + mp.setSurface(Surface(texture)) + mp.isLooping = true + mp.setOnVideoSizeChangedListener { _, w, h -> if (w > 0 && h > 0) ratio = w.toFloat() / h } + mp.setOnPreparedListener { it.start(); state = 1 } + mp.setOnErrorListener { _, _, _ -> state = 2; true } + mp.prepareAsync() + } catch (e: Exception) { + state = 2 + } + } + + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) = Unit + + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + runCatching { player[0]?.release() } + player[0] = null + return true + } + + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit + } + } + }, + ) + if (state == 0) Text(stringResource(R.string.common_working), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f)) + if (state == 2) Text(stringResource(R.string.boot_movie_repo_preview_failed), fontSize = 13.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.padding(12.dp)) + } +} + +/** A dialog with one text box, for a search term or a page number; the Android keyboard comes up with it. */ +@Composable +private fun TextEntryDialog( + title: String, initial: String, number: Boolean, confirm: String, onSubmit: (String) -> Unit, onDismiss: () -> Unit, +) { + val shown = rememberShown(onDismiss) + val close = { shown.targetState = false } + var text by remember { mutableStateOf(initial) } + val submit = { onSubmit(text.trim()); close() } + AppDialog(shown, close, "boot-movie-entry", wide = false, maxWidth = 460.dp) { + DialogTitleRow(title, close) + AdbTextField( + value = text, onValueChange = { text = it }, label = "", keyboardType = if (number) KeyboardType.Number else KeyboardType.Text, + imeAction = ImeAction.Done, placeholder = title, focusOnShow = true, onDone = { submit() }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton(stringResource(R.string.common_cancel), modifier = Modifier.weight(1f), onClick = close) + SecondaryButton(confirm, modifier = Modifier.weight(1f)) { submit() } + } + } +} + +/** Page buttons around the current page, which opens a box to type another page into; first and last page show their numbers. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RepoPager(page: Int, last: Int?, hasMore: Boolean, onPage: (Int) -> Unit, onJump: () -> Unit) { + val reach = last ?: if (hasMore) Int.MAX_VALUE else page + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton(stringResource(R.string.boot_movie_repo_previous), enabled = page > 1, compact = true) { onPage(page - 1) } + if (page - 3 > 1) SecondaryButton("1", compact = true) { onPage(1) } + for (n in maxOf(1, page - 3) until page) SecondaryButton("$n", compact = true) { onPage(n) } + TabChip("$page", true, Modifier.widthIn(min = 52.dp).heightIn(min = 44.dp), onJump) + for (n in page + 1..minOf(page + 3, reach)) SecondaryButton("$n", compact = true) { onPage(n) } + if (last != null && page + 3 < last) SecondaryButton("$last", compact = true) { onPage(last) } + SecondaryButton(stringResource(R.string.boot_movie_repo_next), enabled = page < reach, compact = true) { onPage(page + 1) } + } +} 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 b0f501cbe..d90747aca 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -139,7 +139,7 @@ class ModeSettingsActions( val onBootMovie: (String) -> Unit = {}, val onBootMovieSound: (Boolean) -> Unit = {}, val onImportBootMovie: () -> Unit = {}, - val onRemoveBootMovie: () -> Unit = {}, + val onBootMovieChanged: () -> Unit = {}, val onWifiDiscovery: (Boolean) -> Unit = {}, val onWifiDiscoverySettings: () -> Unit = {}, val onDeckyInstall: (DeckyManager.Release) -> Unit = {}, @@ -467,41 +467,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: onPick = a.onSteamClientArch, ) } - if (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) { - ChoiceRow( - host, "boot-movie", stringResource(R.string.boot_movie_video), stringResource(R.string.common_applies_next_session), - buildList { - add(BootMovie.DROIDDECK_30 to stringResource(R.string.boot_movie_droiddeck, 30)) - add(BootMovie.DROIDDECK_60 to stringResource(R.string.boot_movie_droiddeck, 60)) - s.bootMovieCustomName?.let { add(BootMovie.CUSTOM to it) } - }, s.bootMovie, - note = stringResource(R.string.boot_movie_note), onPick = a.onBootMovie, - ) - ToggleRow( - host, "boot-movie-sound", stringResource(R.string.boot_movie_sound), - stringResource(R.string.common_applies_next_session), s.bootMovieSound, onChange = a.onBootMovieSound, - ) - SettingsRow( - stringResource(R.string.boot_movie_custom), - stringResource(R.string.boot_movie_custom_hint, (BootMovie.MAX_BYTES shr 20).toInt(), BootMovie.MAX_CONVERT_SECONDS), - ) { - Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) { - SecondaryButton( - when { - s.bootMovieProgress >= 0 -> stringResource(R.string.boot_movie_converting, s.bootMovieProgress) - s.bootMovieImporting -> stringResource(R.string.common_working) - else -> stringResource(R.string.boot_movie_import) - }, - enabled = !s.bootMovieImporting, - progress = s.bootMovieProgress.takeIf { it >= 0 }?.let { it / 100f }, - onClick = a.onImportBootMovie, - ) - if (s.bootMovieCustomName != null) SecondaryButton( - stringResource(R.string.boot_movie_remove), enabled = !s.bootMovieImporting, onClick = a.onRemoveBootMovie, - ) - } - } - } + if (s.bootMovie != null) BootMovieGroup(s, a, host) if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { val hint = when { s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 45e898e19..0bc7bd281 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2234,7 +2234,7 @@ الحجم حفظ العودة إلى الافتراضي - يُرسم فوق خلفية اللعبة كما يفعل Steam. قد يختلف الموضع الدقيق ببضع بكسلات. + قد يختلف هذا الموضع بضع نقاط بكسل. لا توجد لهذه اللعبة شعار بعد. اختر واحدًا من الشعار لتراه هنا. تعذّر حفظ موضع الشعار. حاول مرة أخرى. الخلفية @@ -2815,7 +2815,7 @@ فيديو الإقلاع الفيديو DroidDeck · %1$d إطار/ث - معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو البدء المختار في إعدادات Steam نفسها له الأولوية. + معدل 60 إطارًا/ث أكثر سلاسة لكنه يستهلك المعالج أكثر أثناء بدء Steam. فيديو مختار في إعدادات Steam نفسها يظهر هنا أيضًا. الصوت فيديو خاص بك MP4 أو MOV أو MKV أو WebM. تُحوَّل الصيغ الأخرى والملفات الأكبر من %1$d ميغابايت إلى WebM بدقة 720p على الجهاز، حتى %2$d ثانية. @@ -2829,6 +2829,51 @@ جارٍ تحويل %1$s لـ Steam. أبقِ DroidDeck مفتوحًا. لا يستطيع هذا الجهاز فك ترميز هذا الفيديو. تعذّر استيراد الفيديو + إزالة %1$s؟ + سيُحذف الفيديو من DroidDeck ومن مجلد فيديوهات Steam. + المثبّتة + استيراد + SteamDeckRepo + الصوت + الفيديوهات المثبّتة + استعراض + DroidDeck + مستورد + SteamDeckRepo + Steam + متجر النقاط + تقرير فيديو الإقلاع + يحفظ ما يعرفه DroidDeck وSteam عن فيديوهات الإقلاع في ملف نصي يمكنك إرفاقه ببلاغ خطأ. + حفظ + تم الحفظ في %1$s + تعذّر حفظ التقرير. + مستوى الصوت + المدة + %1$d ث + عدد الثواني التي يتم تشغيلها من الفيديو. أي جزء أطول من ذلك يُقطع في النهاية. + البحث في SteamDeckRepo + كل الأجهزة + Steam Deck + Steam Machine + الرائجة + الأكثر تنزيلًا + الأكثر إعجابًا + الأحدث + تعذّر الوصول إلى SteamDeckRepo + إعادة المحاولة + لم يُعثر على فيديوهات + تثبيت + استخدام + قيد الاستخدام + تم تثبيت %1$s + تعذّر تنزيل هذا الفيديو + السابق + التالي + الصفحة %1$d + مسح + الانتقال إلى صفحة + معاينة + تعذر تشغيل المعاينة كاميرات Luma Ultra - تجريبية تتبع موضع Joy-Con استخدم كاميرات النظارات الذكية لتحديد موضع وحدات تحكم Joy-Con المقترنة. يتم التحكم في الدوران والأزرار من خلال وحدات تحكم Joy-Con. يتم تقدير الموضع عندما تكون معصميك مخفية. يحل هذا محل وحدات التحكم التي يتم تشغيلها باليدين ويوقف تتبع كاميرا الهاتف مؤقتًا. وجّه وحدات تحكم Joy-Con للأمام عند توصيل مركز التتبع أو إعادة ضبطه. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 64a9038b3..b1c1c7eb5 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2185,7 +2185,7 @@ Tamaño Guardar Volver al valor predeterminado - Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles. + Este punto exacto puede variar unos pocos píxeles. Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí. No se pudo guardar la posición del logotipo. Inténtalo de nuevo. Fondo @@ -2655,7 +2655,7 @@ Video de arranque Video DroidDeck · %1$d fps - 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video de inicio elegido en la configuración de Steam tiene prioridad. + 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un video elegido en la configuración de Steam también aparece aquí. Sonido Tu propio video MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos. @@ -2669,6 +2669,51 @@ Convirtiendo %1$s para Steam. Mantén DroidDeck abierto. Este dispositivo no puede decodificar ese video. No se pudo importar ese video + ¿Quitar %1$s? + Esto elimina el video de DroidDeck y de la carpeta de videos de Steam. + Instalados + Importar + SteamDeckRepo + Sonido + Videos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Tienda de puntos + Informe de películas de arranque + Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error. + Guardar + Guardado en %1$s + No se pudo guardar el informe. + Volumen + Duración + %1$d s + Cuántos segundos del video se reproducen. Todo lo que dure más se corta al final. + Buscar en SteamDeckRepo + Todos los dispositivos + Steam Deck + Steam Machine + Tendencias + Más descargados + Más gustados + Más recientes + No se pudo conectar con SteamDeckRepo + Reintentar + No se encontraron videos + Instalar + Usar + En uso + %1$s instalado + No se pudo descargar ese video + Anterior + Siguiente + Página %1$d + Borrar + Ir a la página + Vista previa + No se pudo reproducir la vista previa Clave de API Obtener una clave Clave rechazada diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index d9b58d73f..0b839a223 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2150,7 +2150,7 @@ Размер Запази Обратно по подразбиране - Рисува се върху фона на играта, както прави Steam. Точната позиция може да се различава с няколко пиксела. + Точното място може да се различава с няколко пиксела. Тази игра още няма лого. Избери такова в Лого, за да го видиш тук. Позицията на логото не можа да се запази. Опитай отново. Фон @@ -2679,7 +2679,7 @@ Начално видео Видео DroidDeck · %1$d кадъра/с - 60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Начално видео, избрано в настройките на Steam, има предимство. + 60 кадъра/с изглежда по-гладко, но натоварва повече процесора, докато Steam се стартира. Видео, избрано в настройките на Steam, също се показва тук. Звук Собствено видео MP4, MOV, MKV или WebM. Други формати и файлове над %1$d MB се конвертират на устройството до WebM 720p, до %2$d секунди. @@ -2693,6 +2693,51 @@ %1$s се конвертира за Steam. Не затваряйте DroidDeck. Устройството не може да декодира това видео. Видеото не можа да бъде импортирано + Да се премахне ли %1$s? + Това изтрива видеото от DroidDeck и от папката с видеа на Steam. + Инсталирани + Импортиране + SteamDeckRepo + Звук + Инсталирани видеа + Преглед + DroidDeck + Импортирано + SteamDeckRepo + Steam + Магазин за точки + Отчет за стартовите филми + Записва в текстов файл какво знаят DroidDeck и Steam за стартовите филми, за да го прикачите към доклад за грешка. + Запази + Записано в %1$s + Отчетът не можа да бъде записан. + Сила на звука + Продължителност + %1$d с + Колко секунди от филма се възпроизвеждат. Всичко по-дълго се отрязва в края. + Търсене в SteamDeckRepo + Всички устройства + Steam Deck + Steam Machine + Популярни + Най-изтеглени + Най-харесвани + Най-нови + SteamDeckRepo не може да бъде достигнат + Опитайте отново + Не са намерени видеа + Инсталиране + Използване + Използва се + %1$s е инсталирано + Видеото не можа да бъде изтеглено + Назад + Напред + Страница %1$d + Изчисти + Към страница + Преглед + Прегледът не можа да бъде пуснат Luma Ultra камери — Експериментално Проследяване на позицията на Joy-Con Използвайте камерите на интелигентните очила, за да позиционирате сдвоените си контролери Joy-Con. Въртенето и натискането на бутоните идват от контролерите Joy-Con. Позицията се оценява, когато китките ви са скрити. Заменя контролерите, управлявани с голи ръце, и поставя на пауза проследяването с камерата на телефона. Насочете контролерите Joy-Con напред, когато свързвате или нулирате центъра за проследяване. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5fd6a89f1..3ebc7210f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2192,7 +2192,7 @@ Velikost Uložit Zpět na výchozí - Kreslí se přes pozadí hry, jako to dělá Steam. Přesná poloha se může lišit o pár pixelů. + Přesná poloha se může lišit o několik pixelů. Tato hra zatím nemá logo. Vyber ho v Logo, ať se tady zobrazí. Polohu loga se nepodařilo uložit. Zkus to znovu. Pozadí @@ -2747,7 +2747,7 @@ Úvodní video Video DroidDeck · %1$d fps - 60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Úvodní video vybrané přímo v nastavení Steamu má přednost. + 60 fps vypadá plynuleji, ale při spouštění Steamu víc zatěžuje procesor. Video vybrané přímo v nastavení Steamu se zde také zobrazí. Zvuk Vlastní video MP4, MOV, MKV nebo WebM. Jiné formáty a soubory nad %1$d MB se v zařízení převedou na WebM 720p, nejvýše %2$d sekund. @@ -2761,6 +2761,51 @@ Převádí se %1$s pro Steam. Nechte DroidDeck otevřený. Toto zařízení video nedokáže dekódovat. Video se nepodařilo importovat + Odebrat %1$s? + Tím se video smaže z DroidDecku i ze složky videí Steamu. + Nainstalované + Importovat + SteamDeckRepo + Zvuk + Nainstalovaná videa + Procházet + DroidDeck + Importované + SteamDeckRepo + Steam + Obchod s body + Zpráva o úvodních videích + Uloží do textového souboru, co DroidDeck a Steam vědí o úvodních videích, aby šel přiložit k hlášení chyby. + Uložit + Uloženo do %1$s + Zprávu se nepodařilo uložit. + Hlasitost + Délka + %1$d s + Kolik sekund filmu se přehraje. Cokoli delšího se na konci odřízne. + Hledat v SteamDeckRepo + Všechna zařízení + Steam Deck + Steam Machine + Populární + Nejstahovanější + Nejoblíbenější + Nejnovější + SteamDeckRepo se nepodařilo kontaktovat + Zkusit znovu + Nenalezena žádná videa + Nainstalovat + Použít + Používá se + %1$s nainstalováno + Video se nepodařilo stáhnout + Předchozí + Další + Strana %1$d + Vymazat + Přejít na stránku + Náhled + Náhled se nepodařilo přehrát Kamery Luma Ultra — Experimentální Sledování polohy Joy-Conů Použijte kamery chytrých brýlí k umístění spárovaných ovladačů Joy-Con. Otáčení a vstupy tlačítek pocházejí z ovladačů Joy-Con. Poloha se odhaduje, když máte skrytá zápěstí. Nahrazuje ovladače ovládané holýma rukama a pozastavuje sledování fotoaparátem telefonu. Při připojování nebo resetování centra sledování namiřte ovladače Joy-Con dopředu. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 00b526836..d8005ceec 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2150,7 +2150,7 @@ Størrelse Gem Tilbage til standard - Tegnes oven på spillets baggrund, som Steam gør. Den præcise placering kan afvige et par pixels. + Præcis placering kan afvige med nogle få pixel. Dette spil har endnu ikke et logo. Vælg et under Logo for at se det her. Logopladeringen kunne ikke gemmes. Prøv igen. Baggrund @@ -2679,7 +2679,7 @@ Opstartsvideo Video DroidDeck · %1$d fps - 60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En opstartsvideo valgt i Steams egne indstillinger har forrang. + 60 fps ser mere flydende ud, men bruger mere CPU, mens Steam starter. En video valgt i Steams egne indstillinger vises også her. Lyd Din egen video MP4, MOV, MKV eller WebM. Andre formater og filer over %1$d MB konverteres til 720p-WebM på enheden, op til %2$d sekunder. @@ -2693,6 +2693,51 @@ Konverterer %1$s til Steam. Hold DroidDeck åben. Enheden kan ikke afkode videoen. Kunne ikke importere videoen + Fjern %1$s? + Dette sletter videoen fra DroidDeck og fra Steams videomappe. + Installeret + Importér + SteamDeckRepo + Lyd + Installerede videoer + Gennemse + DroidDeck + Importeret + SteamDeckRepo + Steam + Pointbutik + Rapport om startfilm + Gemmer, hvad DroidDeck og Steam ved om startfilm, i en tekstfil, som du kan vedhæfte en fejlrapport. + Gem + Gemt i %1$s + Rapporten kunne ikke gemmes. + Lydstyrke + Længde + %1$d s + Hvor mange sekunder af filmen der afspilles. Alt, hvad der er længere, klippes af til sidst. + Søg i SteamDeckRepo + Alle enheder + Steam Deck + Steam Machine + Populære + Mest downloadet + Mest likede + Nyeste + Kunne ikke få forbindelse til SteamDeckRepo + Prøv igen + Ingen videoer fundet + Installér + Brug + I brug + %1$s installeret + Kunne ikke downloade videoen + Forrige + Næste + Side %1$d + Ryd + Gå til side + Forhåndsvis + Forhåndsvisningen kunne ikke afspilles Luma Ultra-kameraer — Eksperimentel Joy-Con-positionssporing Brug smartglass-kameraerne til at placere dine parrede Joy-Con-controllere. Rotation og knapinput kommer fra Joy-Con-controllerne. Positionen estimeres, når dine håndled er skjulte. Erstatter controllere, der betjenes med bare hænder, og sætter telefonkameraets sporing på pause. Ret Joy-Con-controllerne fremad, når du tilslutter eller nulstiller sporingscentret. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ccbb25f78..d7c02ce9e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2136,7 +2136,7 @@ Größe Speichern Auf Standard zurücksetzen - Wird wie bei Steam über dem Spielehintergrund gezeichnet. Die Position kann um einige Pixel abweichen. + Die genaue Position kann um einige Pixel abweichen. Für dieses Spiel gibt es noch kein Logo. Wähle unter Logo eines aus, um es hier zu sehen. Die Logoposition ließ sich nicht speichern. Versuche es erneut. Hintergrund @@ -2679,7 +2679,7 @@ Bootvideo Video DroidDeck · %1$d fps - 60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Startvideo hat Vorrang. + 60 fps wirkt flüssiger, braucht aber mehr CPU, während Steam startet. Ein in Steam selbst gewähltes Video erscheint hier ebenfalls. Ton Eigenes Video MP4, MOV, MKV oder WebM. Andere Formate und Dateien über %1$d MB werden auf dem Gerät in 720p-WebM umgewandelt, bis %2$d Sekunden. @@ -2693,6 +2693,51 @@ %1$s wird für Steam umgewandelt. DroidDeck geöffnet lassen. Dieses Gerät kann das Video nicht dekodieren. Das Video konnte nicht importiert werden + %1$s entfernen? + Dadurch wird das Video aus DroidDeck und aus dem Videoordner von Steam gelöscht. + Installiert + Importieren + SteamDeckRepo + Ton + Installierte Videos + Durchsuchen + DroidDeck + Importiert + SteamDeckRepo + Steam + Punkteshop + Bericht zu Startfilmen + Speichert in einer Textdatei, was DroidDeck und Steam über Startfilme wissen, zum Anhängen an einen Fehlerbericht. + Speichern + Gespeichert unter %1$s + Der Bericht konnte nicht gespeichert werden. + Lautstärke + Länge + %1$d s + Wie viele Sekunden des Films abgespielt werden. Alles, was länger ist, wird am Ende abgeschnitten. + SteamDeckRepo durchsuchen + Alle Geräte + Steam Deck + Steam Machine + Im Trend + Meistgeladen + Beliebteste + Neueste + SteamDeckRepo ist nicht erreichbar + Erneut versuchen + Keine Videos gefunden + Installieren + Verwenden + In Verwendung + %1$s installiert + Das Video konnte nicht heruntergeladen werden + Zurück + Weiter + Seite %1$d + Leeren + Zur Seite + Vorschau + Die Vorschau konnte nicht abgespielt werden Luma Ultra-Kameras – Experimentell Joy-Con-Positionsverfolgung Positionieren Sie Ihre gekoppelten Joy-Con-Controller mithilfe der Kameras Ihrer Smartbrille. Drehung und Tasteneingaben erfolgen über die Joy-Con-Controller. Die Position wird geschätzt, wenn Ihre Handgelenke verdeckt sind. Ersetzt Controller, die mit bloßen Händen bedient werden, und pausiert die Verfolgung per Smartphone-Kamera. Richten Sie die Joy-Con-Controller nach vorn, wenn Sie die Verbindung herstellen oder das Tracking-Center zurücksetzen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index bba71c05a..40dbd1ced 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2150,7 +2150,7 @@ Μέγεθος Αποθήκευση Επαναφορά στην προεπιλογή - Σχεδιάζεται πάνω από το φόντο του παιχνιδιού, όπως κάνει το Steam. Η ακριβής θέση μπορεί να διαφέρει μερικά pixel. + Ακριβώς αυτή η θέση μπορεί να διαφέρει κατά μερικά pixel. Αυτό το παιχνίδι δεν έχει λογότυπο ακόμη. Διάλεξε ένα στο Λογότυπο για να το δεις εδώ. Η θέση του λογότυπου δεν αποθηκεύτηκε. Δοκίμασε ξανά. Φόντο @@ -2679,7 +2679,7 @@ Βίντεο εκκίνησης Βίντεο DroidDeck · %1$d fps - Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο εκκίνησης που επιλέχθηκε στις ρυθμίσεις του Steam έχει προτεραιότητα. + Τα 60 fps φαίνονται πιο ομαλά, αλλά επιβαρύνουν περισσότερο τον επεξεργαστή όσο ξεκινά το Steam. Ένα βίντεο που επιλέχθηκε στις ρυθμίσεις του Steam εμφανίζεται κι εδώ. Ήχος Δικό σας βίντεο MP4, MOV, MKV ή WebM. Άλλες μορφές και αρχεία πάνω από %1$d MB μετατρέπονται στη συσκευή σε WebM 720p, έως %2$d δευτερόλεπτα. @@ -2693,6 +2693,51 @@ Μετατροπή του %1$s για το Steam. Αφήστε το DroidDeck ανοιχτό. Η συσκευή δεν μπορεί να αποκωδικοποιήσει αυτό το βίντεο. Δεν ήταν δυνατή η εισαγωγή του βίντεο + Αφαίρεση του %1$s; + Το βίντεο θα διαγραφεί από το DroidDeck και από τον φάκελο βίντεο του Steam. + Εγκατεστημένα + Εισαγωγή + SteamDeckRepo + Ήχος + Εγκατεστημένα βίντεο + Περιήγηση + DroidDeck + Εισαγμένο + SteamDeckRepo + Steam + Κατάστημα πόντων + Αναφορά ταινιών εκκίνησης + Αποθηκεύει σε αρχείο κειμένου ό,τι γνωρίζουν το DroidDeck και το Steam για τις ταινίες εκκίνησης, για να το επισυνάψετε σε αναφορά σφάλματος. + Αποθήκευση + Αποθηκεύτηκε στο %1$s + Δεν ήταν δυνατή η αποθήκευση της αναφοράς. + Ένταση + Διάρκεια + %1$d δευτ. + Πόσα δευτερόλεπτα της ταινίας αναπαράγονται. Ό,τι είναι μεγαλύτερο κόβεται στο τέλος. + Αναζήτηση στο SteamDeckRepo + Όλες οι συσκευές + Steam Deck + Steam Machine + Τάσεις + Περισσότερες λήψεις + Περισσότερα likes + Νεότερα + Δεν ήταν δυνατή η σύνδεση με το SteamDeckRepo + Επανάληψη + Δεν βρέθηκαν βίντεο + Εγκατάσταση + Χρήση + Σε χρήση + Το %1$s εγκαταστάθηκε + Δεν ήταν δυνατή η λήψη αυτού του βίντεο + Προηγούμενη + Επόμενη + Σελίδα %1$d + Εκκαθάριση + Μετάβαση στη σελίδα + Προεπισκόπηση + Η προεπισκόπηση δεν μπόρεσε να αναπαραχθεί Κάμερες Luma Ultra — Πειραματικό Παρακολούθηση θέσης Joy-Con Χρησιμοποιήστε τις κάμερες των έξυπνων γυαλιών για να τοποθετήσετε τα συνδεδεμένα χειριστήρια Joy-Con. Η περιστροφή και οι εντολές κουμπιών προέρχονται από τα χειριστήρια Joy-Con. Η θέση εκτιμάται όταν οι καρποί σας είναι κρυμμένοι. Αντικαθιστά τα χειριστήρια που λειτουργούν με γυμνά χέρια και διακόπτει την παρακολούθηση της κάμερας του τηλεφώνου. Στρέψτε τα χειριστήρια Joy-Con προς τα εμπρός κατά τη σύνδεση ή την επαναφορά του κέντρου παρακολούθησης. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 46ab87b35..07d581180 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2165,7 +2165,7 @@ Tamaño Guardar Volver al valor predeterminado - Se dibuja sobre el fondo del juego, igual que en Steam. La posición exacta puede variar unos píxeles. + Este punto exacto puede variar unos pocos píxeles. Este juego aún no tiene logotipo. Elige uno en Logotipo para verlo aquí. No se pudo guardar la posición del logotipo. Inténtalo de nuevo. Fondo @@ -2713,7 +2713,7 @@ Vídeo de arranque Vídeo DroidDeck · %1$d fps - 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo de inicio elegido en los ajustes de Steam tiene prioridad. + 60 fps se ve más fluido pero usa más CPU mientras Steam se inicia. Un vídeo elegido en los ajustes de Steam también aparece aquí. Sonido Tu propio vídeo MP4, MOV, MKV o WebM. Otros formatos y archivos de más de %1$d MB se convierten a WebM 720p en el dispositivo, hasta %2$d segundos. @@ -2727,6 +2727,51 @@ Convirtiendo %1$s para Steam. Mantén DroidDeck abierto. Este dispositivo no puede decodificar ese vídeo. No se pudo importar ese vídeo + ¿Quitar %1$s? + Esto elimina el vídeo de DroidDeck y de la carpeta de vídeos de Steam. + Instalados + Importar + SteamDeckRepo + Sonido + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Tienda de puntos + Informe de películas de arranque + Guarda en un archivo de texto lo que DroidDeck y Steam saben de las películas de arranque, para adjuntarlo a un informe de error. + Guardar + Guardado en %1$s + No se pudo guardar el informe. + Volumen + Duración + %1$d s + Cuántos segundos de la película se reproducen. Lo que dure más se corta al final. + Buscar en SteamDeckRepo + Todos los dispositivos + Steam Deck + Steam Machine + Tendencias + Más descargados + Más valorados + Más recientes + No se pudo acceder a SteamDeckRepo + Reintentar + No se encontraron vídeos + Instalar + Usar + En uso + %1$s instalado + No se pudo descargar ese vídeo + Anterior + Siguiente + Página %1$d + Borrar + Ir a la página + Vista previa + No se pudo reproducir la vista previa Cámaras Luma Ultra — Experimental Seguimiento de posición de los Joy-Con Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 86560920f..85574fa93 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2150,7 +2150,7 @@ Koko Tallenna Takaisin oletukseen - Piirretään pelin taustan päälle, kuten Steam tekee. Tarkka sijainti voi poiketa muutaman pikselin. + Tämä tarkka kohta voi vaihdella muutamalla pikselillä. Tällä pelillä ei ole vielä logoa. Valitse sellainen kohdasta Logo, niin näet sen tässä. Logon sijaintia ei voitu tallentaa. Yritä uudelleen. Tausta @@ -2679,7 +2679,7 @@ Käynnistysvideo Video DroidDeck · %1$d fps - 60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu käynnistysvideo on etusijalla. + 60 fps näyttää sulavammalta, mutta kuormittaa suoritinta enemmän Steamin käynnistyessä. Steamin omista asetuksista valittu video näkyy myös täällä. Ääni Oma video MP4, MOV, MKV tai WebM. Muut muodot ja yli %1$d Mt:n tiedostot muunnetaan laitteella 720p-WebM-muotoon, enintään %2$d sekuntia. @@ -2693,6 +2693,51 @@ Muunnetaan %1$s Steamille. Pidä DroidDeck auki. Laite ei pysty purkamaan videota. Videon tuonti epäonnistui + Poistetaanko %1$s? + Tämä poistaa videon DroidDeckistä ja Steamin videokansiosta. + Asennetut + Tuo + SteamDeckRepo + Ääni + Asennetut videot + Selaa + DroidDeck + Tuotu + SteamDeckRepo + Steam + Pisteputiikki + Käynnistyselokuvaraportti + Tallentaa tekstitiedostoon, mitä DroidDeck ja Steam tietävät käynnistyselokuvista, liitettäväksi virheilmoitukseen. + Tallenna + Tallennettu: %1$s + Raportin tallennus epäonnistui. + Äänenvoimakkuus + Pituus + %1$d s + Kuinka monta sekuntia elokuvasta toistetaan. Sitä pidempi osa leikataan pois lopusta. + Hae SteamDeckReposta + Kaikki laitteet + Steam Deck + Steam Machine + Trendaavat + Ladatuimmat + Tykätyimmät + Uusimmat + SteamDeckRepoon ei saatu yhteyttä + Yritä uudelleen + Videoita ei löytynyt + Asenna + Käytä + Käytössä + %1$s asennettu + Videota ei voitu ladata + Edellinen + Seuraava + Sivu %1$d + Tyhjennä + Siirry sivulle + Esikatselu + Esikatselua ei voitu toistaa Luma Ultra -kamerat — Kokeellinen Joy-Con-paikan seuranta Käytä älylasien kameroita pariliitettyjen Joy-Con-ohjainten asemointiin. Kierto- ja painikesyötteet tulevat Joy-Con-ohjaimista. Sijainti arvioidaan, kun ranteesi ovat piilossa. Korvaa paljain käsin käytettävät ohjaimet ja keskeyttää puhelimen kameran seurannan. Osoita Joy-Con-ohjaimet eteenpäin, kun yhdistät tai nollaat seurantakeskuksen. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d81acc097..fe2a4330f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2165,7 +2165,7 @@ Taille Enregistrer Rétablir par défaut - Dessiné sur l\'arrière-plan du jeu, comme le fait Steam. La position exacte peut varier de quelques pixels. + Cet emplacement exact peut varier de quelques pixels. Ce jeu n\'a pas encore de logo. Choisissez-en un dans Logo pour le voir ici. Impossible d\'enregistrer la position du logo. Réessayez. Arrière-plan @@ -2713,7 +2713,7 @@ Vidéo de démarrage Vidéo DroidDeck · %1$d i/s - 60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo de démarrage choisie dans les paramètres de Steam est prioritaire. + 60 i/s est plus fluide mais sollicite davantage le processeur pendant le démarrage de Steam. Une vidéo choisie dans les paramètres de Steam apparaît aussi ici. Son Votre propre vidéo MP4, MOV, MKV ou WebM. Les autres formats et les fichiers de plus de %1$d Mo sont convertis en WebM 720p sur l\'appareil, jusqu\'à %2$d secondes. @@ -2727,6 +2727,51 @@ Conversion de %1$s pour Steam. Gardez DroidDeck ouvert. Cet appareil ne peut pas décoder cette vidéo. Impossible d\'importer cette vidéo + Supprimer %1$s ? + Cela supprime la vidéo de DroidDeck et du dossier de vidéos de Steam. + Installées + Importer + SteamDeckRepo + Son + Vidéos installées + Parcourir + DroidDeck + Importée + SteamDeckRepo + Steam + Boutique de points + Rapport sur les vidéos de démarrage + Enregistre dans un fichier texte ce que DroidDeck et Steam savent des vidéos de démarrage, à joindre à un rapport de bogue. + Enregistrer + Enregistré dans %1$s + Impossible d\'enregistrer le rapport. + Volume + Durée + %1$d s + Nombre de secondes du film qui sont lues. Tout ce qui dépasse est coupé à la fin. + Rechercher dans SteamDeckRepo + Tous les appareils + Steam Deck + Steam Machine + Tendances + Les plus téléchargées + Les plus aimées + Les plus récentes + Impossible de joindre SteamDeckRepo + Réessayer + Aucune vidéo trouvée + Installer + Utiliser + Utilisé + %1$s installée + Impossible de télécharger cette vidéo + Précédent + Suivant + Page %1$d + Effacer + Aller à la page + Aperçu + Impossible de lire cet aperçu Caméras Luma Ultra — Expérimental Suivi de la position des Joy-Con Utilisez les caméras des lunettes connectées pour positionner vos manettes Joy-Con appariées. La rotation et les entrées des boutons proviennent des manettes Joy-Con. La position est estimée lorsque vos poignets sont cachés. Remplace les manettes utilisées à mains nues et interrompt le suivi par la caméra du téléphone. Pointez les manettes Joy-Con vers l\'avant lors de la connexion ou de la réinitialisation du centre de suivi. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 07bd570ae..fa32ff251 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2150,7 +2150,7 @@ आकार सहेजें डिफ़ॉल्ट पर लौटाएँ - गेम की पृष्ठभूमि के ऊपर बनता है, जैसे Steam करता है। सटीक स्थिति कुछ पिक्सेल अलग हो सकती है। + यह सटीक जगह कुछ पिक्सल तक अलग हो सकती है। इस गेम का लोगो अभी नहीं है। यहाँ देखने के लिए लोगो में से कोई चुनें। लोगो की स्थिति सहेजी नहीं जा सकी। फिर कोशिश करें। बैकग्राउंड @@ -2679,7 +2679,7 @@ बूट वीडियो वीडियो DroidDeck · %1$d fps - 60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया स्टार्टअप वीडियो प्राथमिकता लेता है। + 60 fps ज़्यादा स्मूथ दिखता है, लेकिन Steam शुरू होते समय CPU ज़्यादा इस्तेमाल करता है। Steam की अपनी सेटिंग्स में चुना गया वीडियो यहाँ भी दिखता है। आवाज़ अपना वीडियो MP4, MOV, MKV या WebM. दूसरे फ़ॉर्मैट और %1$d MB से बड़ी फ़ाइलें डिवाइस पर 720p WebM में बदली जाती हैं, %2$d सेकंड तक। @@ -2693,6 +2693,51 @@ %1$s को Steam के लिए बदला जा रहा है। DroidDeck खुला रखें। यह डिवाइस उस वीडियो को डिकोड नहीं कर सकता। वीडियो इंपोर्ट नहीं हो सका + %1$s हटाएं? + इससे वीडियो DroidDeck और Steam के वीडियो फ़ोल्डर से मिट जाएगा। + इंस्टॉल किए गए + इंपोर्ट करें + SteamDeckRepo + आवाज़ + इंस्टॉल किए गए वीडियो + ब्राउज़ करें + DroidDeck + इंपोर्ट किया गया + SteamDeckRepo + Steam + पॉइंट्स शॉप + बूट वीडियो रिपोर्ट + बूट वीडियो के बारे में DroidDeck और Steam की जानकारी एक टेक्स्ट फ़ाइल में सहेजता है, जिसे आप बग रिपोर्ट के साथ जोड़ सकते हैं। + सहेजें + %1$s में सहेजा गया + रिपोर्ट सहेजी नहीं जा सकी। + वॉल्यूम + अवधि + %1$d सेक + वीडियो के कितने सेकंड चलेंगे। इससे लंबा हिस्सा अंत में काट दिया जाता है। + SteamDeckRepo में खोजें + सभी डिवाइस + Steam Deck + Steam Machine + ट्रेंडिंग + सबसे ज़्यादा डाउनलोड किए गए + सबसे ज़्यादा पसंद किए गए + सबसे नए + SteamDeckRepo से कनेक्ट नहीं हो सका + फिर कोशिश करें + कोई वीडियो नहीं मिला + इंस्टॉल करें + इस्तेमाल करें + इस्तेमाल में + %1$s इंस्टॉल हो गया + वह वीडियो डाउनलोड नहीं हो सका + पिछला + अगला + पेज %1$d + साफ़ करें + पेज पर जाएँ + पूर्वावलोकन + यह पूर्वावलोकन चलाया नहीं जा सका Luma Ultra कैमरे — एक्सपेरिमेंटल Joy-Con पोजीशन ट्रैकिंग अपने पेयर्ड Joy-Con कंट्रोलर्स को सही जगह पर लगाने के लिए स्मार्ट ग्लास के कैमरे का इस्तेमाल करें। रोटेशन और बटन इनपुट Joy-Con कंट्रोलर्स से आते हैं। जब आपकी कलाइयां छिपी होती हैं, तो पोजीशन का अंदाज़ा लगाया जाता है। नंगे हाथों से चलने वाले कंट्रोलर्स को बदलता है और फ़ोन-कैमरा ट्रैकिंग को पॉज़ करता है। ट्रैकिंग सेंटर को कनेक्ट या रीसेट करते समय Joy-Con कंट्रोलर्स को आगे की ओर पॉइंट करें। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ef37ac8ca..1ea4d95be 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2150,7 +2150,7 @@ Méret Mentés Vissza az alapértelmezettre - A játék háttere fölött rajzolódik ki, ahogy a Steam is teszi. A pontos pozíció néhány pixellel eltérhet. + Ez a pontos hely néhány képpontnyit eltérhet. Ennek a játéknak még nincs logója. Válassz egyet a Logó alatt, hogy itt lásd. A logó pozícióját nem sikerült menteni. Próbáld újra. Háttér @@ -2679,7 +2679,7 @@ Indítóvideó Videó DroidDeck · %1$d fps - A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott indítóvideó elsőbbséget élvez. + A 60 fps simább, de jobban terheli a processzort a Steam indulása közben. A Steam saját beállításaiban választott videó itt is megjelenik. Hang Saját videó MP4, MOV, MKV vagy WebM. Más formátumokat és a %1$d MB-nál nagyobb fájlokat az eszköz 720p WebM-re alakítja, legfeljebb %2$d másodpercig. @@ -2693,6 +2693,51 @@ %1$s átalakítása a Steamhez. Hagyd nyitva a DroidDecket. Ez az eszköz nem tudja dekódolni a videót. Nem sikerült importálni a videót + Eltávolítod: %1$s? + Ezzel törlődik a videó a DroidDeckből és a Steam videómappájából. + Telepítve + Importálás + SteamDeckRepo + Hang + Telepített videók + Tallózás + DroidDeck + Importált + SteamDeckRepo + Steam + Pontbolt + Indítóvideó-jelentés + Szöveges fájlba menti, amit a DroidDeck és a Steam tud az indítóvideókról, hibajelentéshez csatolható. + Mentés + Mentve: %1$s + A jelentést nem sikerült menteni. + Hangerő + Hossz + %1$d mp + A videó hány másodperce játszódik le. Ami ennél hosszabb, azt a végén levágjuk. + Keresés a SteamDeckRepo-ban + Minden eszköz + Steam Deck + Steam Machine + Népszerű + Legtöbbet letöltött + Legkedveltebb + Legújabb + Nem érhető el a SteamDeckRepo + Újra + Nem található videó + Telepítés + Használat + Használatban + %1$s telepítve + Nem sikerült letölteni a videót + Előző + Következő + %1$d. oldal + Törlés + Ugrás az oldalra + Előnézet + Az előnézetet nem sikerült lejátszani Luma Ultra kamerák — Kísérleti Joy-Con pozíciókövetés Használd az okosszemüveg kameráit a párosított Joy-Con kontrollerek pozicionálásához. A forgatás és a gombbemenetek a Joy-Con kontrollerekről származnak. A pozíció akkor becsült, amikor a csuklód rejtve van. Helyettesíti a puszta kézzel működtetett kontrollereket, és szünetelteti a telefonkamera követést. Irányítsd a Joy-Con kontrollereket előre a követőközpont csatlakoztatásakor vagy visszaállításakor. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9673e06ee..a08d3e5db 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -2129,7 +2129,7 @@ Ukuran Simpan Kembalikan ke bawaan - Digambar di atas latar gim, seperti yang dilakukan Steam. Posisi persisnya bisa berbeda beberapa piksel. + Posisi persis ini bisa berbeda beberapa piksel. Gim ini belum punya logo. Pilih salah satu di Logo untuk melihatnya di sini. Posisi logo tidak tersimpan. Coba lagi. Latar belakang @@ -2654,7 +2654,7 @@ Video boot Video DroidDeck · %1$d fps - 60 fps terlihat lebih mulus tetapi memakai lebih banyak CPU saat Steam dimulai. Video startup yang dipilih di pengaturan Steam sendiri lebih diutamakan. + 60 fps terlihat lebih mulus tetapi memakai lebih banyak CPU saat Steam dimulai. Video yang dipilih di pengaturan Steam sendiri juga muncul di sini. Suara Video sendiri MP4, MOV, MKV, atau WebM. Format lain dan file di atas %1$d MB dikonversi ke WebM 720p di perangkat, hingga %2$d detik. @@ -2668,6 +2668,51 @@ Mengonversi %1$s untuk Steam. Biarkan DroidDeck tetap terbuka. Perangkat ini tidak dapat mendekode video itu. Tidak dapat mengimpor video itu + Hapus %1$s? + Ini menghapus video dari DroidDeck dan dari folder video Steam. + Terpasang + Impor + SteamDeckRepo + Suara + Video terpasang + Telusuri + DroidDeck + Diimpor + SteamDeckRepo + Steam + Toko Poin + Laporan film boot + Menyimpan apa yang diketahui DroidDeck dan Steam tentang film boot ke berkas teks yang dapat dilampirkan pada laporan bug. + Simpan + Disimpan di %1$s + Laporan tidak dapat disimpan. + Volume + Durasi + %1$d dtk + Berapa detik film yang diputar. Bagian yang lebih panjang akan dipotong di bagian akhir. + Cari di SteamDeckRepo + Semua perangkat + Steam Deck + Steam Machine + Tren + Paling banyak diunduh + Paling disukai + Terbaru + Tidak dapat terhubung ke SteamDeckRepo + Coba lagi + Video tidak ditemukan + Pasang + Gunakan + Sedang digunakan + %1$s terpasang + Tidak dapat mengunduh video itu + Sebelumnya + Berikutnya + Halaman %1$d + Hapus + Buka halaman + Pratinjau + Pratinjau tidak dapat diputar Kamera Luma Ultra — Eksperimental Pelacakan posisi Joy-Con Gunakan kamera kacamata pintar untuk memposisikan pengontrol Joy-Con yang terpasang. Rotasi dan input tombol berasal dari pengontrol Joy-Con. Posisi diperkirakan saat pergelangan tangan Anda tersembunyi. Menggantikan pengontrol yang dioperasikan dengan tangan kosong dan menghentikan pelacakan kamera ponsel. Arahkan pengontrol Joy-Con ke depan saat menghubungkan atau mengatur ulang pusat pelacakan. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0a807b72e..7b650591c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2157,7 +2157,7 @@ Dimensione Salva Ripristina predefinita - Viene disegnato sullo sfondo del gioco, come fa Steam. La posizione esatta può variare di qualche pixel. + Questa posizione esatta può variare di qualche pixel. Questo gioco non ha ancora un logo. Scegline uno in Logo per vederlo qui. Impossibile salvare la posizione del logo. Riprova. Sfondo @@ -2713,7 +2713,7 @@ Video di avvio Video DroidDeck · %1$d fps - 60 fps è più fluido ma usa più CPU durante l\'avvio di Steam. Un video di avvio scelto nelle impostazioni di Steam ha la precedenza. + 60 fps è più fluido ma usa più CPU durante l\'avvio di Steam. Anche un video scelto nelle impostazioni di Steam compare qui. Audio Il tuo video MP4, MOV, MKV o WebM. Altri formati e file oltre %1$d MB vengono convertiti in WebM 720p sul dispositivo, fino a %2$d secondi. @@ -2727,6 +2727,51 @@ Conversione di %1$s per Steam. Tieni aperto DroidDeck. Questo dispositivo non può decodificare il video. Impossibile importare il video + Rimuovere %1$s? + Il video verrà eliminato da DroidDeck e dalla cartella video di Steam. + Installati + Importa + SteamDeckRepo + Audio + Video installati + Sfoglia + DroidDeck + Importato + SteamDeckRepo + Steam + Negozio punti + Rapporto sui filmati di avvio + Salva in un file di testo ciò che DroidDeck e Steam sanno sui filmati di avvio, da allegare a una segnalazione di bug. + Salva + Salvato in %1$s + Impossibile salvare il rapporto. + Volume + Durata + %1$d s + Quanti secondi del filmato vengono riprodotti. Tutto ciò che è più lungo viene tagliato alla fine. + Cerca in SteamDeckRepo + Tutti i dispositivi + Steam Deck + Steam Machine + Di tendenza + Più scaricati + Più apprezzati + Più recenti + Impossibile raggiungere SteamDeckRepo + Riprova + Nessun video trovato + Installa + Usa + In uso + %1$s installato + Impossibile scaricare il video + Precedente + Successivo + Pagina %1$d + Cancella + Vai alla pagina + Anteprima + Impossibile riprodurre l\'anteprima Fotocamere Luma Ultra — Sperimentale Tracciamento della posizione dei Joy-Con Utilizza le fotocamere degli occhiali smart per posizionare i controller Joy-Con associati. La rotazione e gli input dei pulsanti provengono dai controller Joy-Con. La posizione viene stimata quando i polsi sono nascosti. Sostituisce i controller utilizzati a mani nude e mette in pausa il tracciamento tramite la fotocamera del telefono. Punta i controller Joy-Con in avanti quando li connetti o reimposti il ​​centro di tracciamento. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 149634e11..bee429969 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2129,7 +2129,7 @@ サイズ 保存 既定に戻す - ゲームの背景に、Steam と同じ方法で描画されます。位置が数ピクセルずれることがあります。 + この位置は数ピクセルずれることがあります。 このゲームにはまだロゴがありません。ロゴでいずれかを選ぶとここに表示されます。 ロゴの位置を保存できませんでした。もう一度お試しください。 背景 @@ -2654,7 +2654,7 @@ 起動ムービー ムービー DroidDeck · %1$d fps - 60 fps はなめらかですが、Steam の起動中に CPU をより多く使います。Steam 本体の設定で選んだ起動ムービーが優先されます。 + 60 fps はなめらかですが、Steam の起動中に CPU をより多く使います。Steam 本体の設定で選んだムービーもここに表示されます。 サウンド 自分のムービー MP4、MOV、MKV、WebM。その他の形式や %1$d MB を超えるファイルは、端末上で 720p の WebM に変換されます(%2$d 秒まで)。 @@ -2668,6 +2668,51 @@ %1$s を Steam 用に変換しています。DroidDeck を開いたままにしてください。 この端末ではその動画をデコードできません。 ムービーをインポートできませんでした + %1$s を削除しますか? + DroidDeck と Steam のムービーフォルダからムービーが削除されます。 + インストール済み + インポート + SteamDeckRepo + サウンド + インストール済みのムービー + 参照 + DroidDeck + インポート済み + SteamDeckRepo + Steam + ポイントショップ + 起動ムービーのレポート + DroidDeck と Steam が把握している起動ムービーの情報をテキストファイルに保存します。不具合報告に添付できます。 + 保存 + %1$s に保存しました + レポートを保存できませんでした。 + 音量 + 長さ + %1$d 秒 + ムービーを何秒間再生するかを設定します。それより長い部分は最後がカットされます。 + SteamDeckRepo を検索 + すべてのデバイス + Steam Deck + Steam Machine + トレンド + ダウンロード数順 + いいね順 + 新着順 + SteamDeckRepo に接続できませんでした + 再試行 + ムービーが見つかりません + インストール + 使用 + 使用中 + %1$s をインストールしました + ムービーをダウンロードできませんでした + 前へ + 次へ + %1$d ページ + クリア + ページへ移動 + プレビュー + プレビューを再生できませんでした スマートグラス スマートグラスを接続して、ディスプレイと追跡の設定を変更してください。接続を解除しても、保存された設定は保持されます。 VR diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d9cfa1501..03e678056 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2129,7 +2129,7 @@ 크기 저장 기본값으로 - 게임 배경에 Steam과 같은 방식으로 그려집니다. 위치가 몇 픽셀 어긋날 수 있습니다. + 이 정확한 위치는 몇 픽셀 차이가 날 수 있습니다. 이 게임에는 아직 로고가 없습니다. 로고에서 하나를 고르면 여기에 표시됩니다. 로고 위치를 저장하지 못했습니다. 다시 시도하세요. 배경 @@ -2654,7 +2654,7 @@ 부팅 동영상 동영상 DroidDeck · %1$d fps - 60 fps는 더 부드럽지만 Steam이 시작되는 동안 CPU를 더 사용합니다. Steam 자체 설정에서 고른 시작 동영상이 우선합니다. + 60 fps는 더 부드럽지만 Steam이 시작되는 동안 CPU를 더 사용합니다. Steam 자체 설정에서 고른 동영상도 여기에 표시됩니다. 소리 내 동영상 MP4, MOV, MKV 또는 WebM. 다른 형식과 %1$d MB를 넘는 파일은 기기에서 720p WebM으로 변환됩니다(최대 %2$d초). @@ -2668,6 +2668,51 @@ Steam용으로 %1$s 변환 중입니다. DroidDeck을 열어 두세요. 이 기기에서 해당 동영상을 디코딩할 수 없습니다. 동영상을 가져오지 못했습니다 + %1$s을(를) 삭제할까요? + DroidDeck과 Steam의 동영상 폴더에서 동영상이 삭제됩니다. + 설치됨 + 가져오기 + SteamDeckRepo + 소리 + 설치된 동영상 + 찾아보기 + DroidDeck + 가져옴 + SteamDeckRepo + Steam + 포인트 상점 + 부팅 영상 보고서 + DroidDeck과 Steam이 부팅 영상에 대해 알고 있는 내용을 텍스트 파일로 저장합니다. 버그 보고서에 첨부할 수 있습니다. + 저장 + %1$s에 저장됨 + 보고서를 저장하지 못했습니다. + 볼륨 + 길이 + %1$d초 + 동영상을 몇 초 동안 재생할지 설정합니다. 그보다 긴 부분은 끝에서 잘립니다. + SteamDeckRepo 검색 + 모든 기기 + Steam Deck + Steam Machine + 인기 + 다운로드 많은 순 + 좋아요 많은 순 + 최신순 + SteamDeckRepo에 연결할 수 없습니다 + 다시 시도 + 동영상이 없습니다 + 설치 + 사용 + 사용 중 + %1$s 설치됨 + 동영상을 다운로드하지 못했습니다 + 이전 + 다음 + %1$d페이지 + 지우기 + 페이지로 이동 + 미리보기 + 미리보기를 재생할 수 없습니다 Luma Ultra 카메라 — 실험적 기능 Joy-Con 위치 추적 스마트 안경 카메라를 사용하여 페어링된 Joy-Con 컨트롤러의 위치를 ​​조정합니다. 회전 및 버튼 입력은 Joy-Con 컨트롤러에서 이루어집니다. 손목이 가려진 경우 위치가 추정됩니다. 맨손으로 조작하는 컨트롤러를 대체하며, 휴대폰 카메라 추적이 일시 중지됩니다. 추적 센터를 연결하거나 재설정할 때는 Joy-Con 컨트롤러를 앞쪽으로 향하게 하십시오. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index bf3e97a2d..96f8207c6 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2129,7 +2129,7 @@ Saiz Simpan Kembali kepada lalai - Dilukis di atas latar belakang permainan, seperti yang dibuat oleh Steam. Kedudukan tepatnya mungkin berbeza beberapa piksel. + Kedudukan tepat ini mungkin berbeza beberapa piksel. Permainan ini belum ada logo. Pilih satu di Logo untuk melihatnya di sini. Kedudukan logo tidak dapat disimpan. Cuba lagi. Latar belakang @@ -2654,7 +2654,7 @@ Video but Video DroidDeck · %1$d fps - 60 fps kelihatan lebih lancar tetapi menggunakan lebih banyak CPU semasa Steam bermula. Video permulaan yang dipilih dalam tetapan Steam sendiri diutamakan. + 60 fps kelihatan lebih lancar tetapi menggunakan lebih banyak CPU semasa Steam bermula. Video yang dipilih dalam tetapan Steam sendiri turut dipaparkan di sini. Bunyi Video anda sendiri MP4, MOV, MKV atau WebM. Format lain dan fail melebihi %1$d MB ditukar kepada WebM 720p pada peranti, sehingga %2$d saat. @@ -2668,6 +2668,51 @@ Menukar %1$s untuk Steam. Biarkan DroidDeck terbuka. Peranti ini tidak dapat menyahkod video itu. Tidak dapat mengimport video itu + Alih keluar %1$s? + Ini memadamkan video daripada DroidDeck dan daripada folder video Steam. + Dipasang + Import + SteamDeckRepo + Bunyi + Video dipasang + Layari + DroidDeck + Diimport + SteamDeckRepo + Steam + Kedai Mata + Laporan filem but + Menyimpan apa yang DroidDeck dan Steam ketahui tentang filem but ke dalam fail teks yang boleh dilampirkan pada laporan pepijat. + Simpan + Disimpan di %1$s + Laporan tidak dapat disimpan. + Kelantangan + Tempoh + %1$d s + Berapa saat filem dimainkan. Bahagian yang lebih panjang akan dipotong pada penghujungnya. + Cari SteamDeckRepo + Semua peranti + Steam Deck + Steam Machine + Sohor kini + Paling banyak dimuat turun + Paling disukai + Terbaharu + Tidak dapat menghubungi SteamDeckRepo + Cuba semula + Tiada video ditemui + Pasang + Guna + Sedang digunakan + %1$s dipasang + Tidak dapat memuat turun video itu + Sebelumnya + Seterusnya + Halaman %1$d + Kosongkan + Pergi ke halaman + Pratonton + Pratonton tidak dapat dimainkan Kamera Luma Ultra — Eksperimen Penjejakan kedudukan Joy-Con Gunakan kamera cermin mata pintar untuk meletakkan pengawal Joy-Con yang dipasangkan. Input putaran dan butang datang daripada pengawal Joy-Con. Kedudukan dianggarkan apabila pergelangan tangan anda tersembunyi. Menggantikan pengawal yang dikendalikan dengan tangan kosong dan menjeda penjejakan kamera telefon. Halakan pengawal Joy-Con ke hadapan semasa menyambung atau menetapkan semula pusat penjejakan. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 73f552bfc..37d099a59 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2150,7 +2150,7 @@ Størrelse Lagre Tilbake til standard - Tegnes oppå spillets bakgrunn, slik Steam gjør. Den nøyaktige posisjonen kan avvike noen piksler. + Denne nøyaktige plasseringen kan avvike med noen piksler. Dette spillet har ingen logo ennå. Velg en under Logo for å se den her. Logoposisjonen kunne ikke lagres. Prøv igjen. Bakgrunn @@ -2679,7 +2679,7 @@ Oppstartsvideo Video DroidDeck · %1$d fps - 60 fps ser jevnere ut, men bruker mer CPU mens Steam starter. En oppstartsvideo valgt i Steams egne innstillinger har forrang. + 60 fps ser jevnere ut, men bruker mer CPU mens Steam starter. En video valgt i Steams egne innstillinger vises også her. Lyd Din egen video MP4, MOV, MKV eller WebM. Andre formater og filer over %1$d MB konverteres til 720p-WebM på enheten, opptil %2$d sekunder. @@ -2693,6 +2693,51 @@ Konverterer %1$s for Steam. Hold DroidDeck åpen. Enheten kan ikke dekode videoen. Kunne ikke importere videoen + Fjerne %1$s? + Dette sletter videoen fra DroidDeck og fra Steams videomappe. + Installert + Importer + SteamDeckRepo + Lyd + Installerte videoer + Bla gjennom + DroidDeck + Importert + SteamDeckRepo + Steam + Poengbutikk + Rapport om oppstartsfilmer + Lagrer det DroidDeck og Steam vet om oppstartsfilmer i en tekstfil du kan legge ved en feilrapport. + Lagre + Lagret i %1$s + Rapporten kunne ikke lagres. + Volum + Lengde + %1$d s + Hvor mange sekunder av filmen som spilles av. Alt som er lengre, klippes bort på slutten. + Søk i SteamDeckRepo + Alle enheter + Steam Deck + Steam Machine + Trender + Mest nedlastet + Mest likt + Nyeste + Fikk ikke kontakt med SteamDeckRepo + Prøv igjen + Fant ingen videoer + Installer + Bruk + I bruk + %1$s installert + Kunne ikke laste ned videoen + Forrige + Neste + Side %1$d + Tøm + Gå til side + Forhåndsvis + Forhåndsvisningen kunne ikke spilles av Luma Ultra-kameraer – Eksperimentelt Joy-Con-posisjonssporing Bruk smartbrillenes kameraer til å plassere de parede Joy-Con-kontrollerne. Rotasjon og knappinndata kommer fra Joy-Con-kontrollerne. Posisjonen estimeres når håndleddene dine er skjult. Erstatter kontrollere som betjenes med bare hender og pauser sporing av telefonkamera. Rett Joy-Con-kontrollerne fremover når du kobler til eller tilbakestiller sporingssenteret. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6cd75efa2..e2d4ffa98 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2150,7 +2150,7 @@ Grootte Opslaan Terug naar standaard - Wordt over de achtergrond van de game getekend, zoals Steam doet. De exacte positie kan een paar pixels afwijken. + Deze exacte plek kan een paar pixels verschillen. Deze game heeft nog geen logo. Kies er een bij Logo om het hier te zien. De logopositie kon niet worden opgeslagen. Probeer het opnieuw. Achtergrond @@ -2679,7 +2679,7 @@ Opstartvideo Video DroidDeck · %1$d fps - 60 fps oogt vloeiender maar gebruikt meer CPU terwijl Steam opstart. Een opstartvideo die in Steam zelf is gekozen heeft voorrang. + 60 fps oogt vloeiender maar gebruikt meer CPU terwijl Steam opstart. Een video die in Steam zelf is gekozen verschijnt hier ook. Geluid Eigen video MP4, MOV, MKV of WebM. Andere formaten en bestanden boven %1$d MB worden op het apparaat omgezet naar 720p-WebM, tot %2$d seconden. @@ -2693,6 +2693,51 @@ %1$s wordt omgezet voor Steam. Houd DroidDeck open. Dit apparaat kan deze video niet decoderen. Kan deze video niet importeren + %1$s verwijderen? + Hiermee wordt de video verwijderd uit DroidDeck en uit de videomap van Steam. + Geïnstalleerd + Importeren + SteamDeckRepo + Geluid + Geïnstalleerde video\'s + Bladeren + DroidDeck + Geïmporteerd + SteamDeckRepo + Steam + Puntenwinkel + Rapport over opstartfilms + Slaat op in een tekstbestand wat DroidDeck en Steam weten over opstartfilms, om bij een bugrapport te voegen. + Opslaan + Opgeslagen in %1$s + Het rapport kon niet worden opgeslagen. + Volume + Duur + %1$d s + Hoeveel seconden van de film worden afgespeeld. Alles wat langer is, wordt aan het einde afgekapt. + Zoeken in SteamDeckRepo + Alle apparaten + Steam Deck + Steam Machine + Trending + Meest gedownload + Meest geliked + Nieuwste + Kan SteamDeckRepo niet bereiken + Opnieuw + Geen video\'s gevonden + Installeren + Gebruiken + In gebruik + %1$s geïnstalleerd + Kan die video niet downloaden + Vorige + Volgende + Pagina %1$d + Wissen + Ga naar pagina + Voorbeeld + Het voorbeeld kon niet worden afgespeeld Luma Ultra-camera\'s — Experimenteel Positietracking van Joy-Con-controllers Gebruik de camera\'s van de smartbril om je gekoppelde Joy-Con-controllers te positioneren. Rotatie en knopbediening komen van de Joy-Con-controllers. De positie wordt geschat wanneer je polsen verborgen zijn. Vervangt controllers die met blote handen worden bediend en pauzeert de tracking door de telefooncamera. Richt de Joy-Con-controllers naar voren bij het verbinden of resetten van het trackingcentrum. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e2ee25359..9e8459774 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2192,7 +2192,7 @@ Rozmiar Zapisz Przywróć domyślne - Logo jest rysowane na tle gry, tak jak robi to Steam. Dokładna pozycja może się różnić o kilka pikseli. + To dokładne miejsce może różnić się o kilka pikseli. Ta gra nie ma jeszcze logo. Wybierz je w Logo, aby zobaczyć je tutaj. Nie udało się zapisać pozycji logo. Spróbuj ponownie. Tło @@ -2747,7 +2747,7 @@ Film startowy Film DroidDeck · %1$d kl./s - 60 kl./s wygląda płynniej, ale bardziej obciąża procesor podczas uruchamiania Steam. Film startowy wybrany w ustawieniach Steam ma pierwszeństwo. + 60 kl./s wygląda płynniej, ale bardziej obciąża procesor podczas uruchamiania Steam. Film wybrany w ustawieniach Steam też jest tu widoczny. Dźwięk Własny film MP4, MOV, MKV lub WebM. Inne formaty i pliki powyżej %1$d MB są konwertowane na urządzeniu do WebM 720p, do %2$d sekund. @@ -2761,6 +2761,51 @@ Konwertowanie %1$s dla Steam. Nie zamykaj DroidDeck. To urządzenie nie może zdekodować tego filmu. Nie udało się zaimportować filmu + Usunąć %1$s? + Film zostanie usunięty z DroidDeck i z folderu filmów Steam. + Zainstalowane + Importuj + SteamDeckRepo + Dźwięk + Zainstalowane filmy + Przeglądaj + DroidDeck + Zaimportowane + SteamDeckRepo + Steam + Sklep za punkty + Raport o filmach startowych + Zapisuje w pliku tekstowym to, co DroidDeck i Steam wiedzą o filmach startowych, do dołączenia do zgłoszenia błędu. + Zapisz + Zapisano w %1$s + Nie udało się zapisać raportu. + Głośność + Długość + %1$d s + Ile sekund filmu jest odtwarzane. Wszystko, co trwa dłużej, jest ucinane na końcu. + Szukaj w SteamDeckRepo + Wszystkie urządzenia + Steam Deck + Steam Machine + Na czasie + Najczęściej pobierane + Najbardziej lubiane + Najnowsze + Nie można połączyć z SteamDeckRepo + Ponów + Nie znaleziono filmów + Zainstaluj + Użyj + W użyciu + Zainstalowano %1$s + Nie udało się pobrać tego filmu + Poprzednia + Następna + Strona %1$d + Wyczyść + Przejdź do strony + Podgląd + Nie udało się odtworzyć podglądu Kamery Luma Ultra — Eksperymentalne Śledzenie pozycji Joy-Conów Użyj kamer w inteligentnych okularach, aby ustawić sparowane kontrolery Joy-Con. Obrót i sterowanie przyciskami pochodzą z kontrolerów Joy-Con. Pozycja jest szacowana, gdy nadgarstki są ukryte. Zastępuje kontrolery obsługiwane gołymi rękami i wstrzymuje śledzenie kamery telefonu. Skieruj kontrolery Joy-Con do przodu podczas podłączania lub resetowania centrum śledzenia. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index b1b6ccebd..059af9551 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2157,7 +2157,7 @@ Tamanho Guardar Repor predefinição - É desenhado sobre o fundo do jogo, como o Steam faz. A posição exata pode variar alguns píxeis. + Esta posição exata pode variar alguns píxeis. Este jogo ainda não tem logótipo. Escolha um em Logótipo para o ver aqui. Não foi possível guardar a posição do logótipo. Tente novamente. Fundo @@ -2713,7 +2713,7 @@ Vídeo de arranque Vídeo DroidDeck · %1$d fps - 60 fps é mais fluido, mas usa mais CPU enquanto o Steam arranca. Um vídeo de arranque escolhido nas definições do Steam tem prioridade. + 60 fps é mais fluido, mas usa mais CPU enquanto o Steam arranca. Um vídeo escolhido nas definições do Steam também aparece aqui. Som O seu próprio vídeo MP4, MOV, MKV ou WebM. Outros formatos e ficheiros acima de %1$d MB são convertidos para WebM 720p no dispositivo, até %2$d segundos. @@ -2727,6 +2727,51 @@ A converter %1$s para o Steam. Mantenha o DroidDeck aberto. Este dispositivo não consegue descodificar esse vídeo. Não foi possível importar esse vídeo + Remover %1$s? + Isto elimina o vídeo do DroidDeck e da pasta de vídeos do Steam. + Instalados + Importar + SteamDeckRepo + Som + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Loja de Pontos + Relatório dos filmes de arranque + Guarda num ficheiro de texto o que o DroidDeck e o Steam sabem sobre os filmes de arranque, para anexar a um relatório de erro. + Guardar + Guardado em %1$s + Não foi possível guardar o relatório. + Volume + Duração + %1$d s + Quantos segundos do filme são reproduzidos. Tudo o que for mais longo é cortado no fim. + Pesquisar no SteamDeckRepo + Todos os dispositivos + Steam Deck + Steam Machine + Em destaque + Mais transferidos + Mais gostados + Mais recentes + Não foi possível aceder ao SteamDeckRepo + Tentar novamente + Nenhum vídeo encontrado + Instalar + Usar + Em utilização + %1$s instalado + Não foi possível transferir esse vídeo + Anterior + Seguinte + Página %1$d + Limpar + Ir para a página + Pré-visualizar + Não foi possível reproduzir a pré-visualização Câmaras Luma Ultra — Experimental Rastreio de posição do Joy-Con Utilize as câmaras dos óculos inteligentes para posicionar os seus comandos Joy-Con emparelhados. A rotação e os comandos dos botões vêm dos comandos Joy-Con. A posição é estimada quando os seus pulsos estão escondidos. Substitui os controlos operados com as mãos nuas e pausa o rastreio da câmara do telefone. Aponte os controlos Joy-Con para a frente ao ligar ou repor o centro de seguimento. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 28184bdaf..68a46590f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2157,7 +2157,7 @@ Tamanho Salvar Voltar ao padrão - É desenhado sobre o plano de fundo do jogo, como a Steam faz. A posição exata pode variar alguns pixels. + Esta posição exata pode variar alguns pixels. Este jogo ainda não tem logo. Escolha um em Logo para vê-lo aqui. Não foi possível salvar a posição do logo. Tente de novo. Fundo @@ -2713,7 +2713,7 @@ Vídeo de inicialização Vídeo DroidDeck · %1$d fps - 60 fps fica mais fluido, mas usa mais CPU enquanto o Steam inicia. Um vídeo de inicialização escolhido nas configurações do Steam tem prioridade. + 60 fps fica mais fluido, mas usa mais CPU enquanto o Steam inicia. Um vídeo escolhido nas configurações do Steam também aparece aqui. Som Seu próprio vídeo MP4, MOV, MKV ou WebM. Outros formatos e arquivos acima de %1$d MB são convertidos para WebM 720p no aparelho, até %2$d segundos. @@ -2727,6 +2727,51 @@ Convertendo %1$s para o Steam. Mantenha o DroidDeck aberto. Este aparelho não consegue decodificar esse vídeo. Não foi possível importar esse vídeo + Remover %1$s? + Isso exclui o vídeo do DroidDeck e da pasta de vídeos do Steam. + Instalados + Importar + SteamDeckRepo + Som + Vídeos instalados + Explorar + DroidDeck + Importado + SteamDeckRepo + Steam + Loja de Pontos + Relatório dos filmes de inicialização + Salva em um arquivo de texto o que o DroidDeck e o Steam sabem sobre os filmes de inicialização, para anexar a um relatório de bug. + Salvar + Salvo em %1$s + Não foi possível salvar o relatório. + Volume + Duração + %1$d s + Quantos segundos do vídeo são reproduzidos. Tudo o que for mais longo é cortado no final. + Pesquisar no SteamDeckRepo + Todos os dispositivos + Steam Deck + Steam Machine + Em alta + Mais baixados + Mais curtidos + Mais recentes + Não foi possível acessar o SteamDeckRepo + Tentar novamente + Nenhum vídeo encontrado + Instalar + Usar + Em uso + %1$s instalado + Não foi possível baixar esse vídeo + Anterior + Próxima + Página %1$d + Limpar + Ir para a página + Pré-visualizar + Não foi possível reproduzir a pré-visualização Câmeras Luma Ultra — Experimental Rastreamento de posição do Joy-Con Use as câmeras dos óculos inteligentes para posicionar seus controles Joy-Con emparelhados. A rotação e os comandos dos botões vêm dos controles Joy-Con. A posição é estimada quando seus pulsos estão escondidos. Substitui os controles operados com as mãos nuas e pausa o rastreamento da câmera do telefone. Aponte os controles Joy-Con para frente ao conectar ou redefinir o centro de rastreamento. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 6e4435df0..7bf7df032 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2171,7 +2171,7 @@ Dimensiune Salvează Revino la implicit - Se desenează peste fundalul jocului, ca în Steam. Poziția exactă poate diferi cu câțiva pixeli. + Poziția exactă poate diferi cu câțiva pixeli. Acest joc nu are încă un logo. Alege unul la Logo ca să îl vezi aici. Poziția logo-ului nu a putut fi salvată. Încearcă din nou. Fundal @@ -2713,7 +2713,7 @@ Videoclip de pornire Videoclip DroidDeck · %1$d fps - 60 fps arată mai fluid, dar folosește mai mult procesorul cât pornește Steam. Un videoclip de pornire ales în setările Steam are prioritate. + 60 fps arată mai fluid, dar folosește mai mult procesorul cât pornește Steam. Un videoclip ales în setările Steam apare și aici. Sunet Videoclipul tău MP4, MOV, MKV sau WebM. Alte formate și fișierele de peste %1$d MB sunt convertite pe dispozitiv în WebM 720p, până la %2$d secunde. @@ -2727,6 +2727,51 @@ Se convertește %1$s pentru Steam. Lasă DroidDeck deschis. Acest dispozitiv nu poate decoda videoclipul. Videoclipul nu a putut fi importat + Elimini %1$s? + Videoclipul va fi șters din DroidDeck și din folderul de videoclipuri Steam. + Instalate + Importă + SteamDeckRepo + Sunet + Videoclipuri instalate + Răsfoiește + DroidDeck + Importat + SteamDeckRepo + Steam + Magazin de puncte + Raport despre filmele de pornire + Salvează într-un fișier text ce știu DroidDeck și Steam despre filmele de pornire, pentru a-l atașa la un raport de eroare. + Salvează + Salvat în %1$s + Raportul nu a putut fi salvat. + Volum + Durată + %1$d s + Câte secunde din film se redau. Tot ce este mai lung este tăiat la final. + Caută în SteamDeckRepo + Toate dispozitivele + Steam Deck + Steam Machine + Populare + Cele mai descărcate + Cele mai apreciate + Cele mai noi + Nu s-a putut contacta SteamDeckRepo + Reîncearcă + Nu s-au găsit videoclipuri + Instalează + Folosește + În uz + %1$s instalat + Videoclipul nu a putut fi descărcat + Înapoi + Înainte + Pagina %1$d + Șterge + Mergi la pagina + Previzualizare + Previzualizarea nu a putut fi redată Camere Luma Ultra — Experimentale Urmărirea poziției Joy-Con Folosește camerele ochelarilor inteligenți pentru a poziționa controlerele Joy-Con asociate. Rotirea și comenzile de butoane provin de la controlerele Joy-Con. Poziția este estimată atunci când încheieturile mâinilor sunt ascunse. Înlocuiește controlerele acționate cu mâinile goale și întrerupe urmărirea camerei telefonului. Îndreaptă controlerele Joy-Con înainte atunci când conectezi sau resetezi centrul de urmărire. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ec52c1287..4489ed3bb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2186,7 +2186,7 @@ Размер Сохранить Вернуть по умолчанию - Рисуется поверх фона игры, как в Steam. Точное положение может отличаться на несколько пикселей. + Точное положение может отличаться на несколько пикселей. У этой игры пока нет логотипа. Выберите его в разделе «Логотип», чтобы увидеть здесь. Не удалось сохранить положение логотипа. Попробуйте ещё раз. Фон @@ -2747,7 +2747,7 @@ Загрузочное видео Видео DroidDeck · %1$d кадр/с - 60 кадр/с выглядит плавнее, но сильнее нагружает процессор при запуске Steam. Видео при запуске, выбранное в настройках самого Steam, имеет приоритет. + 60 кадр/с выглядит плавнее, но сильнее нагружает процессор при запуске Steam. Видео, выбранное в настройках самого Steam, тоже появляется здесь. Звук Своё видео MP4, MOV, MKV или WebM. Другие форматы и файлы больше %1$d МБ преобразуются на устройстве в WebM 720p, до %2$d секунд. @@ -2761,6 +2761,51 @@ %1$s преобразуется для Steam. Не закрывайте DroidDeck. Устройство не может декодировать это видео. Не удалось импортировать видео + Удалить %1$s? + Видео будет удалено из DroidDeck и из папки видео Steam. + Установленные + Импорт + SteamDeckRepo + Звук + Установленные видео + Обзор + DroidDeck + Импортировано + SteamDeckRepo + Steam + Магазин баллов + Отчет о загрузочных роликах + Сохраняет в текстовый файл то, что DroidDeck и Steam знают о загрузочных роликах, чтобы приложить к сообщению об ошибке. + Сохранить + Сохранено: %1$s + Не удалось сохранить отчет. + Громкость + Длительность + %1$d с + Сколько секунд фильма воспроизводится. Всё, что длиннее, обрезается в конце. + Поиск в SteamDeckRepo + Все устройства + Steam Deck + Steam Machine + В тренде + Больше всего загрузок + Больше всего лайков + Новые + Не удалось подключиться к SteamDeckRepo + Повторить + Видео не найдены + Установить + Использовать + Используется + %1$s установлено + Не удалось скачать это видео + Назад + Далее + Страница %1$d + Очистить + Перейти к странице + Просмотр + Не удалось воспроизвести предпросмотр Камеры Luma Ultra — Экспериментальная Отслеживание положения Joy-Con Используйте камеры умных очков для позиционирования сопряженных контроллеров Joy-Con. Вращение и ввод с кнопок осуществляются с помощью контроллеров Joy-Con. Положение оценивается, когда ваши запястья скрыты. Заменяет контроллеры, управляемые голыми руками, и приостанавливает отслеживание камерой телефона. Направьте контроллеры Joy-Con вперед при подключении или сбросе центра отслеживания. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 2d1573175..4a2ec518d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2150,7 +2150,7 @@ Storlek Spara Tillbaka till standard - Ritas ovanpå spelets bakgrund, som Steam gör. Exakt position kan skilja några pixlar. + Den exakta positionen kan avvika med några pixlar. Det här spelet har ingen logotyp än. Välj en under Logotyp för att se den här. Logotypens position kunde inte sparas. Försök igen. Bakgrund @@ -2679,7 +2679,7 @@ Startvideo Video DroidDeck · %1$d fps - 60 fps ser jämnare ut men använder mer CPU medan Steam startar. En startvideo vald i Steams egna inställningar har företräde. + 60 fps ser jämnare ut men använder mer CPU medan Steam startar. En video vald i Steams egna inställningar visas också här. Ljud Egen video MP4, MOV, MKV eller WebM. Andra format och filer över %1$d MB konverteras till 720p-WebM på enheten, upp till %2$d sekunder. @@ -2693,6 +2693,51 @@ Konverterar %1$s för Steam. Håll DroidDeck öppet. Enheten kan inte avkoda videon. Kunde inte importera videon + Ta bort %1$s? + Videon tas bort från DroidDeck och från Steams videomapp. + Installerade + Importera + SteamDeckRepo + Ljud + Installerade videor + Bläddra + DroidDeck + Importerad + SteamDeckRepo + Steam + Poängbutiken + Rapport om startfilmer + Sparar det DroidDeck och Steam vet om startfilmer i en textfil som du kan bifoga en felrapport. + Spara + Sparad i %1$s + Rapporten kunde inte sparas. + Volym + Längd + %1$d s + Hur många sekunder av filmen som spelas upp. Allt som är längre klipps bort i slutet. + Sök i SteamDeckRepo + Alla enheter + Steam Deck + Steam Machine + Trendar + Mest nedladdade + Mest gillade + Nyast + Kunde inte nå SteamDeckRepo + Försök igen + Inga videor hittades + Installera + Använd + Används + %1$s installerad + Kunde inte ladda ner videon + Föregående + Nästa + Sida %1$d + Rensa + Gå till sida + Förhandsvisa + Förhandsvisningen kunde inte spelas upp Luma Ultra-kameror — Experimentell Joy-Con-positionsspårning Använd smartglasögonens kameror för att placera dina parade Joy-Con-kontroller. Rotation och knappinmatningar kommer från Joy-Con-kontrollerna. Positionen uppskattas när dina handleder är dolda. Ersätter kontroller som manövreras med bara händer och pausar telefonkameran. Rikta Joy-Con-kontrollerna framåt när du ansluter eller återställer spårningscentret. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index ba1962129..67a5906f6 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -2129,7 +2129,7 @@ ขนาด บันทึก กลับเป็นค่าเริ่มต้น - วาดทับพื้นหลังของเกมเหมือนที่ Steam ทำ ตำแหน่งจริงอาจต่างไปสองสามพิกเซล + ตำแหน่งที่แน่นอนอาจต่างกันไปไม่กี่พิกเซล เกมนี้ยังไม่มีโลโก้ เลือกในโลโก้เพื่อดูที่นี่ บันทึกตำแหน่งโลโก้ไม่ได้ ลองอีกครั้ง พื้นหลัง @@ -2654,7 +2654,7 @@ วิดีโอบูต วิดีโอ DroidDeck · %1$d fps - 60 fps ลื่นกว่าแต่ใช้ CPU มากขึ้นระหว่างที่ Steam กำลังเริ่ม วิดีโอเริ่มต้นที่เลือกในการตั้งค่าของ Steam เองจะมีลำดับความสำคัญกว่า + 60 fps ลื่นกว่าแต่ใช้ CPU มากขึ้นระหว่างที่ Steam กำลังเริ่ม วิดีโอที่เลือกในการตั้งค่าของ Steam เองจะแสดงที่นี่ด้วย เสียง วิดีโอของคุณเอง MP4, MOV, MKV หรือ WebM รูปแบบอื่นและไฟล์ที่ใหญ่กว่า %1$d MB จะถูกแปลงเป็น WebM 720p บนอุปกรณ์ ความยาวไม่เกิน %2$d วินาที @@ -2668,6 +2668,51 @@ กำลังแปลง %1$s สำหรับ Steam โปรดเปิด DroidDeck ค้างไว้ อุปกรณ์นี้ถอดรหัสวิดีโอนั้นไม่ได้ นำเข้าวิดีโอนี้ไม่ได้ + ลบ %1$s หรือไม่? + วิดีโอจะถูกลบออกจาก DroidDeck และจากโฟลเดอร์วิดีโอของ Steam + ติดตั้งแล้ว + นำเข้า + SteamDeckRepo + เสียง + วิดีโอที่ติดตั้งแล้ว + เรียกดู + DroidDeck + นำเข้าแล้ว + SteamDeckRepo + Steam + ร้านค้าแต้ม + รายงานวิดีโอเปิดเครื่อง + บันทึกสิ่งที่ DroidDeck และ Steam รู้เกี่ยวกับวิดีโอเปิดเครื่องลงในไฟล์ข้อความ เพื่อแนบกับรายงานข้อบกพร่อง + บันทึก + บันทึกไปที่ %1$s แล้ว + ไม่สามารถบันทึกรายงานได้ + ระดับเสียง + ความยาว + %1$d วิ + จำนวนวินาทีของภาพยนตร์ที่จะเล่น ส่วนที่ยาวเกินกว่านั้นจะถูกตัดออกที่ท้าย + ค้นหาใน SteamDeckRepo + ทุกอุปกรณ์ + Steam Deck + Steam Machine + มาแรง + ดาวน์โหลดมากที่สุด + ถูกใจมากที่สุด + ใหม่ล่าสุด + เชื่อมต่อ SteamDeckRepo ไม่ได้ + ลองอีกครั้ง + ไม่พบวิดีโอ + ติดตั้ง + ใช้ + กำลังใช้งาน + ติดตั้ง %1$s แล้ว + ดาวน์โหลดวิดีโอนั้นไม่ได้ + ก่อนหน้า + ถัดไป + หน้า %1$d + ล้าง + ไปที่หน้า + ดูตัวอย่าง + เล่นตัวอย่างไม่ได้ กล้อง Luma Ultra — ทดลอง การติดตามตำแหน่ง Joy-Con ใช้กล้องของแว่นตาอัจฉริยะเพื่อกำหนดตำแหน่งคอนโทรลเลอร์ Joy-Con ที่จับคู่ไว้ การหมุนและการกดปุ่มจะมาจากคอนโทรลเลอร์ Joy-Con ตำแหน่งจะถูกประมาณเมื่อข้อมือของคุณถูกซ่อนอยู่ แทนที่คอนโทรลเลอร์ที่ใช้งานด้วยมือเปล่าและหยุดการติดตามของกล้องโทรศัพท์ชั่วคราว ชี้คอนโทรลเลอร์ Joy-Con ไปข้างหน้าเมื่อเชื่อมต่อหรือรีเซ็ตศูนย์กลางการติดตาม diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d782c221b..912ef175c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2136,7 +2136,7 @@ Boyut Kaydet Varsayılana döndür - Steam\'in yaptığı gibi oyunun arka planının üzerine çizilir. Tam konum birkaç piksel değişebilir. + Tam konum birkaç piksel farklı olabilir. Bu oyunun henüz logosu yok. Burada görmek için Logo bölümünden bir tane seçin. Logo konumu kaydedilemedi. Yeniden deneyin. Arka plan @@ -2679,7 +2679,7 @@ Açılış videosu Video DroidDeck · %1$d fps - 60 fps daha akıcı görünür ama Steam açılırken işlemciyi daha çok kullanır. Steam\'in kendi ayarlarında seçilen açılış videosu önceliklidir. + 60 fps daha akıcı görünür ama Steam açılırken işlemciyi daha çok kullanır. Steam\'in kendi ayarlarında seçilen bir video da burada görünür. Ses Kendi videon MP4, MOV, MKV veya WebM. Diğer biçimler ve %1$d MB üzeri dosyalar cihazda 720p WebM\'e dönüştürülür, en fazla %2$d saniye. @@ -2693,6 +2693,51 @@ %1$s Steam için dönüştürülüyor. DroidDeck\'i açık tut. Bu cihaz o videonun kodunu çözemiyor. Video içe aktarılamadı + %1$s kaldırılsın mı? + Video, DroidDeck\'ten ve Steam\'in videolar klasöründen silinir. + Yüklü + İçe aktar + SteamDeckRepo + Ses + Yüklü videolar + Göz at + DroidDeck + İçe aktarılan + SteamDeckRepo + Steam + Puan Mağazası + Açılış videosu raporu + DroidDeck ve Steam’in açılış videoları hakkında bildiklerini, hata raporuna ekleyebileceğiniz bir metin dosyasına kaydeder. + Kaydet + %1$s konumuna kaydedildi + Rapor kaydedilemedi. + Ses düzeyi + Süre + %1$d sn + Videonun kaç saniyesinin oynatılacağı. Daha uzun olan kısım sonunda kesilir. + SteamDeckRepo\'da ara + Tüm cihazlar + Steam Deck + Steam Machine + Trend + En çok indirilen + En çok beğenilen + En yeni + SteamDeckRepo\'ya ulaşılamadı + Tekrar dene + Video bulunamadı + Yükle + Kullan + Kullanımda + %1$s yüklendi + Video indirilemedi + Önceki + Sonraki + Sayfa %1$d + Temizle + Sayfaya git + Önizle + Önizleme oynatılamadı Luma Ultra kameralar — Deneysel Joy-Con konum takibi Eşleştirilmiş Joy-Con kumandalarınızı konumlandırmak için akıllı gözlük kameralarını kullanın. Döndürme ve düğme girişleri Joy-Con kumandalarından gelir. Bilekleriniz gizlendiğinde konum tahmin edilir. Çıplak elle çalıştırılan kumandaların yerini alır ve telefon kamerası izlemesini duraklatır. İzleme merkezini bağlarken veya sıfırlarken Joy-Con kumandalarını ileriye doğru çevirin. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d22466ca7..9e7621092 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2192,7 +2192,7 @@ Розмір Зберегти Повернути типове - Малюється поверх тла гри, як це робить Steam. Точна позиція може відрізнятися на кілька пікселів. + Точне розташування може відрізнятися на кілька пікселів. У цієї гри ще немає логотипу. Виберіть його в розділі Логотип, щоб побачити тут. Не вдалося зберегти позицію логотипу. Спробуйте ще раз. Фон @@ -2747,7 +2747,7 @@ Завантажувальне відео Відео DroidDeck · %1$d кадр/с - 60 кадр/с виглядає плавніше, але більше навантажує процесор під час запуску Steam. Відео запуску, вибране в налаштуваннях самого Steam, має пріоритет. + 60 кадр/с виглядає плавніше, але більше навантажує процесор під час запуску Steam. Відео, вибране в налаштуваннях самого Steam, також з\'являється тут. Звук Власне відео MP4, MOV, MKV або WebM. Інші формати й файли понад %1$d МБ перетворюються на пристрої на WebM 720p, до %2$d секунд. @@ -2761,6 +2761,51 @@ %1$s перетворюється для Steam. Не закривайте DroidDeck. Пристрій не може декодувати це відео. Не вдалося імпортувати відео + Видалити %1$s? + Відео буде видалено з DroidDeck і з папки відео Steam. + Встановлені + Імпорт + SteamDeckRepo + Звук + Встановлені відео + Огляд + DroidDeck + Імпортовано + SteamDeckRepo + Steam + Магазин балів + Звіт про завантажувальні ролики + Зберігає в текстовий файл те, що DroidDeck і Steam знають про завантажувальні ролики, щоб додати до повідомлення про помилку. + Зберегти + Збережено: %1$s + Не вдалося зберегти звіт. + Гучність + Тривалість + %1$d с + Скільки секунд фільму відтворюється. Усе, що довше, обрізається в кінці. + Пошук у SteamDeckRepo + Усі пристрої + Steam Deck + Steam Machine + У тренді + Найбільше завантажень + Найбільше вподобань + Нові + Не вдалося підключитися до SteamDeckRepo + Повторити + Відео не знайдено + Встановити + Використати + Використовується + %1$s встановлено + Не вдалося завантажити це відео + Назад + Далі + Сторінка %1$d + Очистити + Перейти на сторінку + Перегляд + Не вдалося відтворити перегляд Камери Luma Ultra — Експериментально Відстеження положення Joy-Con Використовуйте камери розумних окулярів, щоб розташувати парні контролери Joy-Con. Обертання та введення кнопок надходять від контролерів Joy-Con. Положення оцінюється, коли ваші зап\'ястя приховані. Замінює контролери, якими керують голими руками, та призупиняє відстеження камерою телефону. Направте контролери Joy-Con вперед під час підключення або скидання центру відстеження. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 800e33b10..e29d23dfd 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -2129,7 +2129,7 @@ Kích thước Lưu Về mặc định - Được vẽ đè lên nền game như Steam làm. Vị trí chính xác có thể lệch vài pixel. + Vị trí chính xác có thể lệch vài pixel. Game này chưa có logo. Chọn một logo để xem tại đây. Không lưu được vị trí logo. Thử lại. Hình nền @@ -2654,7 +2654,7 @@ Video khởi động Video DroidDeck · %1$d fps - 60 fps mượt hơn nhưng tốn CPU hơn khi Steam đang khởi động. Video khởi động chọn trong cài đặt của chính Steam sẽ được ưu tiên. + 60 fps mượt hơn nhưng tốn CPU hơn khi Steam đang khởi động. Video chọn trong cài đặt của chính Steam cũng hiện ở đây. Âm thanh Video của bạn MP4, MOV, MKV hoặc WebM. Định dạng khác và tệp trên %1$d MB được chuyển sang WebM 720p ngay trên thiết bị, tối đa %2$d giây. @@ -2668,6 +2668,51 @@ Đang chuyển đổi %1$s cho Steam. Hãy để DroidDeck mở. Thiết bị này không giải mã được video đó. Không thể nhập video này + Xóa %1$s? + Thao tác này xóa video khỏi DroidDeck và khỏi thư mục video của Steam. + Đã cài + Nhập + SteamDeckRepo + Âm thanh + Video đã cài + Duyệt + DroidDeck + Đã nhập + SteamDeckRepo + Steam + Cửa hàng điểm + Báo cáo phim khởi động + Lưu những gì DroidDeck và Steam biết về phim khởi động vào một tệp văn bản để đính kèm báo cáo lỗi. + Lưu + Đã lưu vào %1$s + Không thể lưu báo cáo. + Âm lượng + Độ dài + %1$d giây + Số giây của phim sẽ được phát. Phần dài hơn sẽ bị cắt ở cuối. + Tìm trong SteamDeckRepo + Tất cả thiết bị + Steam Deck + Steam Machine + Thịnh hành + Tải nhiều nhất + Thích nhiều nhất + Mới nhất + Không kết nối được SteamDeckRepo + Thử lại + Không tìm thấy video + Cài đặt + Dùng + Đang dùng + Đã cài %1$s + Không thể tải video đó + Trước + Tiếp + Trang %1$d + Xóa + Đến trang + Xem trước + Không thể phát bản xem trước Camera Luma Ultra — Thử nghiệm Theo dõi vị trí Joy-Con Sử dụng camera của kính thông minh để định vị bộ điều khiển Joy-Con đã ghép nối. Xoay và nhập nút đến từ bộ điều khiển Joy-Con. Vị trí được ước tính khi cổ tay của bạn bị che khuất. Thay thế bộ điều khiển được điều khiển bằng tay không và tạm dừng theo dõi bằng camera điện thoại. Hướng bộ điều khiển Joy-Con về phía trước khi kết nối hoặc đặt lại trung tâm theo dõi. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cb8c9903b..44fd2f961 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -764,7 +764,7 @@ 大小 保存 重置为默认 - 显示在游戏背景上,与 Steam 的绘制方式一致。具体位置可能相差几个像素。 + 确切位置可能相差几个像素。 该游戏还没有标志。在标志中选择一个即可在此查看。 无法保存标志位置。请重试。 搜索 SteamGridDB… @@ -2654,7 +2654,7 @@ 开机动画 视频 DroidDeck · %1$d fps - 60 fps 更流畅,但 Steam 启动时会占用更多 CPU。在 Steam 自身设置中选择的启动动画优先。 + 60 fps 更流畅,但 Steam 启动时会占用更多 CPU。在 Steam 自身设置中选择的动画也会显示在这里。 声音 自定义视频 MP4、MOV、MKV 或 WebM。其他格式及超过 %1$d MB 的文件会在设备上转换为 720p WebM,最长 %2$d 秒。 @@ -2668,6 +2668,51 @@ 正在为 Steam 转换 %1$s。请保持 DroidDeck 打开。 此设备无法解码该视频。 无法导入该视频 + 移除 %1$s? + 这会从 DroidDeck 和 Steam 的动画文件夹中删除该动画。 + 已安装 + 导入 + SteamDeckRepo + 声音 + 已安装的动画 + 浏览 + DroidDeck + 已导入 + SteamDeckRepo + Steam + 点数商店 + 启动影片报告 + 将 DroidDeck 和 Steam 掌握的启动影片信息保存为文本文件,可附在错误报告中。 + 保存 + 已保存到 %1$s + 无法保存报告。 + 音量 + 时长 + %1$d 秒 + 播放影片的秒数。超出该时长的部分会在结尾被截断。 + 搜索 SteamDeckRepo + 所有设备 + Steam Deck + Steam Machine + 热门 + 下载最多 + 最受欢迎 + 最新 + 无法连接 SteamDeckRepo + 重试 + 未找到动画 + 安装 + 使用 + 使用中 + 已安装 %1$s + 无法下载该动画 + 上一页 + 下一页 + 第 %1$d 页 + 清除 + 转到页面 + 预览 + 无法播放此预览 Luma Ultra 摄像头 — 实验性功能 Joy-Con 位置追踪 使用智能眼镜摄像头定位已配对的 Joy-Con 控制器。旋转和按键输入来自 Joy-Con 控制器。当您的手腕被遮挡时,系统会估算位置。此功能会取代徒手操作的控制器,并暂停手机摄像头追踪。连接或重置追踪中心时,请将 Joy-Con 控制器指向前方。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 52207967c..431faf6be 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2129,7 +2129,7 @@ 大小 儲存 還原預設 - 顯示在遊戲背景上,與 Steam 的繪製方式一致。實際位置可能相差幾個像素。 + 確切位置可能相差幾個像素。 此遊戲尚無標誌。請在標誌中選擇一個,即可在此檢視。 無法儲存標誌位置。請再試一次。 背景 @@ -2654,7 +2654,7 @@ 開機動畫 影片 DroidDeck · %1$d fps - 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的啟動動畫優先。 + 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的動畫也會顯示在這裡。 聲音 自訂影片 MP4、MOV、MKV 或 WebM。其他格式及超過 %1$d MB 的檔案會在裝置上轉換為 720p WebM,最長 %2$d 秒。 @@ -2668,6 +2668,51 @@ 正在為 Steam 轉換 %1$s。請保持 DroidDeck 開啟。 此裝置無法解碼該影片。 無法匯入此影片 + 移除 %1$s? + 這會從 DroidDeck 和 Steam 的動畫資料夾中刪除該動畫。 + 已安裝 + 匯入 + SteamDeckRepo + 聲音 + 已安裝的動畫 + 瀏覽 + DroidDeck + 已匯入 + SteamDeckRepo + Steam + 點數商店 + 啟動影片報告 + 將 DroidDeck 和 Steam 掌握的啟動影片資訊儲存為文字檔,可附在錯誤報告中。 + 儲存 + 已儲存到 %1$s + 無法儲存報告。 + 音量 + 長度 + %1$d 秒 + 播放影片的秒數。超出該時長的部分會在結尾被截斷。 + 搜尋 SteamDeckRepo + 所有裝置 + Steam Deck + Steam Machine + 熱門 + 下載最多 + 最受歡迎 + 最新 + 無法連接 SteamDeckRepo + 重試 + 找不到動畫 + 安裝 + 使用 + 使用中 + 已安裝 %1$s + 無法下載該動畫 + 上一頁 + 下一頁 + 第 %1$d 頁 + 清除 + 前往頁面 + 預覽 + 無法播放此預覽 Luma Ultra 相機 — 實驗性功能 Joy-Con 位置追踪 使用智慧眼鏡攝影機定位已配對的 Joy-Con 控制器。旋轉和按鍵輸入來自 Joy-Con 控制器。當您的手腕被遮住時,系統會估算位置。此功能會取代徒手操作的控制器,並暫停手機相機追蹤。連接或重設追蹤中心時,請將 Joy-Con 控制器指向前方。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9eacff4a1..dd6732156 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2129,7 +2129,7 @@ 大小 儲存 還原預設 - 顯示在遊戲背景上,與 Steam 的繪製方式一致。實際位置可能相差幾個像素。 + 確切位置可能相差幾個像素。 此遊戲尚無標誌。請在標誌中選擇一個,即可在此檢視。 無法儲存標誌位置。請再試一次。 背景 @@ -2654,7 +2654,7 @@ 開機動畫 影片 DroidDeck · %1$d fps - 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的啟動動畫優先。 + 60 fps 較流暢,但 Steam 啟動時會使用較多 CPU。在 Steam 本身設定中選擇的動畫也會顯示在這裡。 聲音 自訂影片 MP4、MOV、MKV 或 WebM。其他格式及超過 %1$d MB 的檔案會在裝置上轉換為 720p WebM,最長 %2$d 秒。 @@ -2668,6 +2668,51 @@ 正在為 Steam 轉換 %1$s。請保持 DroidDeck 開啟。 此裝置無法解碼該影片。 無法匯入此影片 + 移除 %1$s? + 這會從 DroidDeck 和 Steam 的動畫資料夾中刪除該動畫。 + 已安裝 + 匯入 + SteamDeckRepo + 聲音 + 已安裝的動畫 + 瀏覽 + DroidDeck + 已匯入 + SteamDeckRepo + Steam + 點數商店 + 啟動影片報告 + 將 DroidDeck 和 Steam 掌握的啟動影片資訊儲存為文字檔,可附在錯誤報告中。 + 儲存 + 已儲存到 %1$s + 無法儲存報告。 + 音量 + 長度 + %1$d 秒 + 播放影片的秒數。超出該時長的部分會在結尾被截斷。 + 搜尋 SteamDeckRepo + 所有裝置 + Steam Deck + Steam Machine + 熱門 + 下載最多 + 最受歡迎 + 最新 + 無法連線至 SteamDeckRepo + 重試 + 找不到動畫 + 安裝 + 使用 + 使用中 + 已安裝 %1$s + 無法下載該動畫 + 上一頁 + 下一頁 + 第 %1$d 頁 + 清除 + 前往頁面 + 預覽 + 無法播放此預覽 Luma Ultra 相機 — 實驗性功能 Joy-Con 位置追踪 使用智慧眼鏡攝影機定位已配對的 Joy-Con 控制器。旋轉和按鍵輸入來自 Joy-Con 控制器。當您的手腕被遮住時,系統會估算位置。此功能會取代徒手操作的控制器,並暫停手機相機追蹤。連接或重設追蹤中心時,請將 Joy-Con 控制器指向前方。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10d12a620..03e52a56c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2212,7 +2212,7 @@ Size Save Reset to default - Shown on the game\'s background, the way Steam draws it. The exact spot can differ by a few pixels. + This exact spot can differ by a few pixels. This game has no logo yet. Choose one under Logo to see it here. Couldn\'t save the logo position. Try again. Background @@ -2763,12 +2763,14 @@ Boot movie Movie DroidDeck · %1$d fps - 60 fps looks smoother but uses more CPU while Steam starts. A startup movie picked in Steam\'s own settings takes priority. + 60 fps looks smoother but uses more CPU while Steam starts. A movie picked in Steam\'s own settings shows up here too. Sound Your own movie MP4, MOV, MKV or WebM. Other formats and files over %1$d MB are converted to 720p WebM on the device, up to %2$d seconds. Import Remove + Remove %1$s? + This deletes the movie from DroidDeck and from Steam\'s movies folder. Choose a boot movie %1$s is now the boot movie %1$s is now the boot movie, without sound @@ -2777,6 +2779,49 @@ Converting %1$s for Steam. Keep DroidDeck open. This device cannot decode that video. Could not import that movie + Installed + Import + SteamDeckRepo + Sound + Installed movies + Browse + DroidDeck + Imported + SteamDeckRepo + Steam + Points Shop + Boot movie report + Saves what DroidDeck and Steam know about boot movies to a text file you can attach to a bug report. + Save + Saved to %1$s + Could not save the report. + Volume + Length + %1$d s + How many seconds of the movie play. Anything longer is cut off at the end. + Search SteamDeckRepo + All devices + Steam Deck + Steam Machine + Trending + Most downloaded + Most liked + Newest + Could not reach SteamDeckRepo + Retry + No movies found + Install + Use + In use + %1$s installed + Could not download that movie + Previous + Next + Page %1$d + Clear + Go to page + Preview + This preview could not be played Steady hand aim Aim from your shoulder through your palm. Turn off for wrist-controlled rotation. Spatial setup diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt new file mode 100644 index 000000000..ca68f5867 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieLibraryTest.kt @@ -0,0 +1,310 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BootMovieLibraryTest { + private val app get() = RuntimeEnvironment.getApplication() + private val movies get() = BootMovieLibrary.moviesDir(app) + + @Before fun clear() { + app.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + File(app.filesDir, "boot-movie").deleteRecursively() + movies.deleteRecursively() + BootMovieLibrary.libraryDir(app).deleteRecursively() + File(BootMovieLibrary.steamRoot(app), "steamui").deleteRecursively() + File(BootMovieLibrary.steamRoot(app), "appcache").deleteRecursively() + BootMovieLibrary.forgetScan() + BootMovieLibrary.steamConfig(app).delete() + } + + private fun movie(name: String) = File(movies, name).also { it.parentFile!!.mkdirs(); it.writeText("movie") } + + /** A movie DroidDeck itself imported: kept in the library, copied into Steam's folder and noted in the list. */ + private fun imported(title: String, source: String = BootMovieLibrary.SOURCE_IMPORT, repo: String? = null): String { + val staged = File(BootMovieLibrary.libraryDir(app), ".t-${System.nanoTime()}-${Thread.currentThread().id}.staged").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + return BootMovieLibrary.commit(app, staged, title, source, repo)!! + } + + private fun steamPicksAt(path: String) { + BootMovieLibrary.steamConfig(app).also { it.parentFile!!.mkdirs() }.writeText( + "\"InstallConfigStore\"\n{\n\t\"StartupMovie\"\n\t{\n\t\t\"MovieID\"\t\t\"x\"\n\t\t\"LocalPath\"\t\t\"$path\"\n\t}\n}\n", + ) + } + + private fun steamPicks(name: String) = steamPicksAt("/uioverrides/movies/$name") + + @Test fun theActiveX64ClientsOwnMoviesAreListedAndRemovedToo() { + SessionPrefs.setSteamClientArch(app, "x86_64") + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Mine.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + movie("Main.webm") + val entries = BootMovieLibrary.list(app).filter { it.id.startsWith(BootMovieLibrary.PREFIX) } + assertEquals(listOf("file:Main.webm", "file:Mine.webm"), entries.map { it.id }) + assertTrue(entries.all { it.removable }) + assertEquals("Mine.webm", BootMovieLibrary.file(app, "file:Mine.webm")?.name) + assertEquals("Mine (2).webm", BootMovieLibrary.freeName(app, "Mine")) + assertTrue(BootMovieLibrary.remove(app, "file:Mine.webm")) + assertFalse(File(x64, "Mine.webm").exists()) + } finally { + x64.deleteRecursively() + } + } + + @Test fun removingAMovieKeepsTheSameNamedOneInTheOtherSteamFolder() { + SessionPrefs.setSteamClientArch(app, "x86_64") + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Intro.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + movie("Intro.webm") + assertTrue(BootMovieLibrary.remove(app, "file:Intro.webm")) + assertFalse(File(movies, "Intro.webm").exists()) + assertTrue(File(x64, "Intro.webm").isFile) + assertTrue(BootMovieLibrary.list(app).any { it.id == "file:Intro.webm" }) + assertTrue(BootMovieLibrary.remove(app, "file:Intro.webm")) + assertFalse(File(x64, "Intro.webm").exists()) + } finally { + x64.deleteRecursively() + } + } + + @Test fun aSavedChoiceKeepsMeaningTheSameFileWhicheverClientRuns() { + val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "config/uioverrides/movies") + try { + File(x64, "Intro.webm").also { it.parentFile!!.mkdirs(); it.writeText("other") } + val main = movie("Intro.webm") + SessionPrefs.setSteamClientArch(app, "x86_64") + assertEquals(main.path, BootMovieLibrary.file(app, "file:Intro.webm")?.path) + SessionPrefs.setSteamClientArch(app, "arm64") + assertEquals(main.path, BootMovieLibrary.file(app, "file:Intro.webm")?.path) + } finally { + x64.deleteRecursively() + } + } + + @Test fun listsTheDroidDeckMoviesAndEveryWebmButNotTheStagedCopies() { + movie("Intro.webm") + movie("bigpicture_startup.webm") + movie("steam_os_startup.webm") + movie("notes.txt") + movie(".hidden.webm") + val ids = BootMovieLibrary.list(app).map { it.id } + assertEquals(listOf(BootMovie.DROIDDECK_30, BootMovie.DROIDDECK_60, "file:Intro.webm"), ids) + } + + @Test fun usesTheTitleItWasRegisteredWithAndFallsBackToTheFileName() { + imported("Fancy", BootMovieLibrary.SOURCE_REPO, "id1"); movie("b.webm") + val list = BootMovieLibrary.list(app).associateBy { it.id } + assertEquals("Fancy", list["file:Fancy.webm"]!!.title) + assertEquals(BootMovieLibrary.SOURCE_REPO, list["file:Fancy.webm"]!!.source) + assertTrue(list["file:Fancy.webm"]!!.removable) + assertTrue(list["file:b.webm"]!!.removable) + assertEquals("b", list["file:b.webm"]!!.title) + assertEquals(BootMovieLibrary.SOURCE_IMPORT, list["file:b.webm"]!!.source) + assertEquals(setOf("id1"), BootMovieLibrary.installedRepoIds(app)) + assertEquals("Fancy.webm", BootMovieLibrary.nameForRepo(app, "id1")) + } + + @Test fun aChosenFileMovieIsKeptAndFallsBackOnceItIsGone() { + val name = imported("Mine") + BootMovie.select(app, "file:$name") + assertEquals("file:$name", BootMovie.choice(app)) + assertTrue(BootMovieLibrary.remove(app, "file:$name")) + assertEquals(BootMovie.DROIDDECK_30, BootMovie.choice(app)) + assertFalse(File(movies, name).exists()) + assertFalse(File(BootMovieLibrary.libraryDir(app), name).exists()) + } + + @Test fun aMovieInSteamsCustomFolderCanBeRemovedEvenWithoutANote() { + movie("by-hand.webm") + assertTrue(BootMovieLibrary.list(app).first { it.id == "file:by-hand.webm" }.removable) + assertTrue(BootMovieLibrary.remove(app, "file:by-hand.webm")) + assertFalse(File(movies, "by-hand.webm").exists()) + } + + @Test fun steamsOwnMoviesAreNeverDeleted() { + val own = File(BootMovieLibrary.steamRoot(app), "steamui/movies/deck_startup.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.id == "steam:/movies/deck_startup.webm" } + assertFalse(entry.removable) + assertFalse(BootMovieLibrary.remove(app, entry.id)) + assertTrue(own.exists()) + } + + @Test fun anImportSurvivesTheRuntimeBeingReset() { + val name = imported("Keep") + movies.deleteRecursively() + assertTrue(BootMovieLibrary.list(app).any { it.id == "file:$name" }) + assertTrue(File(movies, name).isFile) + } + + @Test fun importsAtTheSameTimeAllLandInTheList() { + val names = java.util.Collections.synchronizedList(ArrayList()) + (1..8).map { n -> Thread { names.add(imported("Movie")) } }.onEach { it.start() }.forEach { it.join() } + assertEquals(8, names.toSet().size) + assertEquals(8, BootMovieLibrary.list(app).count { it.source == BootMovieLibrary.SOURCE_IMPORT }) + } + + @Test fun steamIsPointedBackAtItsDefaultWhenTheStagedMovieIsGone() { + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertNull(SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun aMissingConfigNeedsNoReset() { + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), true)) + } + + @Test fun aStagedMovieThatIsThereIsLeftAlone() { + movie("bigpicture_startup.webm") + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertFalse(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun aPendingPickResetsASteamMovieThatWasRemoved() { + steamPicksAt("/uioverrides/movies/gone.webm") + assertFalse(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), false)) + assertTrue(SessionFiles.resetMissingStartupMovie(BootMovieLibrary.steamConfig(app), true)) + assertNull(SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(app))) + } + + @Test fun onlyTheThreeStartupMoviesOfSteamAreListed() { + listOf("deck_startup", "oled_startup", "startup_machine", "controller_generic_thumbstick_move").forEach { + File(BootMovieLibrary.steamRoot(app), "steamui/movies/$it.webm").also { f -> f.parentFile!!.mkdirs(); f.writeText("movie") } + } + val ids = BootMovieLibrary.list(app).map { it.id }.filter { it.startsWith("steam:") } + assertEquals( + listOf("steam:/movies/deck_startup.webm", "steam:/movies/oled_startup.webm", "steam:/movies/startup_machine.webm"), + ids.sorted(), + ) + } + + @Test fun aMovieBoughtInThePointsShopIsListedAndAdopted() { + val file = File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/Bought.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.id == "steam:/appcache/startupmovies/Bought.webm" } + assertFalse(entry.removable) + assertEquals(BootMovieLibrary.SOURCE_POINTSHOP, entry.source) + steamPicksAt("/appcache/startupmovies/Bought.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("steam:/appcache/startupmovies/Bought.webm", BootMovie.choice(app)) + assertEquals(file, BootMovie.currentFile(app)) + assertNull(BootMovieLibrary.file(app, "steam:/../x.webm")) + } + + @Test fun aBoughtMovieNamedByIdAndHashIsShownByItsId() { + File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/39900045022_590371b7f10882d09ac59e5d3691b462c0091a6d.webm") + .also { it.parentFile!!.mkdirs(); it.writeText("movie") } + val entry = BootMovieLibrary.list(app).first { it.source == BootMovieLibrary.SOURCE_POINTSHOP } + assertEquals("#39900045022", entry.title) + } + + @Test fun theReportNamesWhatStageDidAndWhereMoviesAre() { + File(BootMovieLibrary.steamRoot(app), "appcache/startupmovies/1_ab.webm").also { it.parentFile!!.mkdirs(); it.writeText("movie") } + BootMovieReport.note(app, "stage test") + val report = BootMovieReport.build(app) + assertTrue(report.contains("== DroidDeck state")) + assertTrue(report.contains("startupmovies/1_ab.webm")) + assertTrue(report.contains("stage test")) + } + + @Test fun pathTricksAreNotMovies() { + movie("a.webm") + assertNull(BootMovieLibrary.file(app, "file:../a.webm")) + assertNull(BootMovieLibrary.file(app, "file:bigpicture_startup.webm")) + assertFalse(BootMovieLibrary.remove(app, "file:../a.webm")) + } + + @Test fun freeNameNeverReusesAFileOrAStagedName() { + movie("Intro.webm") + assertEquals("Intro (2).webm", BootMovieLibrary.freeName(app, "Intro")) + assertEquals("movie.webm", BootMovieLibrary.freeName(app, "///")) + } + + @Test fun aMovieChosenInsideSteamIsAdoptedOnce() { + movie("a.webm"); movie("b.webm") + BootMovie.select(app, "file:a.webm") + steamPicks("b.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("file:b.webm", BootMovie.choice(app)) + // Picking another one here is not undone by the same Steam setting. + BootMovie.select(app, "file:a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + // A new pick inside Steam is adopted again. + movie("c.webm") + steamPicks("c.webm") + assertTrue(BootMovie.syncFromSteam(app)) + assertEquals("file:c.webm", BootMovie.choice(app)) + } + + @Test fun aSteamPickIsTakenOverWithoutBeingStagedOverSteamsOwnSetting() { + movie("a.webm") + BootMovie.select(app, "file:a.webm") + SessionPrefs.setBootMovieApplyNext(app, false) + steamPicks("a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + assertFalse(SessionPrefs.bootMovieApplyNext(app)) + } + + @Test fun theStagedCopyNamedInSteamsConfigIsNotAPick() { + movie("a.webm") + BootMovie.select(app, "file:a.webm") + steamPicksAt(SessionFiles.BOOT_MOVIE_PATH) + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + } + + @Test fun aBoughtMovieIsShownUnderItsPointsShopTitle() { + File(BootMovieLibrary.steamRoot(app), "config/communityitemscache/startupmovies/39900045022_ab12.webm") + .also { it.parentFile!!.mkdirs(); it.writeText("movie") } + File(BootMovieLibrary.steamRoot(app), "userdata/1/config/localconfig.vdf").also { it.parentFile!!.mkdirs() }.writeText( + "\"GetStartupMovies\"\t\t\"[{\\\"communityitemid\\\":\\\"39900045022\\\",\\\"image_small\\\":\\\"x.png\\\",\\\"name\\\":\\\"Turret Factory\\\",\\\"item_title\\\":\\\"Turret Factory\\\"}]\"\n", + ) + val entry = BootMovieLibrary.list(app).first { it.source == BootMovieLibrary.SOURCE_POINTSHOP } + assertEquals("Turret Factory", entry.title) + } + + @Test fun notesKeptInSteamsMoviesFolderByAnEarlierVersionAreTakenOver() { + movie("Old.webm") + File(movies, ".droiddeck-movies.json").writeText("{\"Old.webm\":{\"title\":\"Old one\",\"source\":\"repo\",\"repo\":\"r1\"}}") + val entry = BootMovieLibrary.list(app).first { it.id == "file:Old.webm" } + assertEquals("Old one", entry.title) + assertEquals(BootMovieLibrary.SOURCE_REPO, entry.source) + assertEquals(setOf("r1"), BootMovieLibrary.installedRepoIds(app)) + assertFalse(File(movies, ".droiddeck-movies.json").exists()) + } + + @Test fun aPickMadeHereBeatsAnOlderSteamPickThatWasNeverSeen() { + movie("a.webm"); movie("b.webm") + steamPicks("b.webm") + BootMovie.select(app, "file:a.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals("file:a.webm", BootMovie.choice(app)) + } + + @Test fun steamPointingAtAMovieThatIsNotThereChangesNothing() { + steamPicks("gone.webm") + assertFalse(BootMovie.syncFromSteam(app)) + assertEquals(BootMovie.DROIDDECK_30, BootMovie.choice(app)) + } + + @Test fun volumeAndLengthDefaultToTheWholeMovieAtFullVolume() { + assertEquals(100, SessionPrefs.bootMovieVolume(app)) + assertEquals(0, SessionPrefs.bootMovieSeconds(app)) + SessionPrefs.setBootMovieVolume(app, 40) + SessionPrefs.setBootMovieSeconds(app, 3) + assertEquals(40, SessionPrefs.bootMovieVolume(app)) + assertEquals(3, SessionPrefs.bootMovieSeconds(app)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt new file mode 100644 index 000000000..e9341851d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMoviePlanTest.kt @@ -0,0 +1,59 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.session.BootMovie.Step +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BootMoviePlanTest { + @Test + fun fullVolumeWithoutLimitPassesTheSourceThrough() { + val plan = BootMovie.plan(100, 0) + assertTrue(plan.passthrough) + assertTrue(plan.steps.isEmpty()) + } + + @Test + fun fullVolumeWithLimitKeepsSoundAndFallsBackToTheSource() { + val plan = BootMovie.plan(100, 5) + assertFalse(plan.passthrough) + assertEquals(listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), plan.steps) + assertTrue(plan.sourceOnFailure) + } + + @Test + fun silentGoesWithoutSoundAndNeverFallsBackToTheSource() { + val plan = BootMovie.plan(0, 0) + assertEquals(listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), plan.steps) + assertFalse(plan.sourceOnFailure) + } + + @Test + fun partialVolumeDegradesToSilenceNotToFullVolume() { + val plan = BootMovie.plan(40, 0) + assertEquals(listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), plan.steps) + assertFalse(plan.passthrough) + assertFalse(plan.sourceOnFailure) + } + + @Test + fun framesBeyondFullHdAreOversizedInEitherOrientation() { + assertTrue(BootMovie.oversized(3840, 2160)) + assertTrue(BootMovie.oversized(2160, 3840)) + assertFalse(BootMovie.oversized(1920, 1080)) + assertFalse(BootMovie.oversized(1280, 800)) + assertFalse(BootMovie.oversized(1080, 1920)) + } + + @Test + fun anOversizedMovieIsAlwaysConvertedAndNeverFallsBackToTheSource() { + for (volume in listOf(0, 40, 100)) { + val plan = BootMovie.oversizedPlan(volume) + assertFalse(plan.passthrough) + assertFalse(plan.sourceOnFailure) + } + assertEquals(listOf(Step.TRANSCODE_SILENT), BootMovie.oversizedPlan(0).steps) + assertEquals(listOf(Step.TRANSCODE_AUDIO), BootMovie.oversizedPlan(60).steps) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt new file mode 100644 index 000000000..bb31c7eaa --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/BootMovieTranscoderTest.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class BootMovieTranscoderTest { + @Test fun scaleQuietsAndCutsTheSoundAtTheSameTime() { + val pcm = ShortArray(48_000 * 2) { 1000 } + assertSame(pcm, BootMovieTranscoder.scale(pcm, 2, 1f, Long.MAX_VALUE)) + val quieter = BootMovieTranscoder.scale(pcm, 2, 0.5f, Long.MAX_VALUE) + assertEquals(pcm.size, quieter.size) + assertEquals(500.toShort(), quieter[0]) + val cut = BootMovieTranscoder.scale(pcm, 2, 1f, 500_000L) + assertEquals(24_000 * 2, cut.size) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt new file mode 100644 index 000000000..c14fc0231 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/StartupMovieConfigTest.kt @@ -0,0 +1,66 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class StartupMovieConfigTest { + @get:Rule val folder = TemporaryFolder() + + private val older = "\t\t\t\t\"StartupMovie\"\n\t\t\t\t{\n\t\t\t\t\t\"MovieID\"\t\t\"0\"\n\t\t\t\t\t\"LocalPath\"\t\t\"%s\"\n\t\t\t\t}\n" + private val customization = "\t\"Customization\"\n\t{\n\t\t\"StartupMovie\"\n\t\t{\n\t\t\t\"MovieID\"\t\t\"%s\"\n\t\t\t\"LocalPath\"\t\t\"%s\"\n\t\t}\n\t}\n" + + private fun config(olderPath: String?, id: String?, path: String?): File { + val text = StringBuilder("\"InstallConfigStore\"\n{\n\t\"Software\"\n\t{\n\t\t\"Valve\"\n\t\t{\n\t\t\t\"Steam\"\n\t\t\t{\n") + if (olderPath != null) text.append(older.format(olderPath)) + text.append("\t\t\t}\n\t\t}\n\t}\n") + if (id != null && path != null) text.append(customization.format(id, path)) + text.append("}\n") + return folder.newFile().also { it.writeText(text.toString()) } + } + + @Test fun steamReadsTheMovieUnderCustomizationNotTheOlderOne() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "39900045022", "/communityitemscache/startupmovies/39900045022_ab.webm") + assertEquals("/communityitemscache/startupmovies/39900045022_ab.webm", SessionFiles.startupMoviePath(file)) + } + + @Test fun aPickIsWrittenWhereSteamReadsItAndTheOlderPlaceIsKeptInStep() { + val file = config("/uioverrides/movies/x.webm", "39900045022", "/communityitemscache/startupmovies/39900045022_ab.webm") + assertTrue(SessionFiles.ensureStartupMovieDefault(file, force = true)) + val text = file.readText() + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(file)) + assertFalse(text.contains("39900045022")) + assertEquals(2, Regex("\"LocalPath\"\\s+\"" + Regex.escape(SessionFiles.BOOT_MOVIE_PATH) + "\"").findAll(text).count()) + } + + @Test fun aPickMadeInSteamIsLeftAloneWithoutAPendingPick() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "0", "/uioverrides/movies/Undertale.webm") + assertFalse(SessionFiles.ensureStartupMovieDefault(file, force = false)) + assertEquals("/uioverrides/movies/Undertale.webm", SessionFiles.startupMoviePath(file)) + } + + @Test fun aConfigWithoutCustomizationGetsOne() { + val file = config(null, null, null) + assertTrue(SessionFiles.ensureStartupMovieDefault(file, force = false)) + val text = file.readText() + assertTrue(text.contains("\"Customization\"")) + assertEquals(SessionFiles.BOOT_MOVIE_PATH, SessionFiles.startupMoviePath(file)) + assertEquals(2, Regex("\"StartupMovie\"").findAll(text).count()) + } + + @Test fun aMissingStagedMovieResetsTheSectionSteamReads() { + val file = config(SessionFiles.BOOT_MOVIE_PATH, "0", SessionFiles.BOOT_MOVIE_PATH) + assertTrue(SessionFiles.resetMissingStartupMovie(file, false)) + assertNull(SessionFiles.startupMoviePath(file)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt new file mode 100644 index 000000000..4458985f8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDeckRepoTest.kt @@ -0,0 +1,92 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SteamDeckRepoTest { + private val sample = """ + {"posts":[ + {"id":"p1","title":"Cool","content":"c","user":{"steam_name":"Ann"},"thumbnail":"https://x/t.png","video":"https://x/v.webm", + "video_duration":7,"likes":3,"downloads":9,"devices":["steam_deck"],"url":"https://steamdeckrepo.com/post/p1"}, + {"id":"p2","title":"","video":"http://insecure/v.webm"}, + {"id":"","video":"https://x/v.webm"}, + {"id":"p3","video":"https://x/v3.webm","thumbnail":"ftp://bad"} + ],"currentSort":"trending"} + """.trimIndent() + + @Test fun parsesPostsAndSkipsBrokenOnes() { + val posts = SteamDeckRepo.parse(sample)!! + assertEquals(listOf("p1", "p3"), posts.map { it.id }) + val first = posts[0] + assertEquals("Cool", first.title) + assertEquals("Ann", first.author) + assertEquals(7, first.seconds) + assertEquals(listOf("steam_deck"), first.devices) + assertEquals("p3", posts[1].title) + assertEquals("", posts[1].thumbnail) + } + + @Test fun notJsonIsNotAPage() { + assertNull(SteamDeckRepo.parse("")) + assertNull(SteamDeckRepo.parse("{}")) + } + + @Test fun buildsTheSameAddressesAsTheWebsite() { + assertEquals("https://steamdeckrepo.com/api/posts?sort=trending", SteamDeckRepo.url(SteamDeckRepo.Sort.TRENDING, null, "")) + assertEquals( + "https://steamdeckrepo.com/api/posts?sort=downloads-desc&device=steam_machine&search=a%20b%26c&page=3", + SteamDeckRepo.url(SteamDeckRepo.Sort.DOWNLOADS, "steam_machine", " a b&c ", 3), + ) + } + + @Test fun readsAreCutOffAtTheLimit() { + assertEquals(10, SteamDeckRepo.readLimited(ByteArray(10).inputStream(), 10)!!.size) + assertNull(SteamDeckRepo.readLimited(ByteArray(11).inputStream(), 10)) + } + + @Test fun jsonNullsAreEmptyTextNotTheWordNull() { + val posts = SteamDeckRepo.parse( + """{"posts":[{"id":"p9","title":null,"content":null,"user":{"steam_name":null},"thumbnail":null,"video":"https://x/v.webm","url":null}]}""", + )!! + val post = posts.single() + assertEquals("p9", post.title) + assertEquals("", post.content) + assertEquals("", post.author) + assertEquals("", post.thumbnail) + assertEquals("", post.url) + assertTrue(SteamDeckRepo.parse("""{"posts":[{"id":null,"video":"https://x/v.webm"}]}""")!!.isEmpty()) + } + + @Test fun aPageHoldsTwelveMovies() { + assertEquals(12, SteamDeckRepo.PAGE_SIZE) + } + + private fun lastPageOf(pages: Int, tail: Int = SteamDeckRepo.PAGE_SIZE) = SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "") { p -> + when { + p < pages -> SteamDeckRepo.PAGE_SIZE + p == pages -> tail + else -> 0 + } + } + + @Test fun theLastPageIsFoundByAskingForPages() { + for (pages in listOf(2, 3, 7, 8, 9, 33, 100, 500)) assertEquals(pages, lastPageOf(pages)) + } + + @Test fun aShortLastPageEndsTheSearchAtOnce() { + assertEquals(1, lastPageOf(1, tail = 5)) + assertEquals(5, lastPageOf(5, tail = 3)) + assertEquals(37, lastPageOf(37, tail = 1)) + } + + @Test fun aSearchWithNothingHasOnePageAndAnUnreachableSiteHasNone() { + assertEquals(1, SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { 0 }) + assertNull(SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { null }) + assertNull(SteamDeckRepo.lastPage(SteamDeckRepo.Sort.TRENDING, null, "x") { p -> if (p < 4) SteamDeckRepo.PAGE_SIZE else null }) + } +} diff --git a/docs/development/boot-movies.md b/docs/development/boot-movies.md new file mode 100644 index 000000000..1fa2dd04b --- /dev/null +++ b/docs/development/boot-movies.md @@ -0,0 +1,38 @@ +# Boot movies + +Settings → Boot movie has four tabs. + +- **Installed**: "Browse" lists every movie Steam can play: DroidDeck's two, plus every `.webm` in Steam's + `config/uioverrides/movies` folder (imported, installed from SteamDeckRepo, or put there by Steam). The list is re-read + every two seconds, so a movie picked or removed inside Steam shows up, and a movie picked inside Steam becomes the choice. +- **Import**: the same importer as before (copy, remux or on-device transcode to VP8/Opus). The result is written into + Steam's movies folder, so Steam lists it as well. +- **SteamDeckRepo**: browses `https://steamdeckrepo.com/api/posts` (`sort`, `device`, `search`, `page`; 12 posts a page). + Installing downloads the movie through the site's own download address and imports it like any other movie. Tapping a + thumbnail plays the movie from the site (`MediaPlayer` on a `TextureView`) before it is installed. The site does not + say how many pages there are, so the last page is found by asking for pages until one comes back empty or short; the + pager shows the current page (tap it to type another), three pages either side, the first and last page numbers, and + Previous and Next. Search and "go to page" use a small dialog with the app's native text field (`AdbTextField`), + because that field is what brings up the Android keyboard in these screens. +- **Sound**: sound on/off, volume, and how many seconds of the movie play. Anything other than full volume and the whole + movie is built into `files/boot-movie/variants` (audio re-encoded as Opus, video copied) and staged instead. + +Imported and installed movies are kept in `files/boot-movie/library` (with their list, `files/boot-movie/movies.json`) and +copied into Steam's movies folder, so resetting the Linux runtime does not lose them; the copy is put back when it is +missing. The list is changed under one lock, so imports at the same time cannot overwrite each other. Every movie in Steam's custom movies +folder (`config/uioverrides/movies`) can be removed after a confirmation, whether DroidDeck put it there or it was added by +hand or by Steam's own picker. Steam's own three startup movies (`deck_startup`, `oled_startup`, `startup_machine` in +`steamui/movies`) and points shop movies (any other `.webm` below Steam's `config`, `userdata`, `appcache` and `steamui` +folders, plus whatever path `StartupMovie.LocalPath` names) are listed as Steam movies, adopted when picked in Steam, and +never deleted. Bought movies live in `config/communityitemscache/startupmovies/_.webm`; their titles +come from the `GetStartupMovies` list in `userdata//config/localconfig.vdf`. Other videos in `steamui/movies` are not startup movies and are not listed. +Only one processed copy (volume or length change) is kept in `files/boot-movie/variants`; a volume that cannot be applied +plays without sound rather than louder, and plays nothing if that fails too. Covers are cached in `files/boot-movie/thumbs`. Steam reads its startup movie from the `StartupMovie` section inside the `Customization` section of +`config/config.vdf` (`MovieID`, and `LocalPath` relative to `config`; a Points Shop movie has its item number as +`MovieID`). The `StartupMovie` section under `Software/Valve/Steam` is where older clients read it, and DroidDeck writes both. +Selecting a movie here sets `bootMovieApplyNext`, so the next session stages the movie as `bigpicture_startup.webm` and +points both sections at it, over a movie Steam had picked; otherwise a movie picked in Steam is left alone and taken over as +the choice here without being staged over Steam's own setting. Settings > Boot movie > Installed has a report button that +writes what Steam and DroidDeck know to `Download/DroidDeck/boot-movie-report.txt`; it only shows while Debugging tools is on. +A movie larger than 1920x1080 (4K and the like) is never copied or remuxed as it is: import and staging always convert it +(the converter scales to at most 1280x720), because such movies have made Steam crash while it starts. From d5e87a8c7ca7a74d6d7d8dcd48d37162f74841cc Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:38:14 +0300 Subject: [PATCH 050/156] fix: restore Factorio ARM64 controller input through readv Steam's ARM64 overlay can fall back from read() to readv(). Route registered controller reads through the existing private ring reader so the game receives input events instead of backing-file bytes. Preserve ordinary descriptor passthrough and evdev per-vector reads. --- app/src/main/cpp/fakeinput_steam.cpp | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 486b72076..2efba76ce 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3577,7 +3577,7 @@ EXPORT int close(int fd) { static constexpr int kMaxRingWaitMs = 2; static constexpr int kLowLatencyRingWaitUs = 250; -EXPORT ssize_t read(int fd, void *buf, size_t count) { +static ssize_t read_input(int fd, void *buf, size_t count) { if (!watched(fd)) return syscall(SYS_read, fd, buf, count); std::unique_lock guard(controller_mutex()); auto controller = controller_map().find(fd); @@ -3673,6 +3673,45 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { } } +EXPORT ssize_t read(int fd, void *buf, size_t count) { + return read_input(fd, buf, count); +} + +EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) { + if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt); + if (iovcnt < 0 || iovcnt > IOV_MAX) { + errno = EINVAL; + return -1; + } + if (iovcnt && !iov) { + errno = EFAULT; + return -1; + } + size_t size = 0; + for (int i = 0; i < iovcnt; ++i) { + if (iov[i].iov_len > static_cast(SSIZE_MAX) - size) { + errno = EINVAL; + return -1; + } + size += iov[i].iov_len; + if (iov[i].iov_len && !iov[i].iov_base) { + errno = EFAULT; + return -1; + } + } + + // evdev's readv calls its read handler per vector; Steam's ARM64 overlay uses this path too. + ssize_t total = 0; + for (int i = 0; i < iovcnt; ++i) { + if (!iov[i].iov_len) continue; + ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len); + if (got < 0) return total ? total : -1; + total += got; + if (static_cast(got) != iov[i].iov_len) break; + } + return total; +} + EXPORT ssize_t write(int fd, const void *buf, size_t count) { static auto my_write = reinterpret_cast(dlsym(RTLD_NEXT, "write")); From 4f799ea59c9555672d7695444d53254801e0c754 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 09:38:14 -0400 Subject: [PATCH 051/156] fix: open Flatpak apps in renamed app variants (#1192) Flatpak starts bwrap with an empty environment, so the bwrap shim lost DROIDDECK_BWRAP_SOCKET and dialled the default package's socket. The session now writes the socket name to /etc/droiddeck/bwrap-socket and the shim reads it when the variable is missing. Fixes #1092 --- README.md | 2 ++ .../launcher/session/SessionFiles.kt | 8 +++++ .../overlay/usr/local/bin/droiddeck-bwrap | 16 ++++++++- tools/tests/test_bwrap_socket.py | 36 +++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tools/tests/test_bwrap_socket.py diff --git a/README.md b/README.md index c8793fe6d..5ead27303 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol de Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo. Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md). + +The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 3b0af016d..506d5b772 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -5,6 +5,7 @@ import android.os.Environment import android.util.Log import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LegacyNames import com.droiddeck.launcher.runtime.LinuxRuntime @@ -208,6 +209,13 @@ object SessionFiles { if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) { Log.e(TAG, "could not write steam-compatibility-label") } + val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() } + val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged") + if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n") + || !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) { + stagedSocket.delete() + Log.e(TAG, "could not write bwrap-socket") + } BootMovie.syncFromSteam(context) val startupMovie = BootMovie.prepare(context) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap index 909056fcd..2e84a695c 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap @@ -7,7 +7,21 @@ import socket import struct import sys -SOCKET = "\0" + os.environ.get("DROIDDECK_BWRAP_SOCKET", "com.droiddeck.launcher.bwrap") +SOCKET_FILE = "/etc/droiddeck/bwrap-socket" + + +def socket_name(): + name = os.environ.get("DROIDDECK_BWRAP_SOCKET", "") + if not name: + try: + with open(SOCKET_FILE, encoding="utf-8") as f: + name = f.read().strip() + except OSError: + pass + return "\0" + (name or "com.droiddeck.launcher.bwrap") + + +SOCKET = socket_name() VERSION = "bubblewrap 0.11.0 (droiddeck)" NO_ARG = { diff --git a/tools/tests/test_bwrap_socket.py b/tools/tests/test_bwrap_socket.py new file mode 100644 index 000000000..7fa782c8a --- /dev/null +++ b/tools/tests/test_bwrap_socket.py @@ -0,0 +1,36 @@ +import os +import runpy +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +BWRAP = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-bwrap" + + +class BwrapSocketTest(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, True) + self.shim = runpy.run_path(str(BWRAP)) + self.shim["socket_name"].__globals__["SOCKET_FILE"] = str(self.dir / "bwrap-socket") + + def name(self, env): + with mock.patch.dict(os.environ, env, clear=True): + return self.shim["socket_name"]() + + def test_flatpak_empty_environment_reaches_a_renamed_package(self): + (self.dir / "bwrap-socket").write_text("com.antutu.benchmark.full.bwrap\n") + self.assertEqual("\0com.antutu.benchmark.full.bwrap", self.name({})) + + def test_the_environment_still_wins(self): + (self.dir / "bwrap-socket").write_text("com.antutu.benchmark.full.bwrap\n") + self.assertEqual("\0com.tencent.ig.bwrap", self.name({"DROIDDECK_BWRAP_SOCKET": "com.tencent.ig.bwrap"})) + + def test_without_either_the_standard_package_is_asked(self): + self.assertEqual("\0com.droiddeck.launcher.bwrap", self.name({})) + + +if __name__ == "__main__": + unittest.main() From 4a1aba3c02acfe9c25384508f55ceebbb76448ce Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:42:24 +0300 Subject: [PATCH 052/156] test: cover vectored controller reads Cover readv on original and virtual controller rings, an overlay-style read-to-readv fallback, event-aligned vectors, empty vectors, nonblocking errors, and ordinary descriptor passthrough. --- tools/tests/test_fakeinput_ring.py | 140 ++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 13 deletions(-) diff --git a/tools/tests/test_fakeinput_ring.py b/tools/tests/test_fakeinput_ring.py index 3c87bd11e..42d6f85b6 100644 --- a/tools/tests/test_fakeinput_ring.py +++ b/tools/tests/test_fakeinput_ring.py @@ -18,24 +18,44 @@ READER = r""" #include #include #include +#include #include -int main(void) +static int vector_count; + +static ssize_t controller_read(int fd, void *buffer, size_t size) { + if (!vector_count) + return read(fd, buffer, size); + struct iovec vectors[3] = { + {buffer, sizeof(struct input_event)}, + {(char *)buffer + sizeof(struct input_event), 0}, + {(char *)buffer + sizeof(struct input_event), size - sizeof(struct input_event)}, + }; + if (vector_count == 1) { + vectors[0].iov_len = size; + return readv(fd, vectors, 1); + } + return readv(fd, vectors, 3); +} + +int main(int argc, char **argv) +{ + vector_count = argc > 1 ? (argv[1][0] == '1' ? 1 : 3) : 0; struct input_event events[64]; int fd = open("/dev/input/event16", O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return 1; } - while (read(fd, events, sizeof(events)) > 0) + while (controller_read(fd, events, sizeof(events)) > 0) ; puts("ready"); fflush(stdout); char go; if (read(0, &go, 1) != 1) return 1; - ssize_t got = read(fd, events, sizeof(events)); + ssize_t got = controller_read(fd, events, sizeof(events)); for (ssize_t i = 0; i < got / (ssize_t)sizeof(events[0]); i++) printf("%ld.%06ld %u %u %d\n", (long)events[i].time.tv_sec, (long)events[i].time.tv_usec, events[i].type, events[i].code, events[i].value); @@ -65,6 +85,61 @@ int main(void) return 0; } """ +READV_CHECKS = r""" +#include +#include +#include +#include +#include +#include +#include + +int main(void) +{ + struct input_event events[64]; + int fd = open("/dev/input/event16", O_RDONLY | O_NONBLOCK); + if (fd < 0) + return 1; + while (read(fd, events, sizeof(events)) > 0) + ; + struct iovec vector = {events, sizeof(events)}; + if (readv(fd, &vector, 1) != -1 || errno != EAGAIN) + return 2; + if (readv(fd, NULL, 0) != 0) + return 3; + vector.iov_len = 0; + if (readv(fd, &vector, 1) != 0) + return 4; + vector.iov_len = sizeof(struct input_event) - 1; + if (readv(fd, &vector, 1) != -1 || errno != EINVAL) + return 5; + ssize_t (*invoke)(int, const struct iovec *, int) = readv; + if (invoke(fd, NULL, -1) != -1 || errno != EINVAL) + return 6; + if (invoke(fd, NULL, 1025) != -1 || errno != EINVAL) + return 7; + if (invoke(fd, NULL, 1) != -1 || errno != EFAULT) + return 8; + int pipefd[2]; + char text[5] = {0}; + struct iovec parts[2] = {{text, 1}, {text + 1, 3}}; + if (pipe(pipefd) || write(pipefd[1], "test", 4) != 4 || + readv(pipefd[0], parts, 2) != 4 || strcmp(text, "test")) + return 9; + puts("ok"); + return 0; +} +""" +OVERLAY_READ = r""" +#include +#include + +ssize_t read(int fd, void *buffer, size_t size) +{ + struct iovec vector = {buffer, size}; + return readv(fd, &vector, 1); +} +""" def can_build(bits): @@ -82,15 +157,16 @@ class FakeinputRingTest(unittest.TestCase): self.tmp = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, self.tmp, True) - def ring(self): + def ring(self, virtual=True): hooks = self.tmp / "hooks" hooks.mkdir() - (hooks / "event16").touch() - ring = bytearray(HEADER + CAPACITY * EVENT + DESCRIPTION) + (hooks / ("event16" if virtual else "event0")).touch() + ring = bytearray(HEADER + CAPACITY * EVENT + (DESCRIPTION if virtual else 0)) struct.pack_into(" Date: Tue, 20 Oct 2026 16:43:35 +0300 Subject: [PATCH 053/156] build: require the controller readv export Check that packaged libfakeinput libraries export readv alongside the existing read hook. --- tools/linuxfs/check-preloads.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/linuxfs/check-preloads.sh b/tools/linuxfs/check-preloads.sh index 78021c440..30f94a96a 100644 --- a/tools/linuxfs/check-preloads.sh +++ b/tools/linuxfs/check-preloads.sh @@ -9,7 +9,7 @@ for bad in libstdc++.so.6 libgcc_s.so.1; do done syms() { "${READELF:-readelf}" -Ws "$1" | awk '$4 == "FUNC" && $5 == "GLOBAL" {sub(/@.*/, "", $8); print $8}'; } FAKE=$(syms "$D/libfakeinput.so") -for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do +for sym in open openat ioctl read readv close poll ppoll select stat fstat access scandir; do grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; } done if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then From e2d6b5dabf75f861bc90c725e84bf8352d0b4f54 Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:29:42 +0300 Subject: [PATCH 054/156] fix: safely copy readv descriptors before validation Copy the caller's iovec array through process_vm_readv before inspecting it, so unmapped, protected, or partially accessible arrays return EFAULT instead of crashing the process. Verified valid and invalid copies in the Android guest. --- app/src/main/cpp/fakeinput_steam.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 2efba76ce..3b16d9ac8 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3687,6 +3687,18 @@ EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) { errno = EFAULT; return -1; } + std::vector copied_iov(static_cast(iovcnt)); + if (iovcnt) { + size_t bytes = copied_iov.size() * sizeof(struct iovec); + struct iovec local = {copied_iov.data(), bytes}; + struct iovec remote = {const_cast(iov), bytes}; + ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0); + if (copied != static_cast(bytes)) { + errno = EFAULT; + return -1; + } + } + iov = copied_iov.data(); size_t size = 0; for (int i = 0; i < iovcnt; ++i) { if (iov[i].iov_len > static_cast(SSIZE_MAX) - size) { From 0b3e7428a6cdadf2ed78d8ea3d24f323b22f5801 Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:34:14 +0300 Subject: [PATCH 055/156] test: reject inaccessible readv descriptor arrays Cover unmapped, PROT_NONE, and partially accessible iovec arrays. The probe crashes with SIGSEGV before the descriptor-copy fix and returns EFAULT afterward. The Linux fakeinput suite and native ARM64 probe pass; Factorio was manually retested on the Thor with the updated library. --- tools/tests/test_fakeinput_ring.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/tests/test_fakeinput_ring.py b/tools/tests/test_fakeinput_ring.py index 42d6f85b6..af749d44a 100644 --- a/tools/tests/test_fakeinput_ring.py +++ b/tools/tests/test_fakeinput_ring.py @@ -91,6 +91,7 @@ READV_CHECKS = r""" #include #include #include +#include #include #include @@ -120,6 +121,22 @@ int main(void) return 7; if (invoke(fd, NULL, 1) != -1 || errno != EFAULT) return 8; + long page = sysconf(_SC_PAGESIZE); + void *area = mmap(NULL, 2 * page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (area == MAP_FAILED) + return 10; + struct iovec *boundary = (struct iovec *)((char *)area + page - sizeof(struct iovec)); + *boundary = vector; + if (mprotect((char *)area + page, page, PROT_NONE)) + return 11; + if (invoke(fd, boundary, 2) != -1 || errno != EFAULT) + return 12; + if (invoke(fd, boundary + 1, 1) != -1 || errno != EFAULT) + return 13; + if (munmap(area, 2 * page)) + return 14; + if (invoke(fd, boundary, 1) != -1 || errno != EFAULT) + return 15; int pipefd[2]; char text[5] = {0}; struct iovec parts[2] = {{text, 1}, {text + 1, 3}}; From e308224391d8d7a785162347410652313c73b93b Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 10:38:52 -0400 Subject: [PATCH 056/156] fix: RetroArch fills the screen from the emulator rail (#1194) --- .../overlay/usr/local/bin/droiddeck-session | 3 ++ tools/tests/test_session_run_display.py | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tools/tests/test_session_run_display.py diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 9b56c76bc..e99f44409 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -303,6 +303,9 @@ case $mode in taskset -cp "$cores" $$ >/dev/null 2>&1 || true fi python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 || true + if [ -n "${GAMESCOPE_WAYLAND_DISPLAY:-}" ]; then + export WAYLAND_DISPLAY= XDG_SESSION_TYPE=x11 + fi run_program() { local rc=0 child sig forwarded=0 { trap - INT QUIT; exec "$@"; } <&0 & diff --git a/tools/tests/test_session_run_display.py b/tools/tests/test_session_run_display.py new file mode 100644 index 000000000..5cf48817b --- /dev/null +++ b/tools/tests/test_session_run_display.py @@ -0,0 +1,46 @@ +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + + +def run_case(): + return re.search(r"^ run\)\n.*?^ ;;\n", SESSION.read_text(), re.M | re.S).group(0) + + +class SessionRunDisplayTest(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, True) + self.target = self.dir / "target.sh" + self.target.write_text("printf '%s\\n' \"${WAYLAND_DISPLAY-unset}\" \"${XDG_SESSION_TYPE-unset}\"" + " > \"$1\"\n") + + def display(self, **env): + base = {k: v for k, v in os.environ.items() + if k not in ("GAMESCOPE_WAYLAND_DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE", + "BL_LAUNCH_DIR")} + seen = self.dir / "seen" + script = ("program_cores() { :; }\nmode=run\ncase $mode in\n" + run_case() + "esac\n" + + "echo unreachable\n") + run = subprocess.run(["bash", "-c", script, "droiddeck-session", str(self.target), str(seen)], + env={**base, **env}, capture_output=True, text=True, timeout=30) + self.assertEqual(0, run.returncode, run.stderr) + self.assertNotIn("unreachable", run.stdout) + return seen.read_text().splitlines() + + def test_programs_under_gamescope_cannot_reach_a_wayland_server(self): + self.assertEqual(["", "x11"], self.display(GAMESCOPE_WAYLAND_DISPLAY="gamescope-0", + WAYLAND_DISPLAY="gamescope-0", XDG_SESSION_TYPE="wayland")) + + def test_without_gamescope_the_display_is_left_alone(self): + self.assertEqual(["wayland-0", "wayland"], self.display(WAYLAND_DISPLAY="wayland-0", XDG_SESSION_TYPE="wayland")) + + +if __name__ == "__main__": + unittest.main() From 58382e5182685c9560c7469a85828c9391009b0a Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:40 +0300 Subject: [PATCH 057/156] fix: safely copy controller events to caller buffers Stage ring and keyframe events before copying them with process_vm_writev. Return EFAULT for inaccessible destinations and advance the reader only for complete events delivered, preserving partial readv progress. Retested Factorio on the Thor with the exact ARM64 library. --- app/src/main/cpp/fakeinput_steam.cpp | 39 ++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 3b16d9ac8..09428e442 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3577,6 +3577,21 @@ EXPORT int close(int fd) { static constexpr int kMaxRingWaitMs = 2; static constexpr int kLowLatencyRingWaitUs = 250; +static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) { + size_t bytes = count * sizeof(struct input_event); + struct iovec local = {const_cast(events), bytes}; + struct iovec remote = {buf, bytes}; + ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0); + if (copied < 0) return -1; + if (copied != static_cast(bytes)) { + errno = EFAULT; + // evdev reads only report complete input_event records. + copied -= copied % sizeof(struct input_event); + if (!copied) return -1; + } + return copied; +} + static ssize_t read_input(int fd, void *buf, size_t count) { if (!watched(fd)) return syscall(SYS_read, fd, buf, count); std::unique_lock guard(controller_mutex()); @@ -3622,42 +3637,44 @@ static ssize_t read_input(int fd, void *buf, size_t count) { if (events == 0) break; const uint8_t *ring_events = reinterpret_cast(fake.ring) + FAKE_INPUT_RING_HEADER_SIZE; + std::vector input_events(events); for (size_t i = 0; i < events; ++i) { size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY; RingEvent stored; memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); - struct input_event ev = {}; + struct input_event &ev = input_events[i]; ev.time.tv_sec = stored.sec; ev.time.tv_usec = stored.usec; ev.type = stored.type; ev.code = stored.code; ev.value = stored.value; if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); } __atomic_thread_fence(__ATOMIC_ACQUIRE); if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED)) continue; - fake.read_seq += events; - return static_cast(events * sizeof(struct input_event)); + ssize_t copied = copy_input_events(buf, input_events.data(), events); + if (copied > 0) fake.read_seq += copied / sizeof(struct input_event); + return copied; } } size_t events = std::min(requested, fake.keyframe_remaining); + struct input_event input_events[kNeutralEventCount] = {}; struct timeval now = {}; gettimeofday(&now, nullptr); for (size_t i = 0; i < events; ++i) { - size_t index = kNeutralEventCount - fake.keyframe_remaining; - struct input_event ev = {}; + size_t index = kNeutralEventCount - fake.keyframe_remaining + i; + struct input_event &ev = input_events[i]; ev.time = now; ev.type = kNeutralEvents[index].type; ev.code = kNeutralEvents[index].code; ev.value = keyframe_value(fake, ev.type, ev.code); if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); - --fake.keyframe_remaining; } - return static_cast(events * sizeof(struct input_event)); + ssize_t copied = copy_input_events(buf, input_events, events); + if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event); + return copied; } if (nonblock) { errno = EAGAIN; @@ -3706,10 +3723,6 @@ EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) { return -1; } size += iov[i].iov_len; - if (iov[i].iov_len && !iov[i].iov_base) { - errno = EFAULT; - return -1; - } } // evdev's readv calls its read handler per vector; Steam's ARM64 overlay uses this path too. From a59ed6075ef6f628e39848b28e0fd2c413459913 Mon Sep 17 00:00:00 2001 From: Delete228 <31237303+Delete228@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:55:25 +0300 Subject: [PATCH 058/156] test: cover controller destination faults and partial reads Exercise keyframe and ring reads with unmapped, PROT_NONE, read-only and page-boundary destinations. Verify complete-event progress within one vector and across vectors, including a NULL destination. The Linux fakeinput suite passes 11 tests with 3 i386 skips; both native ARM64 probes pass on the Thor. --- tools/tests/test_fakeinput_ring.py | 82 +++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/tools/tests/test_fakeinput_ring.py b/tools/tests/test_fakeinput_ring.py index af749d44a..86afc9939 100644 --- a/tools/tests/test_fakeinput_ring.py +++ b/tools/tests/test_fakeinput_ring.py @@ -147,6 +147,74 @@ int main(void) return 0; } """ +READV_DESTINATION_CHECKS = r""" +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + struct input_event events[64]; + int fd = open("/dev/input/event16", O_RDONLY | O_NONBLOCK); + if (fd < 0) + return 1; + if (argc > 1) + while (read(fd, events, sizeof(events)) > 0) + ; + puts("ready"); + fflush(stdout); + char go; + if (read(0, &go, 1) != 1) + return 2; + long page = sysconf(_SC_PAGESIZE); + char *area = mmap(NULL, 2 * page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (area == MAP_FAILED || mprotect(area + page, page, PROT_NONE)) + return 3; + struct iovec vector = {area + page, sizeof(struct input_event)}; + if (readv(fd, &vector, 1) != -1 || errno != EFAULT) + return 4; + if (read(fd, vector.iov_base, vector.iov_len) != -1 || errno != EFAULT) + return 5; + if (mprotect(area, page, PROT_READ)) + return 6; + vector.iov_base = area; + if (readv(fd, &vector, 1) != -1 || errno != EFAULT) + return 7; + if (mprotect(area, page, PROT_READ | PROT_WRITE) || munmap(area + page, page)) + return 8; + vector.iov_base = area + page; + if (readv(fd, &vector, 1) != -1 || errno != EFAULT) + return 9; + vector.iov_base = area + page - 1; + if (readv(fd, &vector, 1) != -1 || errno != EFAULT) + return 15; + vector.iov_base = area + page - sizeof(struct input_event); + vector.iov_len = 2 * sizeof(struct input_event); + if (readv(fd, &vector, 1) != sizeof(struct input_event)) + return 10; + struct input_event *first = vector.iov_base; + if (first->type != EV_KEY || first->code != BTN_A) + return 11; + struct iovec parts[2] = {{events, sizeof(struct input_event)}, {NULL, sizeof(struct input_event)}}; + if (readv(fd, parts, 2) != sizeof(struct input_event)) + return 12; + if (events[0].type != (argc > 1 ? EV_ABS : EV_KEY) || events[0].code != (argc > 1 ? ABS_Y : BTN_B)) + return 13; + vector.iov_base = events; + vector.iov_len = sizeof(events); + ssize_t got = readv(fd, &vector, 1); + if (got != (argc > 1 ? 1 : 18) * sizeof(struct input_event) || + events[0].type != (argc > 1 ? EV_SYN : EV_KEY) || events[0].code != (argc > 1 ? SYN_REPORT : BTN_X)) + return 14; + munmap(area, page); + puts("ok"); + return 0; +} +""" OVERLAY_READ = r""" #include #include @@ -199,8 +267,9 @@ class FakeinputRingTest(unittest.TestCase): subprocess.run(["gcc", bits, "-o", str(binary), str(self.tmp / (name + ".c"))], check=True) return library, binary - def read_through(self, bits, vectors=None, overlay=False, virtual=True): - program = READER if virtual else READER.replace("event16", "event0") + def read_through(self, bits, vectors=None, overlay=False, virtual=True, program=READER): + if not virtual: + program = program.replace("event16", "event0") library, reader = self.build(bits, program, "reader") hooks, path = self.ring(virtual=virtual) env = dict(os.environ, LD_PRELOAD=str(library), FAKE_EVDEV_DIR=str(hooks)) @@ -262,6 +331,15 @@ class FakeinputRingTest(unittest.TestCase): self.assertEqual(run.returncode, 0, run.stderr) self.assertEqual(run.stdout, "ok\n") + def test_readv_keyframes_handle_destination_faults_and_partial_progress(self): + self.assertEqual(self.read_through("-m64", program=READV_DESTINATION_CHECKS), "ok\n") + + def test_readv_ring_events_handle_destination_faults_and_partial_progress(self): + self.assertEqual(self.read_through("-m64", vectors=1, program=READV_DESTINATION_CHECKS), "ok\n") + + def test_a_32_bit_guest_handles_readv_destination_faults(self): + self.assertEqual(self.read_through("-m32", program=READV_DESTINATION_CHECKS), "ok\n") + def test_an_event_node_rejects_joydev_requests_so_sdl_reads_its_id(self): library, probe = self.build("-m64", PROBE, "probe") hooks, _ = self.ring() From 7775a87129b8f2190da3433fb153f3fd2df0a276 Mon Sep 17 00:00:00 2001 From: kingokksa <47888451+kingokksa@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:12:27 +0800 Subject: [PATCH 059/156] feat: arrange and hide/show store tabs from stores settings (#1121) --- .../launcher/session/SessionPrefs.kt | 14 + .../droiddeck/launcher/stores/StoreTabs.kt | 9 + .../droiddeck/launcher/ui/StoresDownloads.kt | 9 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 261 +++++++++++++++--- app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-b+es+419/strings.xml | 2 + app/src/main/res/values-bg/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-da/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-el/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fi/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-hi/strings.xml | 2 + app/src/main/res/values-hu/strings.xml | 2 + app/src/main/res/values-in/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-ms/strings.xml | 2 + app/src/main/res/values-nb/strings.xml | 2 + app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rPT/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-ro/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-sv/strings.xml | 2 + app/src/main/res/values-th/strings.xml | 2 + app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values-vi/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rHK/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 37 files changed, 313 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 25be1b42f..e7556e97a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -118,6 +118,20 @@ object SessionPrefs { prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply() } + fun storesTabOrder(context: Context): List = idList(prefs(context).getString("storesTabOrder", null)) + + fun setStoresTabOrder(context: Context, order: List) { + prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply() + } + + fun storesTabHidden(context: Context): Set = idList(prefs(context).getString("storesTabHidden", null)).toSet() + + fun setStoresTabHidden(context: Context, hidden: Set) { + prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply() + } + + private fun idList(raw: String?): List = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() } + val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc") val STORES_VIEWS = listOf("cards", "capsules", "list") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt new file mode 100644 index 000000000..975626168 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreTabs.kt @@ -0,0 +1,9 @@ +package com.droiddeck.launcher.stores + +data class StoreTab(val store: Store, val shown: Boolean) + +fun arrangeStoreTabs(order: List, hidden: Set): List { + val known = Store.entries.associateBy { it.id } + val listed = order.mapNotNull { known[it] }.distinct() + return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index 81a2fee3d..fe4e7a009 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.geometry.Size @@ -483,7 +484,7 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) { } } -internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk = +internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color, onArrange: () -> Unit): StepAsk = StepAsk( anchor = at, side = StepSide.Left, accent = accent, items = 2, maxWidth = 440.dp, handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) }, @@ -494,6 +495,12 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State().apply { + addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx))) + } + } + var arranging by remember { mutableStateOf(false) } + val shownStores = tabs.filter { it.shown }.map { it.store } + fun commitTabs() { + SessionPrefs.setStoresTabOrder(ctx, tabs.map { it.store.id }) + SessionPrefs.setStoresTabHidden(ctx, tabs.filterNot { it.shown }.map { it.store.id }.toSet()) + } + fun shiftTab(store: Store, delta: Int) { + val from = tabs.indexOfFirst { it.store == store } + val to = from + delta + if (from < 0 || to !in tabs.indices) return + tabs.add(to, tabs.removeAt(from)) + commitTabs() + } + fun moveTab(store: Store, to: Int) { + val from = tabs.indexOfFirst { it.store == store } + if (from < 0) return + val target = to.coerceIn(0, tabs.lastIndex) + if (target == from) return + tabs.add(target, tabs.removeAt(from)) + commitTabs() + } + fun toggleTab(store: Store) { + val i = tabs.indexOfFirst { it.store == store } + if (i < 0) return + val show = !tabs[i].shown + if (!show && tabs.count { it.shown } <= 1) return + tabs[i] = tabs[i].copy(shown = show) + commitTabs() + } + LaunchedEffect(tabs.map { it.store to it.shown }, chip) { + val picked = Store.byId(chip) ?: return@LaunchedEffect + if (tabs.none { it.store == picked && it.shown }) chip = tabs.firstOrNull { it.shown }?.store?.id ?: chip + } var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf(null) } val liveState = androidx.compose.runtime.rememberUpdatedState(s) LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } } val store = Store.byId(chip) + LaunchedEffect(tabs.toList()) { + if (chip != DOWNLOADS && Store.byId(chip) !in shownStores) chip = shownStores.firstOrNull()?.id ?: DOWNLOADS + } val shownTab = tabFor(store, tab) val pane = PaneKey( chip, if (store == null) "" else shownTab, @@ -246,7 +295,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier } val pal = LocalPalette.current fun cycleStore(step: Int) { - val ids = Store.entries.map { it.id } + val ids = tabs.filter { it.shown }.map { it.store.id } + if (ids.isEmpty()) return val at = ids.indexOf(chip) pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size]) } @@ -254,6 +304,16 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier if (chip != st.id) pickChip(st.id) else move("first") } BackHandler(enabled = openGame != null) { closeGame() } + BackHandler(enabled = arranging) { arranging = false } + LaunchedEffect(arranging) { + val focus = ff ?: return@LaunchedEffect + if (!arranging || inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + repeat(45) { + androidx.compose.runtime.withFrameNanos { } + val req = tabs.firstOrNull()?.let { focus.items["storechip:${it.store.id}"] } ?: return@repeat + if (runCatching { req.requestFocus() }.isSuccess) return@LaunchedEffect + } + } val scroll = rememberScrollState() val detailScroll = rememberScrollState() LaunchedEffect(chip, tab) { scroll.scrollTo(0); grid.scrollToItem(0) } @@ -329,14 +389,32 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier Column( modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) .bumpers( - onPrevious = { if (openGame == null) cycleStore(-1) }, - onNext = { if (openGame == null) cycleStore(1) }, + onPrevious = { + val picked = Store.byId(focusedChip) + if (arranging) { if (picked != null) shiftTab(picked, -1) } + else if (openGame == null) cycleStore(-1) + }, + onNext = { + val picked = Store.byId(focusedChip) + if (arranging) { if (picked != null) shiftTab(picked, 1) } + else if (openGame == null) cycleStore(1) + }, ), ) { Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { - Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, - onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) } + Rise(0) { + StoreChips( + chip, focusedChip, s.storeDownloadsActive, tabs, arranging, + onStore = { st -> openModes(st) }, + onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, + onFocusChip = { focusedChip = it }, + onArrangeDone = { arranging = false }, + onMoveTab = { st, to -> moveTab(st, to) }, + onToggleTab = { toggleTab(it) }, + onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal) { arranging = true }) }, + ) + } } } val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } @@ -547,55 +625,148 @@ private fun SignInFloodLayer(hostAt: Offset) { } +private class ChipDrag { + var store by mutableStateOf(null) + var from by mutableIntStateOf(-1) + var target by mutableIntStateOf(-1) + var x by mutableFloatStateOf(0f) +} + +@Composable +private fun StoreDot(store: Store, signedIn: Boolean) { + val c = sourceColours(store.id) + Box( + Modifier.size(12.dp) + .onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() } + .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot), + ) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) } +} + +@Composable +private fun RowScope.ArrangeStoreChip( + tab: StoreTab, index: Int, last: Int, gapPx: Float, drag: ChipDrag, + onFocusChip: (String?) -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit, +) { + val store = tab.store + val box = remember(store) { mutableFloatStateOf(0f) } + val pitch = box.floatValue + gapPx + val held = drag.store == store + val lift by animateFloatAsState(if (held) 1f else 0f, Motion.sp(0.7f, Spring.StiffnessMediumLow), label = "chipLift") + val shift by animateFloatAsState( + when { + drag.store == null || held -> 0f + drag.from < index && drag.target >= index -> -pitch + drag.from > index && drag.target <= index -> pitch + else -> 0f + }, + Motion.sp(0.6f, Spring.StiffnessMediumLow), label = "chipShift", + ) + val pill = remember { RoundedCornerShape(99.dp) } + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + fun reset() { + drag.store = null + drag.from = -1 + drag.target = -1 + drag.x = 0f + } + fun commit() { + val from = drag.from + val to = drag.target + reset() + if (from >= 0 && to >= 0 && to != from) onMoveTab(store, to) + } + StoreChip( + label = store.shortLabel, on = false, under = false, key = store.id, mark = sourceColours(store.id).dot, lift = lift, + modifier = Modifier.weight(1f) + .zIndex(if (held) 2f else 0f) + .onSizeChanged { box.floatValue = it.width.toFloat() } + .graphicsLayer { + translationX = if (held) drag.x else shift + shadowElevation = 14.dp.toPx() * lift + shape = pill + clip = false + } + .pointerInput(store, index) { + detectDragGestures( + onDragStart = { + drag.store = store + drag.from = index + drag.target = index + drag.x = 0f + }, + onDragEnd = { commit() }, + onDragCancel = { reset() }, + ) { change, amount -> + change.consume() + val step = box.floatValue + gapPx + if (step <= 0f) return@detectDragGestures + drag.x = (drag.x + amount.x).coerceIn(-drag.from * step, (last - drag.from) * step) + drag.target = (drag.from + (drag.x / step).roundToInt()).coerceIn(0, last) + } + } + .alpha(if (tab.shown) 1f else 0.32f), + lead = { StoreDot(store, signedIn) }, + onFocused = onFocusChip, + ) { onToggleTab(store) } +} + @Composable private fun StoreChips( - selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, - onFocusChip: (String?) -> Unit, + selected: String, focused: String?, active: Int, tabs: List, arranging: Boolean, + onStore: (Store) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, onFocusChip: (String?) -> Unit, + onArrangeDone: () -> Unit, onMoveTab: (Store, Int) -> Unit, onToggleTab: (Store) -> Unit, ) { val pal = LocalPalette.current - val keys = Store.entries.map { it.id } + DOWNLOADS + val keys = tabs.filter { it.shown }.map { it.store.id } + DOWNLOADS val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } val rootBounds = remember { HashMap() } val highlighted = focused ?: selected val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode - val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard + val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard && !arranging val tint = androidx.compose.animation.animateColorAsState( Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint", ) + val gap = 8.dp + val gapPx = with(androidx.compose.ui.platform.LocalDensity.current) { gap.toPx() } + val drag = remember { ChipDrag() } Box(Modifier.fillMaxWidth()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - for (store in Store.entries) { - val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true - StoreChip( - label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, - modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, - lead = { - val c = sourceColours(store.id) - Box( - Modifier.size(12.dp) - .onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() } - .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot) - .background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape), - ) - }, - description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), - onFocused = onFocusChip, - ) { onStore(store, rootBounds[store.id]) } + Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + for (tab in tabs) { + val store = tab.store + if (arranging) { + val index = tabs.indexOfFirst { it.store == store } + key(store) { ArrangeStoreChip(tab, index, tabs.lastIndex, gapPx, drag, onFocusChip, onMoveTab, onToggleTab) } + } else if (tab.shown) { + val signedIn = StoresState.isSignedIn(store) && StoresState.expired[store] != true + key(store) { + StoreChip( + label = store.shortLabel, on = selected == store.id, under = fillShown && highlighted == store.id, key = store.id, mark = sourceColours(store.id).dot, + modifier = Modifier.weight(1f).onGloballyPositioned { bounds[store.id] = it.boundsInParent(); rootBounds[store.id] = it.boundsInRoot() }, + lead = { StoreDot(store, signedIn) }, + description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out), + onFocused = onFocusChip, + ) { onStore(store) } + } + } } - StoreChip( - label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, - modifier = Modifier.onGloballyPositioned { - bounds[DOWNLOADS] = it.boundsInParent() - StoresMotion.downloadsChip = it.boundsInRoot() - }, - lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, - trail = if (active > 0) { { CountPill(active) } } else null, - description = stringResource(R.string.stores_tab_downloads), - onFocused = onFocusChip, - ) { onDownloads() } - var cogAt by remember { mutableStateOf(null) } - Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { - IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + if (arranging) { + IconChip(Icons.Filled.Check, stringResource(R.string.stores_tabs_arrange_done)) { onArrangeDone() } + } else { + StoreChip( + label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal, + modifier = Modifier.onGloballyPositioned { + bounds[DOWNLOADS] = it.boundsInParent() + StoresMotion.downloadsChip = it.boundsInRoot() + }, + lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) }, + trail = if (active > 0) { { CountPill(active) } } else null, + description = stringResource(R.string.stores_tab_downloads), + onFocused = onFocusChip, + ) { onDownloads() } + var cogAt by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { + IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + } } } ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint, visible = fillShown) @@ -605,7 +776,7 @@ private fun StoreChips( @Composable private fun StoreChip( label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit, - trail: (@Composable () -> Unit)? = null, description: String? = null, + trail: (@Composable () -> Unit)? = null, description: String? = null, lift: Float = 0f, onFocused: (String?) -> Unit = {}, onClick: () -> Unit, ) { val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current @@ -630,10 +801,10 @@ private fun StoreChip( if (f.isFocused && inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) onFocused(key) else if (!f.isFocused) onFocused(null) } - .heightIn(min = 44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape) - .background(if (under) Color.Transparent else colors.surface) + .heightIn(min = 44.dp).graphicsLayer { scaleX = scale * (1f + 0.08f * lift); scaleY = scale * (1f + 0.08f * lift) }.clip(shape) + .background(if (under) Color.Transparent else lerp(colors.surface, pal.signal, 0.18f * lift)) .shownBar(on, mark) - .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) + .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else lerp(pal.line, pal.signal, lift)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick) .then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0bc7bd281..fc39b7c21 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2008,6 +2008,8 @@ المتاجر إعدادات المتاجر الإعدادات + ترتيب التبويبات + تم (لم يتم تسجيل الدخول) المتجر المكتبة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b1c1c7eb5..4831cd4db 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1975,6 +1975,8 @@ Tiendas Ajustes de las tiendas Ajustes + Organizar pestañas + Listo (sin iniciar sesión) Tienda Biblioteca diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 0b839a223..800ef559b 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1924,6 +1924,8 @@ Магазини Настройки на магазините Настройки + Подреждане на раздели + Готово (не е вписан) Магазин Библиотека diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3ebc7210f..a382e5ea4 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1966,6 +1966,8 @@ Obchody Nastavení obchodů Nastavení + Uspořádat karty + Hotovo (nepřihlášen) Obchod Knihovna diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index d8005ceec..a1e57f7ec 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1924,6 +1924,8 @@ Butikker Butiksindstillinger Indstillinger + Arranger faner + Færdig (ikke logget ind) Butik Bibliotek diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d7c02ce9e..1e0742d4c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1913,6 +1913,8 @@ Stores Store-Einstellungen Einstellungen + Tabs anordnen + Fertig (nicht angemeldet) Store Bibliothek diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 40dbd1ced..eb351a015 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1924,6 +1924,8 @@ Καταστήματα Ρυθμίσεις καταστημάτων Ρυθμίσεις + Τακτοποίηση καρτελών + Έγινε (μη συνδεδεμένος) Κατάστημα Βιβλιοθήκη diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 07d581180..0e6bcec65 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1942,6 +1942,8 @@ Tiendas Ajustes de las tiendas Ajustes + Organizar pestañas + Listo (sin iniciar sesión) Tienda Biblioteca diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 85574fa93..2c48ce8a9 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1924,6 +1924,8 @@ Kaupat Kauppojen asetukset Asetukset + Järjestä välilehdet + Valmis (ei kirjautunut) Kauppa Kirjasto diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fe2a4330f..e3d812d85 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1942,6 +1942,8 @@ Boutiques Paramètres des boutiques Paramètres + Organiser les onglets + Terminé (non connecté) Boutique Bibliothèque diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index fa32ff251..6317d9b53 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1924,6 +1924,8 @@ स्टोर स्टोर सेटिंग सेटिंग + टैब व्यवस्थित करें + हो गया (साइन इन नहीं है) स्टोर लाइब्रेरी diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 1ea4d95be..1cc141ae4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1924,6 +1924,8 @@ Áruházak Áruházbeállítások Beállítások + Fülek rendezése + Kész (nincs bejelentkezve) Áruház Könyvtár diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index a08d3e5db..48fffc0ea 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1903,6 +1903,8 @@ Toko Setelan toko Setelan + Atur tab + Selesai (belum masuk) Toko Pustaka diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7b650591c..6aec8676f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1934,6 +1934,8 @@ Negozi Impostazioni negozi Impostazioni + Disponi schede + Fine (accesso non eseguito) Negozio Libreria diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bee429969..bc82170f5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1903,6 +1903,8 @@ ストア ストア設定 設定 + タブを整理 + 完了 (未ログイン) ストア ライブラリ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 03e678056..8c74c4075 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1903,6 +1903,8 @@ 스토어 스토어 설정 설정 + 탭 정리 + 완료 (로그인 안 됨) 스토어 라이브러리 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 96f8207c6..3c3fe99aa 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1903,6 +1903,8 @@ Kedai Tetapan kedai Tetapan + Susun tab + Selesai (belum log masuk) Kedai Pustaka diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 37d099a59..d194c273b 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1924,6 +1924,8 @@ Butikker Butikkinnstillinger Innstillinger + Ordne faner + Ferdig (ikke innlogget) Butikk Bibliotek diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e2d4ffa98..e02ca9fcf 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1924,6 +1924,8 @@ Winkels Instellingen voor winkels Instellingen + Tabbladen rangschikken + Klaar (niet aangemeld) Winkel Bibliotheek diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9e8459774..770cdf01b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1966,6 +1966,8 @@ Sklepy Ustawienia sklepów Ustawienia + Ułóż karty + Gotowe (niezalogowano) Sklep Biblioteka diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 059af9551..4ab50888c 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1934,6 +1934,8 @@ Lojas Definições das lojas Definições + Organizar separadores + Concluído (sessão não iniciada) Loja Biblioteca diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 68a46590f..001a40c44 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1934,6 +1934,8 @@ Lojas Configurações das lojas Configurações + Organizar abas + Concluído (sem login) Loja Biblioteca diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 7bf7df032..7733ae5d2 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1945,6 +1945,8 @@ Magazine Setările magazinelor Setări + Aranjează filele + Gata (neautentificat) Magazin Bibliotecă diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4489ed3bb..687ec99a1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1963,6 +1963,8 @@ Магазины Настройки магазинов Настройки + Упорядочить вкладки + Готово (вход не выполнен) Магазин Библиотека diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4a2ec518d..5426ae549 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1924,6 +1924,8 @@ Butiker Butiksinställningar Inställningar + Ordna flikar + Klart (inte inloggad) Butik Bibliotek diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 67a5906f6..e8b5b87ac 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1903,6 +1903,8 @@ สโตร์ การตั้งค่าสโตร์ การตั้งค่า + จัดเรียงแท็บ + เสร็จสิ้น (ยังไม่ลงชื่อเข้าใช้) สโตร์ ไลบรารี diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 912ef175c..7759b8662 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1913,6 +1913,8 @@ Mağazalar Mağaza ayarları Ayarlar + Sekmeleri düzenle + Bitti (oturum açılmadı) Mağaza Kütüphane diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9e7621092..47e709882 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1966,6 +1966,8 @@ Магазини Налаштування магазинів Налаштування + Упорядкувати вкладки + Готово (вхід не виконано) Магазин Бібліотека diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index e29d23dfd..d95f00c44 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1903,6 +1903,8 @@ Cửa hàng Thiết lập Stores Thiết lập + Sắp xếp thẻ + Xong (chưa đăng nhập) Cửa hàng Thư viện diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 44fd2f961..213568fe7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1979,6 +1979,8 @@ 游戏商店 游戏商店设置 设置 + 整理标签 + 完成 (未登录) 商店 库 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 431faf6be..57f5c7854 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1903,6 +1903,8 @@ 商店 商店設定 設定 + 整理標籤 + 完成 (未登入) 商店 收藏庫 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index dd6732156..c2622635e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1903,6 +1903,8 @@ 遊戲商店 遊戲商店設定 設定 + 整理分頁 + 完成 (未登入) 商店 收藏庫 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03e52a56c..a050c90f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1975,6 +1975,8 @@ Stores Stores settings Settings + Arrange tabs + Done (not signed in) Store Library From 0663593920b88460d3074841e1e617250ab8592a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:14:06 -0400 Subject: [PATCH 060/156] Fix Game Pass library sync when subscription plan rows are omitted (#1193) --- .../launcher/stores/xbox/XboxBackend.kt | 8 +- .../launcher/stores/xbox/XboxCatalog.kt | 26 ++++- .../launcher/stores/xbox/XboxCatalogTest.kt | 49 ++++++++++ tools/xbox/README.md | 9 +- .../droiddeck-library-subscriptions.patch | 95 +++++++++++++++++++ tools/xbox/prepare.py | 7 +- 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tools/xbox/droiddeck-library-subscriptions.patch diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index c8f1d27d1..24d7ff622 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -54,8 +54,12 @@ object XboxBackend : StoreBackend { } } if (cached != null && !cached.needsRefresh(force)) return@Thread - val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market) - val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() } + val response = Xodus.run(app, listOf("library", "--json", "--market", market)) + val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") }) + val products = XboxCatalog.rawProducts(licenses.keys.toList(), market) + val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() } + StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " + + "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}") synchronized(this) { if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread access = licenses diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index 276919ec3..fa7a6b5fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -11,10 +11,25 @@ internal object XboxCatalog { private val productId = Regex("[A-Z0-9]{12}") private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") - fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map { + fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map { require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds require(age in -60..299) + val memberships: Set? = if (json.has("subscriptions")) { + val subscriptions = json.getJSONArray("subscriptions") + require(subscriptions.length() <= plans.size) + val seen = HashSet() + buildSet { + for (i in 0 until subscriptions.length()) { + val row = subscriptions.getJSONObject(i) + val id = row.getString("productId") + require(id in plans && seen.add(id)) + val status = row.getString("status") + val expiry = timestamp(row.getString("endDate")) + if (status == "Active" && expiry.isAfter(now)) add(id) + } + } + } else null val array = json.getJSONArray("entitlements") require(array.length() <= 10_000) val rows = LinkedHashMap() @@ -30,6 +45,7 @@ internal object XboxCatalog { } fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") && row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now) + fun activePlan(id: String) = rows[id]?.let(::active) ?: (id in memberships.orEmpty()) fun kind(id: String, visited: Set = emptySet()): String? { if (id in blocked || id in visited || visited.size >= 16) return null val row = rows[id] ?: return null @@ -37,10 +53,14 @@ internal object XboxCatalog { val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } if (parent == null) return if (id in plans) null else "owned" if (parent in blocked) return null - if (parent in plans) return if (rows[parent]?.let(::active) == true) "gamepass" else null + if (parent in plans) return if (activePlan(parent)) "gamepass" else null return kind(parent, visited + id) } - return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() + val missingPlan = rows.values.count { row -> row.optString("satisfiedByProductId").let { it in plans && !activePlan(it) } } + report("library entitlements=${array.length()} invalid=${array.length() - rows.size} restricted=${rows.values.count { !active(it) }} " + + "unverified-plan=$missingPlan owned=${result.values.count { it == "owned" }} gamepass=${result.values.count { it == "gamepass" }}") + return result } private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant() diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index 683696f14..c5e6927e7 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -19,6 +19,55 @@ class XboxCatalogTest { private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test") .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList())) private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) + private fun membership() = JSONObject().put("productId", plan).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z") + private fun withMembership(json: JSONObject, vararg plans: JSONObject) = json.put("subscriptions", JSONArray(plans.toList())) + + @Test fun separateMembershipRestoresSubscriptionChildrenWithoutInventingOwnership() { + val json = withMembership(envelope(row(game), row(parent, plan)), membership()) + assertEquals(mapOf(game to "owned", parent to "gamepass"), XboxCatalog.access(json, "test@example.test", "US", now)) + assertTrue(XboxCatalog.access(withMembership(envelope(), membership()), "test@example.test", "US", now).isEmpty()) + assertEquals(mapOf(game to "gamepass", parent to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, parent), row(parent, plan)), membership()), "test@example.test", "US", now)) + } + + @Test fun separateMembershipPreservesProductRestrictionsAndExistingPlanEvidence() { + for (member in listOf(membership().put("status", "Expired"), membership().put("endDate", now.toString()))) { + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, plan)), member), "test@example.test", "US", now).isEmpty()) + } + assertEquals(mapOf(game to "gamepass"), XboxCatalog.access( + withMembership(envelope(row(game, plan), row(plan))), "test@example.test", "US", now)) + for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true, + "sharingSource" to "Family", "endDate" to now.toString())) { + for (rows in listOf(arrayOf(row(game, plan).put(key, value)), arrayOf(row(game, plan), row(plan).put(key, value)))) { + assertTrue(XboxCatalog.access(withMembership(envelope(*rows), membership()), "test@example.test", "US", now).isEmpty()) + } + } + } + + @Test fun malformedMembershipFailsTheRefreshInsteadOfBecomingAnEmptySuccessfulLibrary() { + for (json in listOf( + withMembership(envelope(row(game, plan)), membership().put("endDate", "invalid")), + withMembership(envelope(row(game, plan)), membership(), membership()), + withMembership(envelope(row(game, plan)), membership().put("productId", game)), + envelope(row(game, plan)).put("subscriptions", JSONObject.NULL), + )) { + assertThrows(Exception::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun membershipCannotCrossAccountMarketOrFreshnessBoundaries() { + for ((key, value) in listOf("account" to "other@example.test", "market" to "GB", "checkedAt" to "2020-01-01T00:00:00Z")) { + val json = withMembership(envelope(row(game, plan)).put(key, value), membership()) + assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) } + } + } + + @Test fun libraryDiagnosticsContainCountsWithoutAccountOrProductIdentifiers() { + val lines = mutableListOf() + XboxCatalog.access(envelope(row(game), row(parent, plan)), "test@example.test", "US", now, lines::add) + assertEquals(listOf("library entitlements=2 invalid=0 restricted=0 unverified-plan=1 owned=1 gamepass=0"), lines) + assertFalse(lines.single().contains(game) || lines.single().contains(parent) || lines.single().contains("@")) + } @Test fun purchasedAndSubscriptionAccessRemainDistinct() { assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(plan))) diff --git a/tools/xbox/README.md b/tools/xbox/README.md index b06825748..abe39f8c9 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -5,7 +5,14 @@ service. It does not install the Nexus desktop launcher or x86 Linux Wine. `backend.lock.json` pins Xodus, the Nexus commit, Rust, and the ordered patch checksums. `runtime.lock.json` pins the Nexus Windows runtime release and each extracted PE DLL. `prepare.py` verifies both source revisions and all Nexus patch -checksums before applying the two local transport/launch patches. +checksums before applying the local transport, launch and library patches. + +`droiddeck-library-subscriptions.patch` queries fresh subscription membership +alongside product entitlements using the same Store session, account and market. +Android can validate a Game Pass child's parent plan when that plan is absent +from the product response. A failed membership query fails the refresh and +preserves the previous library. Empty membership results add no plan evidence; +valid plans already present in the product response retain their existing checks. Build on AArch64 Ubuntu 24.04 with Rust from the lock, a C/C++ compiler, OpenSSL development headers, pkg-config, protoc, CMake, Git, Python 3 and xz: diff --git a/tools/xbox/droiddeck-library-subscriptions.patch b/tools/xbox/droiddeck-library-subscriptions.patch new file mode 100644 index 000000000..833fab085 --- /dev/null +++ b/tools/xbox/droiddeck-library-subscriptions.patch @@ -0,0 +1,95 @@ +--- a/crates/xodus-cli/src/commands/library.rs ++++ b/crates/xodus-cli/src/commands/library.rs +@@ -96,6 +96,48 @@ + eprintln!("NEXUS_LIBRARY_DIAGNOSTIC_V1 {stage} {status} {count}"); + } + ++const SUBSCRIPTION_PLANS: &[&str] = &[ ++ "CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", ++ "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD", ++]; ++ ++fn subscription_rows(body: &Value) -> Result, &'static str> { ++ let rows = body["entitlements"].as_object().ok_or("subscriptions-shape")?; ++ if rows.len() > SUBSCRIPTION_PLANS.len() { return Err("subscriptions-count"); } ++ let mut plans = Vec::new(); ++ for (id, row) in rows { ++ if !SUBSCRIPTION_PLANS.contains(&id.as_str()) { return Err("subscriptions-product"); } ++ let status = row["status"].as_str().ok_or("subscriptions-status")?; ++ if status != "Active" { continue; } ++ let expiry = row["endDate"].as_str().filter(|s| s.len() < 50) ++ .filter(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).ok_or("subscriptions-expiry")?; ++ plans.push(json!({"productId":id,"status":status,"endDate":expiry})); ++ } ++ Ok(plans) ++} ++ ++async fn subscriptions(client: &reqwest::Client, ticket: &str, market: &str) -> Result, &'static str> { ++ let url = format!("https://beige.xboxservices.com/PCGAFD/entitlements/subscriptions?market={market}&language=en-US&appVersion=0.2.3"); ++ let cv = format!("{}.0", STANDARD_NO_PAD.encode(uuid::Uuid::new_v4().as_bytes())); ++ let mut response = client.post(url) ++ .header("Authorization", ticket).header("X-MS-API-Version", "1.1") ++ .header("Calling-App-Name", "Xodus-Nexus").header("MS-CV", cv) ++ .json(&json!({"productIds":SUBSCRIPTION_PLANS})) ++ .send().await.map_err(|_| "subscriptions-request")?; ++ if !response.status().is_success() { ++ diagnostic("subscriptions-http", response.status().as_u16(), 0); ++ return Err("subscriptions-http"); ++ } ++ if response.content_length().is_some_and(|n| n > 262_144) { return Err("subscriptions-size"); } ++ let mut bytes = Vec::new(); ++ while let Some(chunk) = response.chunk().await.map_err(|_| "subscriptions-read")? { ++ if bytes.len() + chunk.len() > 262_144 { return Err("subscriptions-size"); } ++ bytes.extend_from_slice(&chunk); ++ } ++ let body: Value = serde_json::from_slice(&bytes).map_err(|_| "subscriptions-json")?; ++ subscription_rows(&body) ++} ++ + async fn query(tokens: &TokenManager, market: &str) -> Result { + if market.len() != 2 || !market.bytes().all(|b| b.is_ascii_uppercase()) { + return Err("market"); +@@ -155,6 +197,7 @@ + let page: Value = serde_json::from_slice(&bytes).map_err(|_| "entitlements-json")?; + all.add(&page)?; + if continuation.is_none() { ++ let subscriptions = subscriptions(&client, &ticket, market).await?; + if tokens.get_user().map_err(|_| "account-check")?.username != account { + return Err("account-changed"); + } +@@ -162,7 +205,7 @@ + for (reason, count) in &all.reasons { diagnostic(reason, 0, *count); } + diagnostic(if all.skipped > 0 { "partial" } else { "complete" }, 0, all.skipped); + return Ok(json!({"schema":1,"account":account,"market":market, +- "checkedAt":chrono::Utc::now().to_rfc3339(),"skippedEntitlements":all.skipped, ++ "checkedAt":chrono::Utc::now().to_rfc3339(),"skippedEntitlements":all.skipped,"subscriptions":subscriptions, + "entitlements":all.entries.into_values().collect::>()})); + } + } +@@ -200,6 +243,26 @@ + #[cfg(test)] + mod tests { + use super::*; ++ #[test] ++ fn subscription_membership_is_separate_from_product_entitlements() { ++ let body = json!({"entitlements":{"CFQ7TTC0KGQ8":{ ++ "status":"Active","endDate":"2099-01-01T00:00:00Z","autoRenew":true,"private":"not-output"}}}); ++ assert_eq!(subscription_rows(&body).unwrap(), vec![json!({"productId":"CFQ7TTC0KGQ8", ++ "status":"Active","endDate":"2099-01-01T00:00:00Z"})]); ++ assert!(subscription_rows(&json!({"entitlements":{}})).unwrap().is_empty()); ++ assert!(subscription_rows(&json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":"Expired"}}})).unwrap().is_empty()); ++ } ++ ++ #[test] ++ fn malformed_membership_is_an_error_not_an_empty_subscription_list() { ++ for body in [json!({}), json!({"entitlements":[]}), ++ json!({"entitlements":{"unknown":{"status":"Active"}}}), ++ json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":true}}}), ++ json!({"entitlements":{"CFQ7TTC0KGQ8":{"status":"Active","endDate":"invalid"}}})] { ++ assert!(subscription_rows(&body).is_err()); ++ } ++ } ++ + const A: &str = "9AAAAAAAAAAA"; + const B: &str = "9BBBBBBBBBBB"; + const C: &str = "9CCCCCCCCCCC"; diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py index ff7bb15c6..30afc1b01 100755 --- a/tools/xbox/prepare.py +++ b/tools/xbox/prepare.py @@ -9,7 +9,8 @@ import sys recipe = Path(__file__).resolve().parent cache = Path(sys.argv[1]).resolve() lock = json.loads((recipe / 'backend.lock.json').read_text()) -identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + (recipe / 'droiddeck-credentials.patch').read_bytes() + (recipe / 'droiddeck-launch-view.patch').read_bytes()).hexdigest() +local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch'] +identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + b''.join((recipe / name).read_bytes() for name in local_patches)).hexdigest() source = cache / ('source-' + identity[:16]) if not (source / '.prepared').is_file(): if source.exists(): @@ -29,8 +30,8 @@ if not (source / '.prepared').is_file(): file = nexus / 'assets' / patch['file'] assert hashlib.sha256(file.read_bytes()).hexdigest() == patch['sha256'] subprocess.run(['git', '-C', str(source), 'apply', str(file)], check=True) - subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-credentials.patch')], check=True) - subprocess.run(['git', '-C', str(source), 'apply', str(recipe / 'droiddeck-launch-view.patch')], check=True) + for name in local_patches: + subprocess.run(['git', '-C', str(source), 'apply', str(recipe / name)], check=True) (source / '.prepared').write_text(identity + '\n') assert (source / '.prepared').read_text().strip() == identity print(source) From e3246b7dd5e8e5f3b9412dac13f4287256873d7b Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:18:44 -0400 Subject: [PATCH 061/156] feat: ship proot, DirectAudio, Xbox, Comet and hand tracking as CI-built add-on packs (#1198) * perf: download hand tracking models and native libs on first use * feat: build and publish add-on packs in CI, add Xbox and Comet packs * feat: ship proot and DirectAudio drivers as CI-built packs * test: hand tracking readiness requires the tracking pack * ci: attach PR test-build packs to the PR release instead of Components --- .github/workflows/build.yml | 22 ++- .github/workflows/pr-release-publish.yml | 22 +++ .github/workflows/publish-packs.yml | 83 +++++++++++ .github/workflows/release.yml | 12 +- app/build.gradle | 46 ++++-- .../assets/handtracking/hand_landmarker.task | Bin 7819105 -> 0 bytes .../handtracking/pose_landmarker_lite.task | Bin 5777746 -> 0 bytes .../main/assets/models/hand_landmarker.task | Bin 7819105 -> 0 bytes .../main/java/com/droiddeck/launcher/App.kt | 4 + .../com/droiddeck/launcher/core/AddonPack.kt | 137 ++++++++++++++++++ .../droiddeck/launcher/glasses/HandModel.kt | 8 +- .../launcher/glasses/StereoHandEngine.kt | 15 +- .../launcher/handtracking/HandCamera.kt | 8 +- .../launcher/handtracking/HandModel.kt | 8 +- .../launcher/handtracking/HandTracking.kt | 7 +- .../launcher/handtracking/TrackingPack.kt | 56 +++++++ .../launcher/runtime/LinuxRuntime.java | 7 +- .../runtime/LinuxRuntimeInstaller.java | 3 + .../launcher/runtime/ProotFastPath.kt | 3 +- .../launcher/session/SessionFiles.kt | 11 +- .../launcher/session/SessionService.kt | 3 + .../droiddeck/launcher/stores/gog/GogComet.kt | 27 +++- .../droiddeck/launcher/stores/xbox/Xodus.kt | 25 ++-- .../com/droiddeck/launcher/ui/HandCameraUi.kt | 19 +++ .../launcher/ui/SpatialTrackingActivity.kt | 4 +- .../droiddeck/launcher/ui/StoresDownloads.kt | 3 + app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-b+es+419/strings.xml | 1 - app/src/main/res/values-bg/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-da/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fi/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-hu/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-ms/strings.xml | 1 - app/src/main/res/values-nb/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rPT/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ro/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rHK/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 7 +- .../launcher/handtracking/HandTrackingTest.kt | 9 +- .../luma-hand-tracking-investigation.md | 2 +- docs/development/luma-spatial-tracking.md | 2 +- docs/third-party/handtracking/README.md | 16 +- tools/build_hand_lite.py | 4 +- tools/build_local.sh | 1 + tools/packs/build.sh | 92 ++++++++++++ .../tracking}/hand_landmarker.provenance.json | 0 .../hand_landmarker_lite.provenance.json | 0 .../packs/tracking}/hand_landmarker_lite.task | Bin tools/packs/tracking/release.env | 4 + tools/release/runtime_inputs.py | 47 ++++++ tools/tests/test_runtime_inputs.py | 73 +++++++++- 72 files changed, 710 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/publish-packs.yml delete mode 100644 app/src/main/assets/handtracking/hand_landmarker.task delete mode 100644 app/src/main/assets/handtracking/pose_landmarker_lite.task delete mode 100644 app/src/main/assets/models/hand_landmarker.task create mode 100644 app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt create mode 100755 tools/packs/build.sh rename {app/src/main/assets/models => tools/packs/tracking}/hand_landmarker.provenance.json (100%) rename {app/src/main/assets/models => tools/packs/tracking}/hand_landmarker_lite.provenance.json (100%) rename {app/src/main/assets/models => tools/packs/tracking}/hand_landmarker_lite.task (100%) create mode 100644 tools/packs/tracking/release.env diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2e346f6b8..b063bfb07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -539,6 +539,9 @@ jobs: rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh + - name: Build the add-on packs + run: bash tools/packs/build.sh app/build/packs + - name: Build id: build env: @@ -642,6 +645,14 @@ jobs: retention-days: 30 compression-level: 0 + - uses: actions/upload-artifact@v4 + with: + name: droiddeck-packs + path: app/build/packs/*.tar.zst + if-no-files-found: error + retention-days: 30 + compression-level: 0 + - name: Report on the pull request if: always() && github.event_name == 'pull_request' continue-on-error: true @@ -744,8 +755,17 @@ jobs: variants: ${{ needs.build.outputs.variants }} secrets: inherit + packs: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + permissions: + contents: read + actions: read + uses: ./.github/workflows/publish-packs.yml + secrets: inherit + announce: - needs: [build, sign] + needs: [build, sign, packs] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index b55d2a214..dab96e015 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -176,6 +176,27 @@ jobs: pattern: droiddeck-pr-signed-* merge-multiple: true path: signed + - name: The add-on packs main has not published + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ needs.authorize.outputs.run_id }} + run: | + set -euo pipefail + gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs + mkdir -p pr-packs + for file in packs/*.tar.zst; do + name=$(basename "$file" .tar.zst) + sha=$(sha256sum "$file" | cut -d' ' -f1) + tag="pack-$name-${sha:0:16}" + digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } | + jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty') + if [ "$digest" = "sha256:$sha" ]; then + echo "$name: served from DroidDeck-Components $tag" + else + cp "$file" "pr-packs/$tag.tar.zst" + echo "$name: attached to the PR release as $tag.tar.zst" + fi + done - name: Describe the change env: GH_TOKEN: ${{ github.token }} @@ -233,6 +254,7 @@ jobs: first=true fi echo "first=$first" >> "$GITHUB_OUTPUT" + cp pr-packs/* signed/ 2>/dev/null || true gh release upload "$tag" signed/* -R "$repo" --clobber gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease keep=$(cd signed && ls | jq -R . | jq -sc .) diff --git a/.github/workflows/publish-packs.yml b/.github/workflows/publish-packs.yml new file mode 100644 index 000000000..ca8be6ea4 --- /dev/null +++ b/.github/workflows/publish-packs.yml @@ -0,0 +1,83 @@ +name: Publish add-on packs + +# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under +# content-addressed tags (pack--). The APK from that run pins each pack's +# SHA-256 and downloads it from that tag. A tag is created once and never replaced. +# Only main and release builds publish here; a PR test build attaches the packs main has not published +# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes. + +on: + workflow_call: + inputs: + run_id: + description: The Build APK run whose packs to publish; empty for the calling run's own + type: string + default: '' + +permissions: + contents: read + actions: read + +jobs: + publish: + name: publish add-on packs + runs-on: ubuntu-24.04 + timeout-minutes: 15 + concurrency: + group: publish-packs + cancel-in-progress: false + steps: + - name: The packs (this run's build) + if: inputs.run_id == '' + uses: actions/download-artifact@v4 + with: + name: droiddeck-packs + path: packs + + - name: The packs (another Build APK run) + if: inputs.run_id != '' + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ inputs.run_id }} + run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs + + - uses: actions/create-github-app-token@v2 + id: components-token + with: + app-id: ${{ vars.DROIDDECK_CI_APP_ID }} + private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }} + owner: Droid-Deck + repositories: DroidDeck-Components + + - name: Publish + env: + GH_TOKEN: ${{ steps.components-token.outputs.token }} + REPO: Droid-Deck/DroidDeck-Components + BUILD_RUN: ${{ inputs.run_id || github.run_id }} + run: | + set -euo pipefail + published_digest() { + { gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } | + jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty' + } + commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA") + for name in proot directaudio tracking xbox comet; do + file="packs/$name.tar.zst" + [ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; } + sha=$(sha256sum "$file" | cut -d' ' -f1) + tag="pack-$name-${sha:0:16}" + digest=$(published_digest "$tag" "$name.tar.zst") + if [ -z "$digest" ]; then + notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID). + + Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest." + gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" "$file" \ + || echo "::warning::creating $tag failed; checking whether another run published it" + digest=$(published_digest "$tag" "$name.tar.zst") + fi + if [ "$digest" != "sha256:$sha" ]; then + echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha" + exit 1 + fi + echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8fe92060..a15159268 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,9 +63,19 @@ jobs: artifact: droiddeck-release secrets: inherit + packs: + needs: find + permissions: + contents: read + actions: read + uses: ./.github/workflows/publish-packs.yml + with: + run_id: ${{ needs.find.outputs.run_id }} + secrets: inherit + publish: name: publish the release - needs: [find, sign] + needs: [find, sign, packs] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/app/build.gradle b/app/build.gradle index 2e269ac50..c889af588 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines() .findAll { it.contains('=') && !it.startsWith('#') } .collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] } +def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines() + .findAll { it.contains('=') && !it.startsWith('#') } + .collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] } +def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name -> + def pack = file("build/packs/${name}.tar.zst") + if (!pack.isFile()) { + if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs") + return [(name): [sha256: '', size: 0L]] + } + def digest = java.security.MessageDigest.getInstance('SHA-256') + pack.withInputStream { input -> + byte[] buffer = new byte[1 << 20] + int n + while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n) + } + [(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]] +} def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json')) if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) { throw new GradleException('Desktop removal inventories do not match the pinned archive') @@ -119,6 +136,10 @@ android { buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\"" buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\"" buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L" + addonPacks.each { name, pack -> + buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\"" + buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L" + } ndk { abiFilters 'arm64-v8a' @@ -172,6 +193,11 @@ android { disable 'ExpiredTargetSdkVersion' } + androidResources { + // aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs). + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:_*:!CVS:!thumbs.db:!picasa.ini:!*~:droiddeck-xbox:droiddeck-comet:directaudio' + } + packagingOptions { resources { excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] @@ -179,6 +205,8 @@ android { jniLibs { useLegacyPackaging = true pickFirsts += ['**/libtermux.so'] + // Shipped in the proot and tracking packs (tools/packs) and run or loaded from there. + excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so'] } } @@ -284,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) { if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir) tasks.named('preBuild').configure { dependsOn(buildRustStores) } -def verifyHandModel = tasks.register('verifyHandModel') { - def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1', - 'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8'] - .collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] } - inputs.files(models.keySet()) - doLast { - models.each { model, expected -> - def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString() - if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}") - } - } -} -tasks.named('preBuild').configure { dependsOn(verifyHandModel) } - dependencies { - implementation 'org.opencv:opencv:4.13.0' + implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}" implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49' implementation 'com.github.luben:zstd-jni:1.5.7-4@aar' implementation 'org.apache.commons:commons-compress:1.27.1' @@ -319,7 +333,7 @@ dependencies { implementation 'androidx.camera:camera-camera2:1.4.2' implementation 'androidx.camera:camera-lifecycle:1.4.2' implementation 'androidx.webkit:webkit:1.12.1' - implementation('com.google.mediapipe:tasks-vision:1.0.0') { + implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") { exclude group: 'com.google.auto.value', module: 'auto-value' } diff --git a/app/src/main/assets/handtracking/hand_landmarker.task b/app/src/main/assets/handtracking/hand_landmarker.task deleted file mode 100644 index 0d53faf3786146e95c5bc010c48029ff16b7fa59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7819105 zcma&t2Xxa`{3w32Hw3~8Bq0e|PHe|Rk|kN5aYER8?-6DoAqkXCpzKkg%rHZylX%Fs zBulpJ*cm|Ct3cVa%(kpDS}6aU(%<8~|2glRr*l4$tb4Dn?$!5RDUKo#4C>pcaYsS@ zGP(Qy5t++7?~SE;3hLGGojG~lw29NwbJM5hW@bxrr)SK{O%Db8ho$yY2m~Dj5rR-b zSCBJE&|1)3&=~9yfIR|10bl~m#ZdwtU>!XLjlk!6lYn@jF+hF!@+GXn*X?%TGdOPV zVJQ>)rwr2@^~3an%OJhmf9WA0Jv)8cf~o1#CT3-)Po0%BD|6n&oCR4~nc2A@t@(fF z&>U=s{8uIzY|ouFZ({a>dAYOZrcYd$o?Tx`&=BnJ4)TNo;eY^~bu7UUlqC3x8n zqyvKpkUt5ew~G=)Gy?e_L<;h*L<+K)NWtCmNWuAykpkWFNP%=&q#%Nb6a@8*6bOnU z1TQy62=2Cs5S)M6L*UQqAs|Qg5ENVu7i{;13-b7IK^7k-n6xQOFlbYEfo@cHfpk=e zAR;zI5ER=*Ab8tZ@bXPZ!QDVd!TCT3fqz>E0l6(mP%u77uzh@6L0)28K~`dG!K9C^ z1cN@d5a>QO6G)FY6GR+uA_&^uL?GDRNbqt)Bf(v=q2N5(K;YloKtOhBASn1pAlSY_ zAjn$=nmkG%hyc!y1fKyZaCZbq2Xw&8;UErxL4bc4NCygr3Istw-Vl&J7{mf_eh|nH zkOM*b08qX^Nbd)Z-xs9!0p)vx^fXYu7pN~4lurTID;bp6f%-I{JOPf6gYqaSkAU(J zC@%-)rJ%e7lox^W37~vDC?5;TM}zXbC;;{W+kp*09mRz+K=Pa2_}T_yHCm0TWOF>;tv~8-P4u5s(GU z1SSEafI&bSpaW1q3d92uKvy6LXbuQ~56waSz!Tsua1A&QoB;d)3y^>bC;;{W+kp*0 z9g%mvzOM7MpZ-g$-&Fwa6=3)Z zvL!If@o%*mnV^}V*82Lul=1&Nm#^nt-=9JMTTjC{TL||F*OLf{ff*5osVt#MkWgH)XIio$z%$fLqSLZRtO>zp=h8 z^=Db%jx4ah{u)dH>-hia?1-c!a52&+XHT7(G#Q*xZf<&Z(!6=;xj9L>nVA_mNmDcD z{deQX=cH#(&XIq8keN7T!7OkZ^Mny%apr`a>YXi!q8v7d~{+n6A$N!f4EBSRq)sMjlu(y7y-JhQi?&Jb( zeIR{}>H68^e~V2Jl`X;N`Z4^qbbYLT{ahap_4C-Ge@lQh7&Guu0!?(Dt-&e0Wm=*pfmh0!-uk&t8kiWhc>PO_i3+M$f8&Ce)pWsnLFg;6#OrAP* zdX_>iNceAm5ZEu5$p4-9>+{zy@uO~j5dl&F0#JYebbt{^1Ns4jfMLKWU>q;n!01wb)i0?YsjPyh??fFGy@P5`HY^T1``8gLW1 z3p@m#055=-z+2!0@Cgu%zWJpw&>UzD1OXj^u0SXd0Yn4wfC!KR5P$*%paYCR8qg0I z1PlX40poy4z%*bckO5=?xxgY|8IT980X6_zfbGC8U>|T0C;*B96JQ2NfC5;62mC-S zZ~{0DoChug*MOVAUEm?`1b6|w1l|H4fKPy63}`>l9B2&$0Ud#^KqwFaL<8}F2#^90 zfC2=d1B^f#&<_{{WKaDXUuht2G!CJa)=|2-iGwXE*8a++g-WP>`H+Ohm42(;`Vjg} z%p}Nc@}vjGm&SH@LiA1dY-qcCVQ~vCR#ZXas2_O(D=qJ!WB(nqek^o@JnWVKKG%=y zf%FK8R9+S;lyty9N^nm{VN7ugVF4pxA4TkDKT7L_3z037w^F0VExJ{6E57;RM)qbQ zN25Q9A6q-c43#E!i1Qq>yb{Weu7-xXmQ%69&WE^|zY_Ap+Cpgt5?$+N zBQ+5_9QUI4^EO2p$DuR%3>)0DQNdl)&vB3J52Hdw`EJf?cgvz)DaIm;q3a<{W2TVJ z$w2qZVb>ry!bom#J~%o#ELSe$-@(tYf!GEbCErUM#OH-Tdyeg&786`h6}PCQrE*>Y z>bWYk7DCdi=BYiyJ#+Bucx~mp#3B0+i!1T9`dbIvNA2PoANbs(oV-8nYk$}GMF`&9 zv!%8SAEc6_gNc>seYPLG9%`Xmhb;LCA#}u2y1oa~7@Ep;YM znGb7vC+{W1I8X*+91%6ujy-?bK;ccP}*_$W+2!3 zTKy~9q&7{viI{*d#jg8R-Vp6yM7v}(xs!6SA+c&ja*ycW)4%n_U!L~VW=v`BEEEu3 zxzz5W^5bzgrG}*Gc3pKBY2TPT^1aOYgsFQ~5N}sTyv1&tZ&`cv2{yH~G!vy4&5^}u z8l#<~dgnLfUMt=zZpJ*6bfC7-$@2N+C-^&Q=mEdEb44chd-!qN8bLDAiLo3BIRIB zo0$3NaA&DJH*t@)Cyc|-)&Hnpx<_n(U&%&|C+6G#n*OaXgk)#t6x1OFqnE3OEAl;u z6as%La#o!zCx~b#L9g+Da)>2ahNZ+)-30$OEvu?P-$Q>of2rz0{NVCp9gJ-x21sbM zY3k6ynAaDk{-C#FLq!5zL;mG)KgMHesv`tsFyTGR@m{lP=Rms@9PypzDtT5piM4ba zgKkw*$7gD`D~04`#X3c@YzP;h+ALy>t)Kopy4u##_?sl3kR}}y{Y=b8OEpufV$kQ_ z8Dt1{$lg;kMeSEk$3?RJ%uBz>roxl3KcTad4w}2@5?L$5Qda>&X^wK@o`W>s`;KXP zYlCznRLvEGt4>vQ*Oe+~swIl1Gr#pkj;p8p6K=)vV!Gr7?_#fNgRqTMzN&laeQt$3 zNHl?EU1RKZy8TIJ_G9HM+kSbWV!Y(8Y;5UDya{=l{JSazl~O$`UXV}Yw?R(qAsoWs zLWwfp+{Sr1p^Nr5s*%^aqu56fgPt)rGLFXgD)Z44R~=eOEk;|>k0BLzqhgc%1^dn| zu6pNcskD>rWvlGjC09-PQeH9EcF=OcH4H|ajU5K2Ow2JR+a~o=^jM+6wHaZ^V)keI zdew4iBU3N73p=9fSV@XZ0pAibOawd7mhPKLJ!G!S)B+PbFkmGQ4w! zv=3#oc9`|GFRp>Pbb$T2)ROE--mUx%`iJYw%y-|W|8h5G=W=G(N4TxBFWp7dg7nAt zGNc)@krB`qE}PN0eau6544ca~b7`SU$V_DyrI0yAc7xT>Ncm17=5ABn-Se5AZnZ0y zf#}|fD7nbN#y7N_vt}h{CSO6i!U{HmTFy0Ker889BkALg-$Y~Lt&Y;-x0bfiT4SoQ z5IF*8(l+~Y_K8E}%BPl7rMC77n)v6|kr8<$9@o;?&UjCLQ})Jj&9;SVM=o^++ZWm6 zZE>O%q8o(`CGY-gWBv0Ik#JKyg`DLqW*V?R@zU`CrAq$wKVY zi)RU?Az?>P2*8*U#b(lCF+rWxhhm(HT^vi7E26d)YOFoTvP?V5-ntc90z^4<(KknH_Sn zXK^E9!?w-Qm*X!v=7sJlYbTq^oGa)VN03s>JacJc+MaCgUW{M#=_pZ|$E;mjbB}nwV0-d={@%`BI`7_80>!+^k4;-?57v8Nn zz2HG16uJO;Y%++u_gEkIQ*?hvJPC<9k)`Nl)JH9~mzQchM`&H_cZHMen=3t{t=8W3 z3&=gcqv5b6#7adY`8*K^WTboKMt)>oCx~!zM zdxTG#7}YiYRpHZ^ma#mij_j84EfyN7&giqWKN^=HP4$NiQu-2~tq>U!s=KJ0=o;!z zDqh;g8dmx)5?64CK2@gS!d(aSj0VCbL=7|1XjA>BouYMW#r%a4~CTM>T&qhsGGS@d;{}S%(k<(8#!O5Mf($!Jw=W3t2 zJ33D{vFe0znFUcQbPW=FX)5?3IuQ}CTgi3}wBcvhPB%W)zwybGdv(KO@2dh-zTqmC ztzEDBgVj^3%dI*yeid!bl;aEWS}caRlN?+(>1bDo2t2|3Np4uZ~2D#S{dq;HdSj&NA+KthpL|Vk?L$Ot!d(5sfB?Z zaASWT^=WjmwphL16sj30&qA_|El|o?tXtxmtG%huPkzV$%uTOp!Ut&^D5k2rpnJ6W zDT9)?X~XOv(C2Dr9aNpE8lY~O$7H@!w6}IKe$05>U9Ing;C0`tpZVJ18;x75jeP99Z?Uj?W81i{ zOlac%%4VV#72_d|cn)lX5L2V#YI1+)Y|FdEZR$E@TxG1PbHRfoJE|l53Y&_eu`N;6 z_Auva^g6N38flppv4)&`=ugvIXRu_O@|5Hmq>Eb}A1Z4pGM8>k=$0_9TxuQx2g*m< zTk&H4Qk705jB0NG8CzuQOi8RoWShhx(k>Obs@-e|XP|nOUl7iYopOXK7MPn^Qe>Z5 zN_J7&zVj_9joGbvN1DjG**#E^eQre?`DR;Rbm+lNM+c+fDI?_+Ms8GB3P;j|tt%7!Jvzk4F~2Gt&SmC=!gN2ftrQ5*84V}B`IX(_Tx zKSwU1qD#&}P{qvnftFzosVPPLIleO$EIV4Vz|AZ3>46C?^BWZp>^Z)uOQH2pXu)#J zlFGKEEbA;KkRi@qzESnP`7Dm;gKD`!PAl_N#-i-y?5vJyuV z=;e_!mK)H91Szz`VJF?)DCSr5T)HngjU1eiY7enJj2IhrkIO0_P-wI|50@$P(0qr8 z`e5r>YH*DAdn_R)iztO`hT!JrmIKN~@N)Cb%EMT3^aShX4hl=hG9|aJ>)%!%`%-aH zvMM@~InKgJXyq(fTk4+lA+9N{F4>nL&-xY%Q#BvaIC!aXJa6|7q#IFb+A)Ss6+e0V z@;BHYpgi4heTM!gT@bNal@^HgA7n#ds``lUJDpHHHf|lHhMXy<1B0Qrp5dC4`s-GY zYmspU|GGk`e&u?gu2El09-^L2{3r_7jPmst9j-d&4uO(%u{f*Mz^A3D>OJbF+9Gr& zb}&L0nC(p_?o(~m-3@7w02!~zLk^GuO)Fg|yO5F+&Ge@=i;b%dWBo?&SmG6W1GiiI zb)J-5toudTLmjReNnG;`Mx?5#>b=e)&W`q1IhBNcD^llcOg-@z=x53X`wZ>?d_z6e zb4hbkR}|0^CU-OQ@xmr-ktPVsz%o6fv=XUH(XTwdW(b`LFL1wBPj|_Zm5QfmTf;n; z%^OXhRu6-(b1{Tb-A_J+uxPG8AF%CE6ApoUC4W>uhFf~hly_FYG96Y$ss_mu&?Yq- z`4lY7u#-&FoMoFq_e8VRpDh`hH-XcNz1S>Ox!$5_gjk7EekrZg1`HZcH?BR>OudA6 z@-e8+@I`&w&X_`A46Ffz5b}2^4>$>I7U80ugU%v3#cnS zp-IA3_5nGQydaAvr4F<6A8Mc^zBrkdNKRv2>FMSp?@hqNFA_uNgB+Ik`ZJX z--^kWo1L2-QvMaXpE+yiEGwKjju&(<$6a$vZ6|B4c^UbT+Haxv^7QrcHI_;`lRWM? zI68*+#~ZZiY2xaQYFcm*4fLcM@J?fErzGl#f}~JYD=-Oqj|s6mVv0yBJ4zOT+R;i8*9A=_R|<3Gy+vx7P1ewDSTL1pi!YQ0|WH zg7uWYr9#=~A9xhJ>PXWaAb;a zz559i2RC*H7o~eXi#AFFbfx#ccnuK-pXM9b%DvtBE_^%w3O|+K={1=%%Y^*p%IWSr z=AM#uSfFL@UF-?pB72{blO^dsxi`wwpABMHxZBu^y{+Bv*@^B7x5S&uzm>dUj`B9h zO$Lj$aydu}f7>1Ezr(KOOFVPkN$xQB5$*wJb53JL?rYXLo{9WVPysu;DuV3FRg zkhSbBuOxbTbT{@#afvg+%eiMWyV-q75lkx9iyKCn*(MH`CzgHg4t8Hg;#kxZ<~r@U z%f*X+U=Ff08$?N&QS3^7jU$)2$3K$`{1ex911uBX_}D>J8)fu+ijCu70Me?9f~ zckFk>4LmvQC%TVvGj~M3ja}kRb{e6Xo|B%Po-pxCcErMOu|R-%_qqSK+E0e#De?Xu zx(m8FDQ)>V#ys5^W2=;HM2R0w2@4D`{-H7JBj5$7+VIf%Qd^g*=6Kn6nxtM>o!#~+ z71150PgGay$E0eo8{SgwYvLF2jAWTM)YvvHs&=;SM)4#6nBJZ1{!lyh3p8QLBjk#d zL%O-pX7|zLr?txJBiczR=kXDIUCpH4ovIRD18Pp<8{LBccv)!XXFy|Pli_sNVwL?3Qe@)Nz)yUH*#d1p$6zFXS< zsziSYE$@|;$|aBVW}-QDQ*|%(yNtAdoUgxUiux@NwAlQFsG zvU-zhDN$G#4tEM1ss1~4Xs;G2|M(KsO5{okSAL_{N}tI%Umw(WFF(e$p;tKbi~pHd zi8Tbz{6o`zOxa-=*-KJA)jG;QWzn}-$jAS}TJl!uHatz*9;<_nxHZgjLQk}iKhbLW z5$+>cdyR?HaqA%vn`+Lxk9c}?IsQVp2;0JMfM4(@kWAR3<<-^Lb<6^>lG=*F+E#ot zIt=-n3QDkh4ry?FDi-YR;O&mIq4JcNY9LxD|ELFZf~pT1hc>j|h4hL-OzyhsiWH-q z&(o1##Qx=m(8o|Yaadl4&(RKo=P7e7qj5@k(sQQt6EVPRLi?$;-Wk|k=?Oj*&1PG2 z?_8~zanL8-HC|2>AXZPC!uCmfyua&S;tkNv+zEJqD#X*&o$nh!OyCY;qu2xRJ}3)9 z;9^*f)+KG>A8}P|n&$!ZnIT-mv32f0>Bh)T$HuA!_(g6pGEr%OBe=c%PgE;NDCvuC zRoeIi?uE!nrj$SFe3XEqPrRpWW>>neO~h9DDlXr$pIwARQjzWn;*$^yzUye_dXL^$ z*82Wnw^4(rkM1y43qFN@&oTTcdz4scf6QdNK4a(UwGJVBgV{~BXE$0S;6CtU>rGF0 zPD-srCeR!_!+X;?44%dZ(G5Hpyaf|N9~55K6iGO}i>Y?cC>r8*!zJ>ASUcG#^HgUx z_fa|~p zRUVyJ|F|l}yVbvksHXXvoq^MV41GIwR@G)c&AZxoRVUIi{>g@czCw+X7(kvOd*cJK z)!r%E8uoc@+Z4Wfo!`YM^v69tyv>pg-c!07{G!LEx0r@_&s4kZHN4qt^L$iYEGp8u z4R%8`u_N`zK-=1-hB^LM{u8=GuBkO={Y|Pn5>KnUr)Z43{gAP^`c`jA%9z^UQeGza zH1=d~`?cO3RdZ_(s$LsXnHegTdY-MJ?+EcEQ0;%hRZDIf&Yn!JYop& z+ZO-H)(iK!KBcTNC<5mV*Q-DGTBLTlTcp^$ORA;>bjG6E{@!1!hHGvaox}mUef534 zqt3*QudXuQ_GfuF7;n|Kp<2`i6L*dWC--Cac$;XZsSmj0s_ceP$gJ^uuJ~bHjAMLY zm;tLj-m78qRqCxVlr2lS#B{Gc}r~=6WI3mB>leC!faW9$V(#n-UQ?ij6$+06k}1;znzG`aioofkgeWz&w9u z@*d;mnkh9N)q3qX_ib#+vTw04Np?@VUH6<(I;{L{Hpt`WWzx;)aN;=MNpS+6s66Nl zATx=lu&)G_&1T!7PQ}!!5zGj0B(;giqtnP_=U}97sl+v!+sIAFjnEZO8Ff_Lj%n`= zu9)xMt^V6ONoiD#L`Ojjh%C9YViLPfUWcuhE1)NYSvVOB;wLb_((_#-9K+Ec7>}b3 zb00a!UL`&3YJMT~luc81;2$zSx`SLD)EQh;qzyzXM&Yw)8zw-Pu(MGQR|qY%=+P#k zEX4>*YxOH7_?`t`D;w(V?OQ@Ol(&@pg=-}%`5@vRQw|SPb<%8QAnto6j~AlHT?e=a za-(O6SAuJh`O5K1lrD9iGJ6v1T-U?0JmBexD0ht#eJ_2$ZY%mME?4e04d!mTtC&z| zB>Wc2@Vc!TxUjX<@q`@&Z9iH{CB*Fn-*t|l=Wzw}C*~$IOSGG9#XNI!E{EwBJw~y} zvD%eP&7l8^&qWU-!>LR0^JM~8GS{1%BR$VL<=0%P&>XIkz3)i2oTJ{m!nrcdS;(h0 zmQ7&}lEIwXKEpB`nIFuiTtyAk&pQ@8edW?~$_B{O*dk62|CH3xvu?$=SZIq6Oi9%bEDttL_n+{H z)Qwax{Vl1sz8CxmVFWXqTF6i;9tad*9j!A)kA&D{E5`( zI#Xl{@MA0{7CjRe$oFSMOy=T_^0WH{QPxFV#gNMZ~HalkOM2BA_5j zyv;m@KtNdtrPNBW-ulb3wA$CI=3Z~r65_S4UGhb220ul0UOmU0r`zfCK#O&43b{ZA z+_rX~w2rSLy1{r&cywRYQ(r%0JA&i|bV=2AeM^fKZRJ19|BC$1OWaFb&FPzZL}w@F zqGOUj1VRE${Wb0bHHB4wFj(z<_d4;8@|)%-hKtaT$+M)vbpzcR^^3qeeM$BAY?EGP zh{E5#u6tD%B%8PdFH-Ncx3Gr7KPcyWf)u4XM1MvZTQ$J&I~L6it=>$1@wyOi@@FzX zSrK^P8ye8-JM*Lc4{L_-8l<6T1$IpntY7cyTh+$>IWSHu@*lwOx|0G`brM5?0n9M& z!$w+-8Rs|20pEJ1v0(j zcYmSk9M@U#W8Sw|Sgm}9C8F8RUh)}O5z*1n2_DO~LK~o|%ExYzJc8&9U38zIJ41cY zXH~R33|{YvQ}mD*ix#sIIE|s9Ue2Rj54LedrhTxP@LcgMk{zSBvAeK2NqvdOilOu- zCWSmCn=JoeEl}mete5Y%z2@8DPwnZRNzi2Ggqx-YiUvuWLRai@;x=ke!ce!{GtRol znnOQDBDpISKGdpkl)YATWg23wl2Z5)s;S%ub~z+f4&-7LE9C-PyZ8l8uWKh2&CRMD3G$^LjyaOu6v`1dcVy(NGgH(VjcjQ~d z%vsA7r;uz}A$DAqrMR#%v#g;*SQ6!0BVER|LU$yMbM=-z^~j|A$fj{ocA;vV`ZC;{ z(=urkN#BQK>5Q1?GQF%NhA4wLtGh>16jbY4?y98LW9^iUEm}_;`Xd+V`k5T=-bj_% zZ_;z=V@wxP0B=Ozkl(@0JVLfe;pKkvzDhW}`dcjgq!}LIP!S#y7?$kPXLDwKl-j9I z;b$ez&^vvPYEJQgh>m03_49qBm=;6>^_AGVsxD_fum8^^b3-o>m=%lSQq~Zs5P-b^*}Qj9ieOJUR%}3ak;v&Gb51h zy%!j&-l6fZ^VK8quF^(GvFuNKrOvJU5%gC$dq%g`7v;SHk?q<8vzW0?paQ{*fMebqnGrwaySoE~dQx;oe(%g(?^i^N(leaNp}X1&-;S1Uh0* zq3&F{FPdh&xUT@)rrF6R2VScB9@BcqSDk>`)&;6pTjp_Zv`sZo)iCu)ViHl}`>1ch zMf)jC=X>J~uUX~q?;V8iOJezsc=wt`#3NrT=MG(kyF0vuZ^7MA;Oa4|h~%HJe08m1 zn>UwM_?y+8PPoX46f-nMRl&M85fA(+9O1jJo0dGgrlm>-cks+4NcWcHcAg+#AJ^#W z8N|f+t~^YnqgcsM3_9 ziHbGfVqvs09Qj*yxj4($N7*)PuJyQVFos|vb(;GMWF#)?^8FjpsaPH}7v8HZkWZDB zs7A-nfG!}*;;r~2xm0yAF^_JCmdJ-Do-F>1*P+|kq2k|4)6kaG9W2?k&sE`CW?m-V zph_Sbs$L!XhkI|&S1#6%m2OkxC3E0$$^g=ryVQu<9=|9$s^Ja*r$pdheJIFHaRXRV??{Lu}pKPfd4F_ppGi5#iiQJMsmTDU$7HI z47Cm(&Ki^})MM#8j&!WHB0@379n<5Qn4!*6C!ELVNBB$eL#VCi5%af8g1%r{Vpq_a zTs(hbU*n1y;5(%UatkxjzCWon>aT>h?r8RC`~%U?>i+aAHc}^HCvq0MAIeCgv5)i^ z#~L~Vdw{4bCnj*__4spW1-(-mE4^u{Kx62o_E6Pw6_ce!xB5U!hxK zyhK`)E7T3(3PY@}>50Jx(%6LPouWggYqT}FI!^rup+pPRndU704Zq$Wo0_ferkVQo`i zXZ9R1jVRK`c|ZC|!#VGHJI26ytnpQ-CU*=@8uaCe71F!-9NiTDm4A!wSJgnzai)q`rEg{=J-EBvJzRaW>W;BS)79Su z`kjyQ^~X!(PdUtx$gT8Es%dSk)XhLN-q_k>#BBE<_=JBWk*~7`dKyH!x|*(OAJoqc z*~cCdKEr#AQjbvQ7|sNAaAIJqaliH*`xJSAtggLBd;u-)s5%p9&5sanQ;DhqYHy9p zUm7@!|H!F7Ncs^o^-)l6-?5$xiqGpbwb#Gz@(JuDB+k*5o9C%h~y$0H*@S-_ht%FfsUY7T$Nk%=~RC=NQ>#k$FwO!HEY zMWdk9QK&KLUFBNm5qqnmXk@8%JsDsIgYRZLvS*`v+b1~}s#Ze+hsN|aD%mj_?hYRo zKBp?n+LY>Jt~;C+Ep0*g9k^IwHb0;q7pt+yM-ujn%HY*W>nsGc!~T##;gr~5>lOQL^Fi{KlT5r%r^f%|N*C^O z4WpTOIO&Ku8qtLcrD?KXtvXAFZ?t(-#m2JnY%lUd#g5o1meHj52w`rAG?mV<*U@X( z?#@DCUwJ?2Sm=iImQ{h&0Q-MCU!D&I(D%cr2z?J-b0ijjzURY({dgzms@Lx!>}`zh5*I9;|u zP(b3p@;VGffDOX_8X z&09q2(%FiD1pL`b(imyuaM+eOd`yu06_bB(lKdaV2P9S+<9_H&5;jL01f;U$irx}8 zGTs)?EjfA)?WquP%lMCq<&M{u8Qr>a7UrQO!atHMXB)sM@|T%`Mo`zce2WFCvY&h< zei1#x=+LJ0MrD%xkStVvP!=rzjP#P8kgkPSLJiq-XeV_e?1=0+{RWw*5@Hdu<(6n$ z$DT8EPUSQ6HTQD+N+wG*ls)Gf43ESPIJ+ay>^{#R(PhOSP`}FO&}8Tdea8w(TgyXG zVPX`TsMzevR-mpqqQm0OvX#P(PYyZNkX?eMI`g1_>|%16CygZ z23jfk;tH}w*dJ2Yxd~ys>_P&YXyAG!g;-N8$wdxPg>nLe(uc(gi6xSNezY^(Hp>_L zeCa~hUzN6E3ALtTHR_~E)*zZMo(M(DH_9JS%}oQ7UcxU$sBA8H!*7hVJ%rPJIJ<%^ zQSvF)P4?d;Zj>W48kwbYl<|8WBU^sP=Clv4u%BiE50G|JAD%CF6}D&6}(&aD>jJB$HstI8HV45&l2P1 zTj?abA3Xv6jDHke5^Y7EihdPuK>OqEup97G$4cip^eXB`QlNp-$xyZOF0vn+f*vQP z*foh$F{e}mHPJ+(pP@YD2qvYkx^8&-vB%JVgc<6Y$Qu`gtW>+0?((T<5gSSzhEvo( z!cmmib}D{6?u@v|viN6dJK}cSd$s}IMcq!D25*H=!cvu1gQ_6RLtQCutvY~=wTDX@ zP({+qNv9OK$O~7gvL*Z*6v6h#8cK_>hQxOYt!t>I4Z02w7j5s_PPHDXk)WzKbVJc> zi;zD|uSG7X@|Z+=CRLzZ6mP3UMJu_T(h5pxStJU`TS0$8F(N00L4Wd{ZEgPg@|7-xhC~t0eK575}lyT<{MCdGQU#`6+)RDdMXY$h4*4K0pTw^NqHFC%h)9EBZB zG-LbuAju{;)^!23>*AdUEXlsc)}G`Fq6V_MRY;`c_4aSE(CXk|;thUVgD0&f_LScx z?;#IxR7G+q`!n2~URZqFu|O3jTLo&{c0kOxQ642$D$m0-It{@^PDF#!sy|>UvK{Z5 zuwA)1X$}1pnu6}&rm_cQ?@du}8$^=^qsmLdv9 zMl)y7Xr?!NA!#xmrOrX;S=<%P-HQ@*mc^vSnT`yD%pM2${iG|Goiy1!M=nAO(ATbT z;tMpDn?Q|2e@?P-oTCiAr4l*%x?UlFNpNnY?2#LePH-<(caRLHdZV)9V(^#4Wl$n_ z#OmU5(P^qpJvYIfOnS#-wM;&+SWCCFx3T*rspj=m0(*vd{9-5vKa2s(k!VjnpY z#5wRj>N(!0;+b+F{GFl`vZ->Zyf-Qqc5xlRs$7ROm$iuelkj^2ui>|nDiD%O1+>`0y+#G6+@)~T!2T1y28rF#= zd0?i17|z`1f5$J0-@3ZVj#3%2=SjKH8Wm+NR59*0tPDM*T%x>K@Wj1 z*d^2;@;+8hN6~$qIm7`+A8Zx0JfW(x8#i55NmVC zI_=f+BcAih5`1CFHs_Fp4=yV=i>a`9%CJ0tj~8HJ$R%V8c9L#J zL(m-0O51!#FN+LGMAMVJbe3YBeHSNmKCx#esGRW?xAFb-7gjG^gsgWLV$H33&TiAY zkHKBZo8UQcRbm~NEj?gQ5jIz~-2E*UTC=;T!J1%XrT46&(5{w_Q^ng`W1ZmfE&}U} zHKk^|Td5`5LU$ugzohocjyOteLi`#Tah$v-_sHI;nq$4(%8DHHg(4SS#0RW?JX14} z6q1FuJUmyG$%*NX-8y0Ikt6gQb4NCZsxIr9K&cnP+R8-I zgk~yfHq*76-D$or8ici>CfR?N{w>tFqR8;5$Fh#*YA!{hhx#izlijE#PzbVu{3uGX z@3ecEFP^{Uk6an1&(wHzgp(I7q?@>gm{W_JJC8|Tlz(w__4H!*Nme)==F60oW#Zd8 z(#YoQBzi~JO|oLS#T8D?gRz3O&{pbu6r;O2-YIp?N&F$^vfWPqLZ6{mo2C`Nv3MPA z(FFybir3^!`!CW-&Q{bMaRT$VHG-;Ee#Ijf0DoInsBD z=h0$uE1z9vB>oX)igKwY@bA*KihPRzjYl6^K3Q;e3-Ld(4bjI;3F1UMmgjKm+2yw9 z?kTDu&qm}uKM&c8&VgZm2;3rKnsYNX(9sC|mg2DPhn6dA=w0Z0c%7m_$ZMMwNmS)2 z`XdAqAs&XEa>C*e;aj9JJd>!Fu+VJfMl=Mugsu+jTpsPxvoy5^Iwi^67DKl4_EOwq z7RBdLU5GTYtteEs*cR-nlW(iEi>h4TadWwc(oW$v-%<9owA%H|R)V%fEy}H~x0Th3 z_pT^MH{?%h1KN$bBzx>Yp%T-@^2X>O_;;v}t)FXx_?jyw!7sgw4yEPg6-muqtwV=Y z9D*8Bj9tgIPMU~@lK(gt+q}?X=TmB(YoUF5#Elr3?;-Bx+)1u=x5bm)a^*{Sk7z1a zgN}t}F>&Z>!)c>5eT$=Mfi)QRr~xI_)-vS(UG{sai<3TYdOpv{w08S;#EbT%fM1nqsYyNvaPr z4>ef10^6xplLs&%*-!E$&W#;HQwg4h;E72m(8jEQJ}2u0|AHFjRp=`!4!$k#L!T4- zWh>Y}xY!Z_zkpWb4Kb=xA)Fyh4#GbEA=LvC1Zy!4zXN#_YAHsk(WZNQ0#}&=yHIh=$UZL{s_p*a9iX{7nDu`B)kU zM(_u8BE?1`?$PLIN0{txd>o7L(foU*Az04)9H+fy_#r7G7>OytT zQJ;6V?>F~d^#me?*vfBoYLG-@jEd4-Az=P*FXo<#UF0*>XE29vzy2~YRi{!v0&fM( zsG5#UHhkx9XX~ojjyyvaJ8n|F%WowfmGNQoUAHw&*{!N}!u@=mPNG^A$W1&XX0NT_^Dg%wnu16zuwqj-Yf(Yv3~83c0}yqFceOkuhjv?45cy z9!4%CYa}B)GVp|O)*It&!~W6pruPoIl%IgFRgNVdX;#B}Hr6`|lcREm+ZWAcXxqzo zDKTw^i(~uP)%c6JS(wr@yLz(kPohwC!Ldeu&zG9?k}s4Ghn6vB|8{bkCx@1C5HZZ# z*EZK$OYewYr#x@9qH7XAdGAUuqJ{7){8#FCBAB=(-=g*hjo9t~(lKxh`3O?j}-+k>vS`QSz1M5m1iYUJfH49V5_Zba|<>qQ7b(w~14z z*Cl-597s!%MAS!joh^cvkvn<>%5xpJj`kN1OH!9dV$H>cFo(8D8m(xp@+w(mn5lEb zOR0oi8vTRlGIiKIQ?)|g42qJa#D~%*(LM1%bAy6im0u2Rk#egEG$#arral1A=LZju|f z8)+%U-Q8sd8QdvW+@Z9kZQ3+0H_45A>ZRD=3_38l``|h}c|O1IS~LH^S?9Hn@BQBU zklTSXBIdyFUa`N~Q;FPh?@wOiUz!ld>55w;Tq-&(`I|0=KRRY2kicVH>H>oOlJJqo z@CNvlmLt*$`>Ox7g+y*ki=tk43?QM^y=ti0S>7iN>pYN{E89ftdu*RL+AI+s$Vh8u*AHs! z>SKDmAzAabC{H+4>f!AaW(W4xHD+%L995vfeZp&SC-c^Nfn;%Av3RL3rke6x<Wi)8mE;j-*c5~9CI)Xu*xEJl>bIQc2}cwSHG;B}C#`&zyBZ zuJgUmfYf>l{p*mP?r0zozT=uLoZxP9ZE~+>U&H!~bgV-nfaCSg3{TbmYm3g|MMw5> zZ}AGUzsMr=yS}tYjpSRPU46&;4Cm_b3$jn@Y1|k+8fG~92+xT|Ggikub@TlxftQY{ z$ca#lC^@blc|fBZ2Vi6xZ#N0&RRa}A$ zj@%M2=l2W|VtQf~^O3xQ6&H9dZs7EX-Uq&WdxcBwzjE*L48lW-qta{AEs?4SC)nVf z<>v=ky#CS5>YPZC^s=a%Vz^+tyw+P=6Bir5DV%eG`MQ5?al_w2Tqpab$HRaP`65{+ zJ|kPGXq4ULX9}J2gMOB96#rZ3H*T+BW6nK6S1dhtYp9bR=5>jUVk5#Aen!qA1oltv zdO5CkIx@*a=aNMhWGe4EZ;Y%9YaaRsNJGDg>yf95_kzn58Ivu1@5~dH`ESVc{1NsK z;j(H)ObH}rjI2H*T@K`kx(7xHB$7PA93;b^?C;4tDzd3 zXR!1ubx|F$u0VDpkcGJUzcq0Lv;8SSj@K>~Iv@Do$qK`>L@5nP;x({jD?si` zU-D7|LdW{(V&7D0Px%7rakE`|P`Cz>@zs)Jp)5|929>m3c(Xw+HeAy}4Ve-2#md6s59oL3yN6^mjEedQ~tjwA7ZPkc5}G zp0V?58iZE@>w;Sh|Jq`zeUhwywpO;l>qSzyE3u$LkkeD#rutfSj^bDGKFMZbcXYq7 zz`Ym|APWMpB3Y4d-Xc9LS?S$VJ&XTTV$LpgF*v1yR+7^ZH@8jQF7^lcEATO|y`pU- zK9(!o+80!`RVWoX;je5Bw!#ht3`h=jtD@fdSaPvCMf53qqLa^W!=5A;!nMA?qRYa| z9D7}V`A16!!S6hIf*XOLHK_OzEpjh&Q57+NmH6-IIB;k18UI#fLEy723Bd^`@PC z?G;lOz3_IA&6q{Lt-{Rk2lqFu8QtwAzypwKWSRJmPr@xWb;_|vzlgbkok%wOdSEO9 z`rXztvDxh?DXWqu_EfZM{LUvv@}&o*nO-A!6q_i{4k-D{J;q3d>0eviLb~$>((^Ks zxTTCHJi&Po%g2e6trwKb9!dWe{39HT^xcB<@VX zDd`p2UT?LuhmeAN7LD^;xT`%&kYBu9$!lqxM3t@cpLgF7ww2$K>_PrQ{ub4V?~#WJ zF7xwbLB1F%67T}QxjRd^q7QggY~M?espO@+lafUtRdw4Ch|H0NB~AW`5wY+jd%2W= zq)V?weTuc>bx8N%&x-zA3q1Q`imEt)HU#-sATIwq-;J<@`%Z+DJmag8V>W@W$a>0|Vdd0l3>R|GZEOF}=x6MHeP%;{1yk(zmA8z`z~ z!+%HG)pn{$t9u~5URPQ_TyQz!sx~;28@dE7GO3it9HiK6A0s^=%!yBPB&ccdji&&VO~XI~pZDmDarq7`zv!Z_!9bfjoO;7E8_7L~Ut`Y=kxD8?p5 zXG9df$kN#Vc06>pcm6GTA$v~uTK=Cc3S!qzT>U+6j!Z2h*E9*q6291tuz6>)U-J4@ zAC--%Nfw()mSjah%rElvai5X@B{)!X$uU$gHfK#BO8_HhWLFfwg~rPrvHST?F^!cS zNfe#0{?5Jbe-<6#6`Jk_V6Ib`;CK;fcJCEW0chSIV0w@s zrA`>R7imJKx@FQgo}=FJ-tUf{u=k-#P$FQqG8m&&+30A+93}s##;ZiOG3kf$OLX@1zf*I zfvXeLowFUNelO$rxivY8`TE7;IZ~taQ>04z)7w*?ExSh3MVkdbxK@^+c^i`JALBg_ z?5}I4H;ZbdV;lA%=jtCHv-m&MEUSAZPUUxqQ0jLH1+wCX-GTAJQ_;;~jW;cNreT64 zE4;hML3#VS521Vi>C4>kM?OUnlDB7P1U5(N7NMtNRJN4bq1*MNmS+nU3Xl%ob)V6d68}lt4{lZXr(gruhd31_@K^wucgcp|vYx z`v%U2^IxsmC94WF$qsuXvd!W973to5$jEw+pTDRo!HzvC`e*W`ARy$zPigVJB> zeyNMg9T+^us|dXm&uK`Cv|tc(GZ3WKDk=*TWd*h7Mm}N>{}d-i=S7|)qp&APV|KqI zbKO@W{Kl{U=?k%1&2dUEB6*ReyaW7PB!XOLj*w1cjb*WAqTI)#OUT=BSLCN`IdT?G zkMu@%C^q;vIPQuHedBW)<%!ZIikrv+KdIM8&mV#m?wRn$;Jyfsr-1?-z4sb7!9`%g z_#QD&Z+l?{GFC`eoD)qCjZ-+eXQUqCRQ(R|4*S1$`Ot7Udx!i4=f057dYjvn{YRu{ zUZ-lRe+pmi%oA5e7Rm=H7WnHF;c#59fPYnUS?%;39q)*zticnWoqH|ZBt2ETBidQE zT3iq~6zP_Gu3@3OSusQ0PiB`c3E$*w_IKv;BaGVN-UeB;+N@|*cPx;ZeT+9TyPp4{ zW|p@*_HSQ?@ovjzNC#zi>G47qhqQsGh_2Oc$WG*D_?l&P=$AYnZ)GG`{wVh!X^yCy za1>I>|15grYF1ox_ZI&J>lEZ1gR~=Z8p#!OuU=W(5Kv0zO8$^26+7hIp310B`o!mO zF(dP{M?^OYx`jSSN#X8Dp_uFWfOv&Hq&KqF|LIGwQ12YSdvoASsBc8(eps_5WC@<; z3>LqxTc2}I0o1jR{1rW3TN)VCw4>$$Qcc%p1GH5>c5QuQR-_{P8h?kk9{DV2u2U#3 zi?4g*knPb$$o<+4Y2*Ambr(as75Oku^1vDj&*a<@wGcg3?B?E*wvr+hR}`3HiFlQO z;rM@c$&Ky1Nsc?&RQ6!`ZueAorLjxR0{N1fZ`s#nL3g=qr1%RGtzHMO_4AR<*>mY6 zZoV*A@wsLl7ZnyDZ;<5vt^2R~Pg}#Jha;gyJXjaiHx2Y$BxyX$s=Lr-?ETWuw5I>G^*XdSa@^ZV(I@hXGcla! z79h1e7#S-u3tlrHD1?z;WBS>a2;g|kY!g0O6RdN~W=qAQ=c09yhwvKt5?NR0zl^5p z9bi-l@ODW~NP*}Z$>-_@Bo&eSKiBkBoO1uf!u4ZwZ+N`?8zEiMWZeu8=SUdA;OZhj_ch{GYZY{Qk9K7aGjWJrh|f8^-@to*-V|qoko&G%x#& zd}6pZoGzRkP$PS@ABXxKxlb>xZRO)fpC23OyX;94D1>kS(^mhQ$*jww@2uS-yATG( zN_Q!$r5WAUM4XXX)m?l()*$|q&}{aEp4GAYRGqgiQq7}FuS$D}Gpja+SMe4Jb6o%0 zD={Zkb}Dw0tF0N!O{|$DY|o!s_ce4Qf@?U`@UiBj{HCm;ajdMc=cwu(l8()1yzzdL zbYZXZynfiX?3O)q#e4qK*5I6%l4*e{iicsQ6v(R+zbszDEe!RMZFO~4bdCa!L5f!1 znZjawK>kH?6PX~qt!QKF4O?&%RzA#^NI7>~cwU zOn=v1{6&tq2x3>H)VYRBcTUYI;1BfpSdYC_&TM#NRlcx){9#rX9@+i>82|r5dbF&M zD8BqU2zh?eyctizxmbP#XBcK{LAgdqHh*TfrX`f?$<+e8>Tza`HJ`MU@`e>he_+KW zzDBHqZa^nT%L;skNlYehBKOuYBF)^V+NG=&qzx3D`UHQh@nZaeP=6XpE%I!o{DY-~ z8*J;;ahNXn2f`H3vMzFua_{1v@(jYSNjX|k1Jyp z4?EAgQrRKok)b2qWq!o$t37B`hYD?r+^sd$rKy=a@w3on#0Kn7BG)n=WRwZAAs1|fIo+3XJS##B6264_|v1YEa~h3Q|Ra0ZT9 zm^Q~YD0G2v(ft{FqMm0K0N2#5ZOK^I%C=^r@mhg9v#;l=mIa?~KQtv$Rl()hzlsk6 zd!3gEPR396i0%sLN%w8~d0=-D6|6Sc8BF{IR)i(s>GbO4713M97FGrOie?8qgf)s$ zMW3R5A;1})nQogZJ%1Zaw#UX@c&c@oXBw2HmXx2s-T;kyh+J<6%wb{AoQ?(=u`O?U z_z@|T<>eQ#Z@@m_v-T%@jiZ{d#15bvS$Veg)NM>1ZZG^D7(}_LUJ6ZRJx38e&Rk|F z;eGEQi;f1P^mn!+}@yu1Jj}`=juK;x%6eUPjGqj=>wcRZr@96*4c`Cp<6`{RWBjq(1Pc5g|7Xjm7=I?VA;!Pe zC_zRVKY&KpPG~uLH5|fzjXW^64K2-(09_eE;dGLn`pn%viPM^q$7KhGCTUrupbLVo1A` zYKPbJrnpmMmA*=ZNC2}^C>->v42aPaAjbCEv5Gz zPaV(^zTxcDJe&9#UP|2Nc%r)i590I_=7`d3rL#iPUY z;IFoBY4hFJxwAb!uP{Z&TZ7GD#n}^33dV*`I(l(%kkLDf(qF{`zMIZ->Rlg@gXnaK zqDEx7rK`;cg2U~1-HRRfllq}s4ehiIw(-tfeOHTBJ4jt@JLI`R|7KsJrfKG-`J4_< zD@i|E)Wu?o$Qis}vX;XM#Al`hlVr_XU)h{wZ9KZHT-|AAmfgr+3Vg zp9svQf3O~d_XKXemrYSQ`tN^;;?&$VJoQxal zI@aQ zG+a{!Q!ek+6;B9eErr&gJ<(p4i@Jp*2u`T8x=8GLWRinJ|CrS!ddK^RdSGZsk199a zB37C4`Sfj;w?xQ13hIGhojpN$&eE~G#749-MK_@W-Q@6HRtIqs)*jwLr#jkby}AH# zvo04tZWv{t%N^K7b)M;lyA5{2MvnYhreI9a-3gb6OG-Y(mpS4|0^VE;Q%^%1=_9G1 zNbhMp_cXeaX`~S7{e@!gO`EtW*?TCZzscet_S|O)u#3ouQ;SXKt4$%UNVJ^2NMeVV zz`V?4^H2I1Y*gkdFOgj!J`w&B=$3Y;JkG`Az4A=)30wcet0zyF>|hy8BGnBr-Z+rE zlv>c39?CYdDlbBdIi1`uVsVMd)bF~Nk-bbZ>11#`>7%=^^LSE|>5K4d$tqhvw4(+P4}@CfU2GMBqdJ=CUhFl>bx zGDjrnLQ=)4h{YXpz31Nbq~Z2a=B1rz$Yza1TnApJzdanK-bbs@c*}G3ZreOq&0gtZ zvh~OgM;){SJ{4*9QmOeNc}|)8n?+Tkz;$JOwalUzY(J=V=G_5z5)JJe-_3T~$Py8u zJC!%WOPL+So2)(HDb!+TOt?_Bg0NC|9)3n88bM^0aECT%p6;DttE=oB`6Hc8Z>@SB zRwI9;Tp*3HlPUs%6Yx>pJM$WP0lhQ66ST~o&yX>x;BIKXFvXu|!#DE1zY<5=mpT=> zhb#o<5U`tbyj9HCGDN(NqIJx5l1kP)80?_XoP{=G_mUfnRoK=^Cm9%FZ9)S1V7 z=PC3ZCgt%*LPMlmX}?(*#Ir<&Mb2G9{T|D&`a?ZhQ)T!qHvYGfD&{GWQgJoX8h&8T z2t9;&9u0Oqr3ES%w%4??f3l&5-zvbd$97_7b~5ww+Cx zESLl5C;v@7t&G$5p>z?Ig7d}KDD6pOeQWVI$?w9Guzitzhfz@@rh>#TZy)d zX=UeBoC054N6-{x8#sw(u>DMHl12nh*lK$Q4Z`0LHF13S$y!@v6#s;34(nzFgiOXR zJdMe#yc)d3YM=6i&$rI8!S-o3FWF|l5Zr(;#4<&`yN{d7JMLMCmnwIs{ir+0F!A3j zJ;_sbZXm;&X6<3DHh`W1@ba9Et{H3$(K;4ED&Cdw5C3BtGxSYS;RaCVfwaS~8PiY= zxj%LiJb}Ke|5hlp6cmrJt}Vieeo|$cb1~&~s<@-I0xp1>oCCDqD)*$m(Vl`6VW;tW z>{YH({xog#X4s0U$&r}^0n}aFGTf`jfYLq0N%m>F#PAmuq9LFg>30}wDJ&Y!HJe$* z&{2=mHVX&&tE>m=0GL_(Lr9ONVzJW19DU2~5fi1JJGy0TYu0LmM>*#F~pcU!~Ro2>0o=Nnn z!T0K`X}WT~xX25OA!_tV z53tL)F|t8O)t0b?oURZ-lg*o6@z4Z^da-_%S%s6afi{iVZuyRlLYB;|3yzf}3?&~qrafgYEcs|zM|v+@50Z>XczzHRkF+Ex>sHwHLqsOCscp94wuV-XJMIb8l+xA&qHP=07NK4&&97E)-jJvY>=$Otac8v8w ze4IC4YvDT!8oT9Yh}Bh(jeL7W-!#$%19p`O>bt2Ff;H@wSad)6|deK>Tk7_{FHUf znuMJ+-Lw9qeu3$O+Yyp@V|c84xii9j?kOO5A`R3I%&jubs9&4hhp8s+q$QZdRyH^r zoX$q!8+A+E0DC8rLw^jN;e#>FMHzY{crt2scQ(J%;z>(PGE0|?PL8K|W>qA|Qh5z5 z!Ij&BRFkj;^vXP!b1#%H9)m4o4F{&XUjpOwFNp(*O4bd{j0&^1rg}Q%gXvcn+v+rD zMTDx0U?1~Pp}Q`n`vlWk({0sH&Rt($N|kLKt(bay|3><6{zZ&dj80I?!g<2y*}JWY zxg{Q_X%Ks+6W`FOJexJNU3X_ovyH=+S^3*Vwh%oQkF>C4q#foS%o=o+whuChqZZEy zcMaaQuIpamYRPNzGz)#g?pe3YB}y^${Ui zW}UhKVlxbvI#7qimZr2B|o$Eb!8X4pbcugn&C%=AAZ?Gq90boEZm z>G@0i9bDz;K_3ne<(^7g$ha3v3f*LA7QS!O(HT!^pq%i$f*9p*!vcpSJlMF1p@IHB z(9GLYhs(YNq4cT3-=H}9Bb`Uz9-K>a6=Vy4ANeYb0|Y-jk-kU-!`q@pt1>7- zqV6ANv{;wc%UWS6Xcskoqs%ni&+Ae780eKDvnCOT3-h3xXoYD}bd9(eo5%dZxaFn7 zVMAwa0=a{)(oMuZBmdwX`Lw3YWeq{_s8xE{m%AP5j+jYHn%W zOZl~;+%lXl@*JoB!?+J{i06en)VI*#zSf>%yo8ohW1D}{!ryE87?J27^J`db!z{=pV%HW}{Q$CmF# zIp|jN=2#5)8sSRMuse~CuYE)8p?-lT(|?ICQ1LC;GBk;l5^87br~BcV#Yn-N zKujH7{@jxoT8zNr*0vX}mTn_|kS{(Su)9PBzD?au=IZx4 zMyrYZ7PxJReVj?|R>&z!8I9sRhYrHG!v{;Zk>%+6inAcy`mz{G|AJNrj_S1KH^O@C z0uWerfJf-i_grUlwno`=T2%ak01YKrbt1#L*^b2T9?J8$E>=74)Il(}3 zOJE(^z;EM!ldXBN5InKBj>i{2P$CEl`T_=_4$8yJc_97G0y3(Fx zwqeaqJ8QQGm1Kc$j%N!=tvs)pr9^?26qgY}WybR*p-QXgQ%vtrWe5UeQ@Pw3;3OnP z+f{#_|0k^w_q(PanPSY>f}~@DkJO*(TIL8{4^e{bG}g_D@|6CK#v0>PRtwrnx}0(% z?Hkz2SQ^VynFsdc{DDj`e53y~_N(0NyyjbMe^~}X_p!CcBG5!5a35MObHDZY!6O;{ zFxd~_+Hf+2rSMl1!GoHW@FMq`szn^U?iItu-)3wq{iIfqmzp~OmhzpN2Xr2M!oJgB zcBXJ2=xowuXuiH3x22xSq{2{IBa~yO+l$cIjLw;v#zmB_#tN{#{4O}$-qW^M@JHH~ z-8ncFdcyX^)D3PGR8r5x4>gU|as)ID-N`n+fI9)(q@8@b%kw)7Grd!El*|FxnHG4r z;TPsz^eXjuW(9Np|M*{E=}srF%D>ro%Q%Ylj`|BdOkR|B4{U8L;~!INSZF4Bwwuf0 z#qQ-*Le4AgWk!f^Gk#aj*F;D&&1Wd%DsHH6)7kJ5`wc_XnZ(_uGfPLIZhdR+dtC}M z1)i9C0-9@=+D&LSBbp&KE~NA_VqhD&8~nvS(N-u>rM=$qAfp_;X!~MHf?EeSQ;!_j zYZ{|v3btza&Q#M=*g;t*P4I0jKYM7W`Av*}128^Q57!yiFrT3Z3|`4Vb24dGZ~|Rx zTomjgc6lI@F)~tppE8g!OLWY#z|zOPQ545ahIG{44zlPCmQ3e3x?3+x{}fd-?-=^7%N32m6ep6f9GNxMt4azR;yv$MNR+ zIf%>JfVX(_F+S5`7=>#`t*~6??IJdsjiN=+3e~F6jhLr-Tezc{%AASvWtZS=T&?~z zg=gsRJ%iog5tg?K9a;WD#`)L*I4Ay+$ttjdrY=e3yy62MiQ@&yG&0XmVUVr;! zS3lh_^Z_@@{ekSM`6-52!#QQx2W@-WGH90aLw0A*Zm}zj3+}bIA#6i`^7r_D@yknl z@&`JS{llCp?2ezonvZqI=8=~M&KbXvQ)zX?soWG#95T+pp*Toe<$cMEV(CF6>>GU9 zu^i~u>?Tr5+6dsNDZ}&%RhyEjOWc2ZMhh3X&KbLyKiVp6FT4@S?bJDzF4h`I$6nz( z$s!Qjnq^!W{{tBGZz5HL8~syot?2{8r$x7eOx-qFBWq3RG5Pvn4@w7{gZoCb&_o2g zr50uNv5t1{E?MhhfkS1FL{{LE(VBjnbYA$uxSu(RN{+Tx4=;r{BN|dIPOO&k$mF1} z4s_%;Yl^Jh;Z~-r%m?Z*%6R|I;1(n#`js=l?X;viAGIaJ(ZpYNsfh3eKJ65MQhfnQAP zN*|T^G}}ttr#+@*J3dsb?)H2*1Qm8zs2o2XNb7uAn!0Rjr0xN>Zb}mWRau=z=zi6+FI5Zo|@2%1jCQ%4988+B~d@^e!?E=G-{!Dje8BL+1b*BmzRpRv6ou_%Iw%m zxI#ah(SrIsI)t~fd?M$2t;Y=6EbujS2CzQxC-*_RSg?SuvgFc+5mU6&{cnT&kgno> z(wQ#3Z3us$53Ohi7668Ze^{R+$M@F?w_sFguj+P(e@xGb09wr4gZA~@K18j{{JB087z zU9<&d(>n0l+gli$fHjn6;}CSTC#;@@d=bB-^wFLUl-U%;hr@L1h1fcBR?kCNxFmYD zQEc#rV0IkZ%|^ptFn%hprEd1EFd!@|bsX!JXnXJ)3O9Cf&()r>9dk)@-{|^r-x?k| zk3vA%aQN4l?`JUmy!bX}42$V#>$pl~SjPZwY&(pH#E$TffC?%iz0ytP=lXj4_iGh= z13XA}(ymi>;bfvi?SEq}pyBnCgFmX4>Xub-xUbwO0>dk)lgN$Lt)a8~hlCuQCf`l$ zkF2APqr!I7VQL6eq9=iqS|+j3`^`<{uJe4;O`#6K#F9+T5m5u_S=Kn#I$>F|fL}!$ zWXJ<9c!vv`%ezRML5RHDA2euaP2qas1K%`C88V*9BP`&vs$0UKjpKbNs>s5pO`>_B z0{2DtVA}ZDOOqd~R1tjrso+1ea!i z@geb^Fg{qsSWk{syyhSB#VSHn%;!IYwkn1@&RSBqEwU)Kdsu(kmgs}e)c%TRs8FuT}=&)K+?4Ofw!H344 z)FT)}@`C(cl;3SCu^+cWID$Z6Po*q3Rgrz(4uT0;mz)E@=NZfWUCn)fuVKBA;B^BJ zglxJ)CGy^`8BhC8?8bz}IuHR)7&77wdMZ(X(C-kkc*xWH)iwQ#M$P6)~N15w^V<8{# ziIb`NuE3&dyJjWUgOm+Tv~JBxpmf!-%wc2foFsnlDe|OpUm$B8y`i_l_rZRV_6Z5W z`?3P>YWujV6jKsywY4C51h|v_082ZP2i-xtkj7)%sekii5D#TVK1EuJW}(M4$EhEk zbVeDe_W?BPEqVmop-v3ug4s!bn+d+t&Rm=*{1bXZ%0OEOr{q0TwnYo97r@`$Hx01S zU|+!eU>3j((*%1maRN)qJixvxYRNcavR4Ex$v$^_fSWgbQhb4 z6sg8aN_o}nuz3KM57pXdnF_JaY=yWa{50@|&ZXedI6+7MQokl?HGh`vP7Yqb)-fw| zn0;A;VEgD5T(HJsfy$pc#oU%29x|IjP`p3aT1x21pz}=?KA;u3 zr}dx#vi_o1Hagw2d6S~WhIQma<}s3%p$+2Rx>vvp9zk>hSd6_kmz(=$-16q}$DonA zcoUyc5gc3oO4~NnS=`_AmHH@fI@wPi!b`6?Dt%5RN=>37dMm~}@R$u>ThG3}ZDL=6}5LOSeL*VQ98d5eK=>0XFlNqc?qjZx7Q#F{c55bCddNXQtLB% zUvy4Zw)b6-gzOYgiWr<-ooRw1-$F7QOfoLdZR6NnKLh{RvAJR|bdcKHv_B>>)pMpX z=GP7Krf}y-{-S@!O9Tca8q6^-2rjR_?i=CyLa5=_p$83<^)~G5`V{Ymf)u_qZ8U=#aV}Ws@NhE+kKZH`WyE5#X=j;0zUMV7RY+ zIp=_3nfO564m@Aw2X`6Yvzz_bGoD#zLo4y~&2+TLl?d43U982#6QajCr!AsfflH$r zNB>C@G;{!F!c)EbTmhBc99rrA+fHsL9Q7g9yt}sTTpl}3Z6K9ue zIrRxS3jKkrq&y9Nu&PO|BR*gSy1;texY9J&-6u2)IV}FCcDv&>#^&37o~rqjUYdc9 z9Q%6DTD=Lm#C%BiANoS+OK-I75q5_zHHo}&vAQ^k%+G~G#5YCih~LQ$R0xN9P3{(& zTJsv~WYZJ7AoRMUcZ_mk^c_Y64%i1Sl(f!m)<)n{^@G*mM;aU-c*3S~fwnjXc^k7*(pC5w*$( zG>&_+ss!G|aL^@UmVbw}&^6t$-jNHh#Vpp3zLG;^?l8+FxACnorh7N= zoUu>-2H2o~!(PKZ?(TtHMh}v{q;2DK&^{(YXaFwN*Ta*dY2=Qv=(Gz7dug?%`_?o= zf~9NTUH3lT@3jLDd8ygJbyGBLv}7jQiS|iwT|I|VNz;_iB~wAYTg+o$`admD?0=|S z!d{>jOh#?~%N`C~NVDL7)iqi3v%XS(XQe0aB8~4hj5s0vH!Nx;xJL$B(7%)VNxlOj z#sp4lo&^Sp7qxu|7W%Sa=ak+4^;Cu3W!=tt4DA3Y?Cr!ZL5EAI_FHMrQP@`Pf>`Wz zHJn6yjl1fHs4bmusXn5JzRxExYKBIM_QXC_Q)sC0F#80ov@*5FzxTc_Qvkt1)n2(?fTzwTf{RT*~^( zpKlG2u9YsdS7o&T&6#T`hjzrnnb;Qd5Xa1j3a1oulCLv{((2&PtOWcp!wUU(sxtH@ z<+*p5ejt3FJPg|km1X|p-jpu$MBq!L6*#;9yYn(}hAGeVwz?&5K7r-W%__CvfE#g_ zfT$x;&4v5Y0QW0Uku+a747i|f?;eHuIGAIDwg=@~YEcG-wu`oMySb{0wlFhZrA(h! z@&o7wue0sd_)JvpBr~=2RV;8e)Vg=qAE3k zi62rvu5+MrZQ|L(pDILY$CJyFy_LD3C@q6EKs~SGQDsu;(~MqQUn?i#W|sdfNKUFL z9=~fsN%EmdnNP_bHT^PM?rYvZSeHg>t9+Jv>!w$o`C1esy*Jv&k?nwHAZ|tCmePVcZ@6j$p%5kM@afejG zgcW6P3GY-tlYSNsN&AId)Ajc)wU%1?rGs|rGJwywVU$Upy5Lp*S?nb>!NwJ! z?9=XD*h)?!aVGTk{;79DsJfrz5@r6}G^);y*iEGo> z?3YrERN~=(a7gl0TrH(dNg;7<+~zE{`jT?5`DWFU^r7Glz=*$#D<}FZTLC*W`etPS zsp`H;L2{GQKuJtDkou9Vlqs4N%HJwpxP*PZ%2%~A{ZZvln3D2SgOU#tPvRyR#*;1= z*~!~9^3)faFImGfUXwB1742-?@8mOO1s%M`Y34iRn|eI(N!19R)2<|*R=2{vQgSLb z13Q#Ufn{XBHkLdBWKzGIqU2glqaG%-AW=+>`a}9=6`6Ppc%kiXNzt6?{siBK_=A9eT_`m`aiNIxLbE0Xj-|T3AubkA=_hrc zXmZOwl>?n@o>58C)dNL9f78aqmH5_KiRMInOZ93Z031o|Y|c0IKXe4r058-_R5`}| zSZkJyWdNQd)q!s$5_yM+XAq$=56f3&r$? z>cXiiHW`%Ut+BUubvKLJLB3@@h=+~8R$T|8B~!`UK?89sZkk~~X+_1~xCe9uU8`He zB+_WKjBj1k%|ciMrgOv+b7=#_9v`TP9qhhx(KHY z*9cRMo8xrPP@eP)Lwm3pzwE%3 z$|BZDGjgXK)xkMf=!K%%Q;t$sPShd73R<*P&oRzA4r5%xSNy!H2s?LWWmaU~MHDZKPxSm}Q)`@FQKhaqQle7P7Hg?W99^IlbIQ1}aTolHW5I z95!0}8$Z$-UG24POh;l`!*Z(Mve9vZvD-SzILxBKw^YW%Z#8lpmE_ENgl;4bg^R&~ zSVsd$T?4FPWPu$RM(TR|aCo}~bNt!97VVWbiQLWR)-R@FnQLg%^k=NSjcUqcstaV; zi1_P_;ZPy8#Qp^38^4kbXeVL_;+h6%Sdc*51wEM#sk%eX zlDVCqmQSwiq#8rKTXs4zUOzTzRP65a3mwdyQaP-gW4?p`hZLf+Qf6Bwrd#%HCTOVD zw4#jmI#Ag~BcNVVJCyv)cA7!@`s9lQOKPOT1JtGchQC^ZOJXMdPOR?SHIoWH(gO$1 zYBrXcaC_;yY%OsZxHkP2eh+SzE(w@d5`RdF_Y`l|*b)_Ks!X2l7O}qUur}4aGI@vc zaYZ*0fiOZpi$K8lOp0VABy82Hv{H?_ya`y*adfgjDW$3y*Foh)t@@W#R0RRc)n3i2 z^eA2e*R^=fe+f2bXgWE?@tHT*>t&d7s~N-|tDzoGq&z+b|t}5jjc1H9W|y@yi$@ zGS|q0r|PZ*9I|T4;l7VNSK5gF$QrL3PH_L@9n?IpO7O=(Amea$Z#t-O4mcLQ21!43gk6qZnucp*(5Xq zI)IO^5z+?Y7IwycohuEu(ft58+VB1-6@@re(fT~#IF%;-p>9N{AQPSKyh~NRDHqjV zKT|S3JUP1&Gt1B|R07`d%p)s4C6 zR>Q!L=#`q`k`+6PeK)I{T5i|&QZ{n5k*qRx!JdgOK`v3FW-Wm)+l1lc@}U_fbcHm| zbyvKe4UT+CSr#07!>=vBq3Un$V7vj&a*0?Tct(K3>%jR*o_A#eNPk@rZ^^>` z6051?1u@@(Fn%@lYcUUIg~DjyPdw89%jIyjbbqB^`%vRIcY=0(O`GESKt! z!_v^OEEozY6~uAfZPCaWpn0o(QSDO1mqAGk$ z|Ixf!+KA+F&cj-DYqBMh5g3AG+SbZzmk;}4v{=(^^s)@&Q=a_FG_!E zo6j4{Z)JFkp!>juGmA8r4tEwG$*|4Z&*EroYCo{FEd<%4FoldcuT=yY}g zasp>VYs-W*OaBSD#qS}QM)xE7=6x-n#~%+yqIC+S4qYgdMB4tg}bJiQtG z(s7C2Da(Y$IK0$D^tp8s+7q4vjnRzq%B?Y?cNv3{Z|nPKBywGm*Wh_{9uGm%;G38j z=lDv+sHh}G>$o7k$A3YGEQ>gU#Tr*5a363dC5gS3`RvYDKE*baPGz>q`M@?;9x%Z? zLf@C)3y(K_GW8OS){IjS|jmxz%Gsv{9Dx#>cG3Gn^!eM_J@40 zwYh4hAO>2<7dkEJl|&o+b)u5Qn3lX&lIWB%<>z%f+`VjC`FOz#-cQaC@nh#A{tvr6 zZIAUYw%3!sbxUb$#c0+WbQ<_3HBOjeJp6oOjBml6n!ZY>#KIW(m5*iWBY9D`B;B zCUKN`TDqs25nZTMRky0dh_7^tXuZ20`lDpCq>sB1nT7Qbzoj(1Sou23=*S!4aau)D zlINkqC;rAy1a8>c^G%Mi#VB=CJHiDOUa^Q=gmVnPZ*@J@Uz}OkpE#UoH$4(c$exS? zx`lDIFMg2jMkM;L@n_nmgAlaqC0#C8Dn2(dyw8l73G=6rr9xI zC-jV~e7pxPzsp%Z&kL1&W zZvKCPTY*<(H@LAN+W5qiruyV)FPdr0%K+e}TSt&ZE)M6rLwuw4LBAD;|6#D&O_OYj@~~ zWyoYjf^(vHtd5VzfUR^!{z6}pcP=%OZcck5e9AVL&pt+$EgkFF;C+ZB;Gw*0TUz2( z=CwqDPi`Tl`2v(R2YX%2lC`B_a)S2~Iog*&{A*wm#s{_9c&Y;#OB{4PmA|Fen0`r4 z$`eFa86a})|F|X#|5R+jcFVB{6WJd=3m1`N;2ZD>Y`OBVqPKk)c$#|dok(r)LdDmx z1+MADcV|4`sz?c5kbl=^L7(Im#3XMubR^s!UPmP>{swWTBm5d1ObzEX*hUd{tS6mG zWJ@Nxewn|Bxb}C>xy~c*!{q?Q$WID<{Fb7@s>9}~NDo0B8!Xvp+ac~K10%$omKbPD z;>uh2u$zc~gYB_hpiq4lZ$~V}o50O2%LJb^Elr<91C;=4FV#)}+i`_bvXRrG zA}AgrQjj;na+@w~sYIcAUn;9=1Jq$#-~ccK`iP(ATtYv%l9{f~JFXOKSTw}?&@u-e z>bNL9Nz_Q!%0lFFyiVR;n$0hQEV6CdN$yXc=b91ZA%B7@2k5cL46l%wJ?$b9CZV_NMLjY-wm{*vm-RH<49Ceu^ZI=-8c zs?Wh9HH)ldlsdxO7&@LV@Lr`<=mp@VvIH&$r$cq}7#IkIEP03|T!csKNcaDg;|YRX zsaWn9Ca&ermyg176>nYV9V@l3R3o(=Xn-!ZAsWUK@g-I!)!I?S>R`A7!WoN%rJm$ z2w{@Fd=RGRq!_cEE;QbH4x1!UpvCSXy#KJ#4n7voQCl~Zx0ES>uY8QNz#;=r7*&$5 z{2Ljy*1zrJg&KB$Xg!n!g@_Io3;(eD0&9UR!K#<+<@>O1k}_tRa3DI;a!B$;J>6{s zyEvDyI`H?|-)|K<27wK>da!X(fL}gY)HQohm`Scw_ks%SPc@vj&~& z2oqNTkr1UWOEli4=r9#e!VSzr*K2v?bWD;z61?=wHkJNfIf5nsL5jeMkfa zRhqf{6xAF5C)f|{KnvkUy4Q+dtWAc)zzf#}_!bc(c7+^7vEsVDR@6&;*VhFpa^(4U z)XWK0Nao_1fq?T*NqbcsHOaOjtpz@pS&OXHbdmpwUo=bPb5$vs^Z6a9*6y`xQD};^ zLDn7`ZeCIQpD)dSf?P=cCQIFK@WGA?`sY<$G>*crsuHkmaDoH%Jd*v6oKNzn^1R*e z&95%;_R%hYdaBMa=U_Ee724rj>UVI*XlSxrcAdSOyrTla7Mffa5A-MgfLeNIBZK8J zq=$|*@0Z(gZ{~7sBF@mc*6re34tJzYP$*Ibc1zD{db*3q{TY{~7d2Z1gG3dIdEyIZ zMmklzNUF5&5l-iwO`q)US|<(6@LCm{Ya=YXBWONJ-Uhc&#f~RHN97#y51=pf1>Rr1 zhr0`m!A07G<{nsSX0kjNZZ1GnFX<_O5V#)DQlG$;zN@nJ>9;*|10$GOoF?vC`2u5m z#a3zzGhU{&{||^oTRQ#zA>KUexrz~_4!Yu1AyL})^hnZA)-&C#6CUN%0z08@A z-Lttg=T}&*@-$BhOELp($yVOl%DP##&qCFEuZ0oyOyqmmTDLkIY<@e!k||m~Q%f_i z2He@Nb~X=p(jEp)p({Bz1NZmuiAIR=J)J0L^qMTB{!j8&joC3RI4-NblE7jWhii(Mx%5&6!|CKa<~vsRx?a6&^q$S~ z-pF^ism`Leh988Z45h@`tZ_aJc`a_Jy&e8j`5^5p`Oy2=cR*VrjRwYPXVE_l3k>tH z`|3{2P+3>aX-!_vP;I5QRrN^E`cS*wWtq=vW+iX*EY{hX8pfaX$#;`imzkwgdBfWM zffDegXqo!9=1c8A3Rb8YvsINS2ebB3VQLhQkDW)3Bc13=ygP_mdfDo)0ZZ>{^JF%9 zqDO)M6Zy7P0<+z>3`@{QDw%nhva6a6@ASpl2Ff2`>p0WFOJG;tLHRd<#>`X9krcby zk;l9xIG0?{KfAq=^P*!6xYaYOss%`3<;Wa7M8`uyZ8U#@x~1h9H3YpayW@PJx|H^x zACUn09U90o(8n8j72mW$y=KRFxiw4pniAvc9 z9pn5(^PS;fp{Adtx%V{nP`pBXTGd~@DNRfzfz9C=swH4MS3l)Qs=YhJ>jGQlqiKn@ zffp{FBi_o{VJuUG!Ew@5$PIUPP6Te?pnsw+sb(#6g}Q9}0WGi$hs(6{1cdCjy$XHj z?k2QI1<)fZB>ty_a$gHPVe_%g^0%87SoU~70RpbuJ5LCk7CD~z_PFoy?5dYwU)d$0 z$lf09E@G)yF_ql+R+n@H^;0O1dg9gbTH#lr%c2=3`wmxxx?d0Nra|{6Lnj|93p#tk zUvxuN%L47Sqv6fY#e!vSy=#u^s{0!-*c%Op{7du?y-uA+-QMR2lB&K+IjBgLJ`b0AN23mrlA)(`PY!D9bx za40iA_%+Z|JISvNDP>3G695_Kgs6!wDcC^>%6r?}Q419){9b&^TdL*Jjn!QOi{a1o z0P+KOTyQ+IN4`|M0-00{VxoX3Y(=Jfzh%~o&nSo6`YWn~)5%4y;eqeczTU&kDN|EE z7w!$SvBti=&;Ue1UUk{=ewo)4-=%Yljx_V}mKftC2hz~;(T;WHbFdgVCnGg|kL;-= z9$S(+xbmZ5R^fT{OjWD$Uu9ck8db6KbL_twpNHob0r*<(UGbGTTlsM&&6F<6GCi|K zS1K!aJO0ByZ10C>uueKjR(0XYv>oZ&^--ny(z+r=!CKqe)M!>LIKj~u0HW5E=cYw9 z;$vF?Es%hfK`{v^x89AxL(}_~BnY~(rR=fBXAZwBF_qnT1Yb;vWp!eqF(|y1H=0vl z{+MqAE~O1)eJZMhCUG->1apJ^wDe)g4ETCQi?k>~U#y>~jqHc>RdTD%9R;*=c!@2w zJ+RLyv@9^M&Pwx5C$<}E5mh)HHHLE(EPXv~&)Jq$3dLwiSykvsAdZ23LY1fXN%NLr zHU+3_V1afeS9=s45d0f??a8h=ShJc+uQ6!(zMi?uwKp=a$%aI9z=wme z@PxoE=HJ>JZ^z7?SscT3t4~#6A7FT*x~|Rj%6DB1tV9S(s~zEc%<#gOm}rfsMuD`= z`X+5nS9p$U4vP3bl~3$V_N`$Oe94YD5(u%pHlHoP%h_(ZSo>DCEAzC7=bViO8x6L; z5}Y?3vg=F)|844QqK5l^(_f-Li7}Ehy#GOZ#}ezjxbF^=cuazx`=2%1LZC&}A1Y?4 zPO-*tInp)8Ch-#EkD4nbD>xS8EBvZWXxz*D!511!OLBK8$oC0XS-GCG=0e9Fg&8I} zdBv~6)7(T$H0`@VMM%;Ne2F02 zHksOJ>Qnp%&_e%KG{#0i9`t)@Z?Lrd27F3Z#JVb2#v3AeB|ha488?%;Dg304U`ct) zq|?HDqJx>`t_M#FzNhkB>o}Lw#c6}BTuCm})wVpSW!CuG(5+Q`@Bi#;wNaT5G{bT% zEW5u8c^+@A(in;q?b&CT-9DM~j5jX}$aU2>-nrYLY5vNPlCwOpLi-m|q8V78U{t=%AEw&ef7sPnM{e{d08+=_I;Vf!G*q9G1>+1lf4gc?>VpR*Qas zPQgndoiszR!MR=3m-g`gN;*hi#LuNHcL;=^9GBb>*$#`W7v)>vU*XuMCGdDhH|D16T9EaU+#P_)NAk702k2-^82(#T=g)KZhQ9i_qJE0jzM9M%@gn&T_1m z)n@;V<+{GmiLy#m$J(gt68uHAu1i8hR<y~}yIc_v)0?koKj z*oUS=%cTF3QQ57X3%$GS3j*1AJiJ*EFF@gg>C@;w-~_4GF`FR3eWX(!!E;>KtPnx;S|mf!PU)m3$aTJtte5UQ@Qk1@@mRW7 z)NHO9XgE^rFXcBeTbkcZPI3{(Itpz<3 zc?Yj{cVqmjL6Q&LM6kQF7HG-e?MVO$*$H5B+C{}H#VOk(NFc9pJw;&fQzS?5NAhx3 z;dWbra~SfH>It?MwWbT{7ZNww0G={FL-aLE`>1VP7^dHbOxr?YPe>~7LJsb;z`>M`3AbX{0zje3_bnmgt#jAu@ z(j{BIir9FSsn#?Hn426YnTC$HwhmmbCa`vBA2YXUBKHb@i8lxRx3LyJYCFr`@7j;H zXCFn6nC2nU=F08qVTy}f=P{|77xZs;Ljd%eRufHy-6@2485plV5{m3wh$$u<>gHSsEK+Y3n#4V%3$6dqwcImc z1F)3*0Jr0R#ozf1jUQUuz@vHJ@ZL=C>NLq*xFCIb>0hLbnHQlbb}RZw+KE4fHID7N0=rz1A{!U>eH>S=2eSnNSld;PtAhS$4C~nN&eoq+1T0osi5Lw?l zQmfyfGo%P_3c42@Oq$B}a5O@f14Xm>py|BJXkE|h;QS7JkjxQn7C`7_^a#I!XA%x~ z)~76#tfsi>yFj+U2ySpyTDfUS*gxVYA{5<}>c}-Uxb_yrnyUHTI5$hxqb5XZI;XPL`PK_aFdDxhoRYl8}h5!r`Ukw0G*`RXDE{Y zEJCHNgIH(y3#36A@krZ;1huXesi ztFcDjwmPM=yDN@+Ot+R+;%2g!dV>f^ZA-uNB>GSI{aHE44*43mluEro-NOnp*ZrbVnNlmR43l|0M1n*@9YTMPq;Q`rO zbaz`3K6Q9-aJ(W?s%w6L>41$;m^F0PwH6Q5r^1s1J8A;8jm1W10n@IQ7q}xIV9Y>z z*`oxvRf8fX__&q_Yh3)6{;oN5Lq`q&gk$Ruv-UC{;dQ_&WONqT{G>v!E`iIvJ z_YXIC_t(Jil>n9bGZX`#^YeVCz?ZdSmHFZM0VUPN&(B)#Fo4I*{EE-zw@ni9LXJ4q zEZUxD!*emhd_JUg-x1Fg*qlcRSmF}s<@q8z3s3Elx`XpReSth&k#GC#j8_D;1?UO; zSW#2%F2z{5NAXkc`oJmlm-&XwR&GaU5mU<3feTVQ`ot3rUY2$-j=`#|Ft{|Sl`9q; ziq=t#BA%;m(vdc{(TKf5pNcXCWB6P6{}D0L9@tb{6Kgx}4zFA^ z9A0mKE$UA{N^1}eDQ$-j5fwTnnlCgy;i)w)^3JP_#WthOGK7UW<}tw9vaiB0xDD%- z#CW>_e<~6z8^qIDInJf5*FaRtJU8H2PaLp(r8Zh4^M8v3w!x2tOT-f^9!GNfe$Yba zvTbV1VArN}Ir^vclkX@}8*w`{Qp#0ZkXJ|-Vh$WDO*Z9}i*bRc($Z2Qk=29o%2NJ~ zx+~;tNyk8Z634m;!CeoDmX%|On`k9;1B{b*u9!~=6-$6A&J4({Nyhd#GvIRjacCVD z<{`Y-roHGjpjUPG3M+R$@lLs)|ABQjt%bCYu{Rk96citmR*D?J0Q#SdBC0>r&%Ri= zKh^H~L&ni`5i!ghOA?N|q*NuE<2nmV@)HbegkSaRu<=+^WrONJtU#idwUe|F+~U#B z0_?fy1LU+-TR(xlowZ=LWdanf5Q)Z+`Jwo=+f|HVHN zwFK%V;`m~$Ak&588BA0!g}A)Oc!^_{Vx{acIbXP$J+{o2xYTqHnGyca-Ba`!ZfZ<4+1%IBWmJvG zLerwL04(?{sl;?NL)akg60&3a zn)L|$vJ?Z)#FxllwrESFO9g-3_5?;L9=s=2N^W5k zQFf7>w>Be(@ucDfR9R}Waei$+vdIya{i0{W!;zoFK^g-85jBB9>~1iU@|b$MyO3|l zSC=?!sok!=|9`{2ln0@nw;|-Fti=`3D8T zkNp3FuL4WtX|@X2An|xhKi@07*7+QM=UzzbMSs|igZWOj*gy*KwIV(fozavEhyl{; zeupHWAJi+$+A9#;uZ|@bW8=k`n-d!^GGS*jy2yt^D~Jgu8y0j9D%&gKIRD~(vu<;) zq>IRS_giUO1aR%8=PA0RZ&%5YJlVrCA32G)5jckq^Be<2=4>8MmY)92@U;yi_Y+@9#Gc4b)9!Z$wMZg@9&^^L2# zsR~~O>*2rTALvQK?v@Ch&#a=0G>9D7W3SHD@r^g$)nIaxfz`SuLN`GL&g`bAE> zLNrmhg~(JLasGrAn~Vwt(_Hwh?kIK%GPxQ#c9^61lh~WEuOJs`sZeP8rpt2X>}}y^6?8siX6nb-P(h z{M1X4v5E@QJ7c2Ei3jzI$VYgT?U`pPAC!+W-dD_aj}gDXSyC;w3(BQ6U@6lU>Fi9F zTVSt>PkfL*!k(7TR{t&ROUqJC?h;ieM#20Q_z3-hF9QGe?2M7fEv8rQ2e27APqr7| z^EPpJrJBPrymp4Xvaq;J!G*@dV5nq2 zRU`O);UK&zlnoEYms$Qo$BXNXJK(|AEC}H}hs?~h9Pbf{w!)ok{*8=?1 z3=A!`wj(#W4{NjOY7s9KM=e12$o^0+P;1B&@EF4a-xO%8;S;=F+fs>uc(_;dqHrg& znWES`L3Wu|6SFlJ;1~Sgp0V;vfgX};*uJdmNH5Jw$|Qa&1+xm&C8;mTEO;h4PB(*T z=9wSp5YE9iz;{Ex)V;k)R6muDT*~OeT<{LwQ=93%UUMz;5xqp&kk-_oWiq&cWL>wpjPn^niD65m)bWGGRQ$Pf$=N4wkA9?*Xx8q^b%6FnEHi9R2+*RwV|J8~~> z5^jGKRqnl&8G%MzjQ*i&vgbZ&B41)B)ZKX#)Kigx$qD8|dGVrjK|{JjlC*6SR^)h? zwlT6p*(vG|M#Cf7Pa{1dC3qj7nLdwYOd4KsMDPV2S)PQCl+A($l!wr0;8Z00?hZFd z|Kqn2H$?`y!~ zy=36gM!?LM>53IHorkt=(u{>vi#(HKJl_vnm^AP5l z#@WoiSG?X?z{!rxsLibZu(zxPN0PNEd0p{4C4*TCHb68XvW1;F*{mhvgq zSi#<^NRv<+2Zhp_MmiWz)yq=AK)QcYU_FzI;F{HJ1gvn4cV46ZCby8yw2)&b-9}YT z&CF~Y7^M3}u4NkOF~}_YTR)9S3>h_k$r^Z4^JDOSz{wy;Jc4SaavywOy#!$p0JD;N z+(BZU>bXH6e=o~5Ocza1^bdWK%|dE4gKIh{uIkz|)skAtS!GxBRB#beA>ZW72DUQG z+&b+;=CJ04`nbBx*hN1{FkjvXEcI7v2BM?XJwq$Qfk-Ak3*IY6R2tPf4;H$Gf9FK2 zCwkU~tEr~7Qf(@78(Trn(bAE*(KdNu@S!@UW`TCNHdbf$-BO{BGoEV6lQ65^P=eH6 ztQYuiD=#uB6rD3NY{MqjF4O#QMETF_GPJju0{3^*dGPPLL1el2tGpDLnN#WSY(TVA zgG3;HSH9tB&0(m_eP3G;f@>dU9SWVyDz1Ixx|_Qvv&gclrhoXOcl@p&y8D_*s(Fh2 zhR^z0Ezj>+Q`)cPJ{;z{kR6MmhMV6sNcI=Vkm9>v&@;zL%e7 z=h>bx`(VQO2U`hbm5#D+bmf}luFLQw%$y{yT2^&mw#@>|Porit;Wwa1O+Niyx2sm2dDkmd`0ISZkYQbmc(2CbqZiiN;1!{&`awL8>bdT5)@xp$ z5Vv+tR!VrBYB#Yw)KS(@tI-GpSG_DTFPrhYy@$246q^kS=!14p_(Nv2Z(zVoelcuT zT&=wW>{d5uTh~!Y6wj<|p|^Pf?Q*@*yB(Hll-GCiD zzxSi(C0gEo~WLrV@0#9#I0L6TicNEtZ4Iqnc^U>I<{bhqlTJT-6!mb z$^CipI)aLmgv* zMWWeJjJYLTh}K*FNV~y1PFFY`l*HDUXy?pTJRq(J7e|Lu=mWRIkmW~<1c86@h?+RSyj(p*QeSLF%i zSAR>y(?Q9vF{Jtgm=ZvdC#J3VVj zV`wl}Df=KU$MPi@@0qO^Zv_7%FkU%YbK1qp_5*W;vnwh!M$b#l80IGP+;~-d&5;f` z#LYEj%H|hG4NQXIf02^Ia}Gg(7UXoQ%9TH(T6jVISVfzo4ud?jmyWj8$Cas})qziW+BG=_J{n!_338f06f z(S2KV9UoeuAoeP{i*MOSONS(NCBES&&T$xs_(oe9@DW~w?|@8v3H6Cy?%X1&g(h2eRj-s~Qaoy! zqpM(u_`ZU!I?T%ia&!{3i(@AA3R`UJ8>^ExoZImmkuI)aSHr@1=Q<-X~- z$_7%C(sICM!7i>(_N$70yyd!JLO$jO#@Vk~FWO$P71bMTn;g?TGDX;zRrd)WY3t$6 z^iNaV@de-!NDVrfS0K2-J|3A{?xA4Oi1cAqV%M4UT@@|1?^X;Dcdsr`+mIR7{vwXu z3RGw4C2nq-@*X4;OtWuup7gb*Czysg7P45!oLg<_GXEC8w;{jz> z_0(+|mzq2+)QMWr`FR-l*u2);Xj3BkZ(b`f*Z3l<&^l;4u0g#mXuS|T)$B%RJ`Q?=FywPQ&MxF zb=phbWTvBs@y_7Ic_uJFRQUp(x=MbYj`nM;xGfnumHrN^sX6G6QjYO!49}EvkepWQ z{R7nxv!BVEWnX4y_{O*o?wq&thQqBX%Iu{|uTPbC^tH+nxc^ke@P-80^5kI7p1EKm zBUQFV?vN)!|A5K$vEdEyg0NV5MLQ&S=pH(^DzmQkHr>JEH7x5=R+6u~w$jZBpK~PVm61?pW#AJs8T;XRkUk#X;a#d}5{SdUMRpMjocmJS zu?jq`RM(Ni?k~u1bz{|i_ayTL`V=Ntu9u(2njq`2I@MVwn@TloQN$xhRLx+>6`?c` zDychN5OClb^lc-hNQ0JxD@=Q6kLabp6f)5xlr|(G(2)ACiUr<RjR(n&pZt=n$~RTaBzG6BN-FzP~)s)c#%|)Q_`wlRYDSa!46d zIl}dX(EF5B5!uqaANjzu)2OLJu?t~Q1k+poAky*g?|ozEC=BkXh{yJ`Y&?90GB|Qc zM6nAm(ye!yG{0ekhedJljlSDhW0}=(-ra}U8)^IO^lo7O(l*nb@onJb(%ZC7QR}8p zot>EtbdmN_?LBgBmNK}*kLKLhzC+gL%y8?G2lPYgv-4+7bodoIK@!F;7$&$DgAJip zZYw+?oGW0H1?p|V$|7 zsEn2U4%TbVt1u)9nHDOxLE2|RLbck^!Vd)e^H45a_4!61_9`W;thjS&JEV`#Y;E_@~`Rzcpw}rT~@&Mfi?kOLVb<$fQyGY(u z@AtOBo+;B9E;&|p7Wb(#d>ta(-Alt_rdPF>QXvCT{*E&)2mPKolKCWbKXgNnhmwP_ zS-*9$MeOW;}ZPtJ59e(wzW11b^z2qF6K)+_Rx(zVP` z_XWi=*ri^qpGDUPS{uG&4XzR1JZ1CRB3QC}oo|q)Me;b&{or)pqCusrP^{ zRpRh{?`HgcwZe!nI+*ab91Jh`t4jGqfE z;T_<8lWu9++PMPg7x4uA#(VqqH4hw*vNv$&x<-)_RSEJ|5;5LXq{#cz3j@EP{Gct6 z?EhA#_XldG1fIcHRK2jTKFtnY4Z7G}$TQhQZtfy=5^qTT+5b*myfvft!f#pHm7 zWLK>wXR7YgPvHARRm3qjkQs(Hi>$>c#{}7Q^dOZ}(-1x{%(pc21_EUe*EpYeRI`Dwq_;aKaT)T2ybx$`EvB~^M;d;LS&3~G|I;$k&S1K(lPJRF052 z6qS?H{Bzc=a2c^R^QmT>f1kez(NsJrWmjf9+5E=GG)0=-wT0wK-@UpX?lG-KNRw#| zxzO9vwenY@0KdRx18f$LC1K~4DiMq`BFjTBf zWZFoY2fk6cy2jGQ{{JEEy~^xZrX{pLP@&4FKI4pYrs#YQ-~UXS03soLCsnV(-(-p0 z4)tQ?&n9W)M^sx^FMCaDy?@oW4oCa4MR$lgmms)Msh2`N7Vo@bXDgkZZHbeR>C(hd z`fC0?>yy%dh2tx>le4UY3cc8BkuhU{a}D>4vbPnkxQ`xmvP#ZW7NuNaJ5q1k2FE(= zD|wlGF7d&VU-_S8Na`!1Q_Gqk=A9~cV|NSL0~`q9+0Q(7xs1(*xH#>$qn4D zrVQSN!ljk1**?09AiDIVvBS2hsjK+usut3!U;!E}PEPDpHr?)o^+g*}FR)LT`v9xW zc-c_LhrGp{;kZ;mIfl9}2%85h5(7;Sr#}WCThckN6L^*9!6@D`aDT)s0O8iu|KZ_H zqfH-deChLT%Sb11yl|#7hN4A(uo9AoIsCo|XRgPqu!TfN(;3lTaAMi`@}_*hydX8n z-H)0>Y^*o}_bpt4{39EHTFsL5&frh;GR{~#<=Ex=$2^-+@mCuc0`1}Dj?HeX_^b0S zzZknv^%H2D5>s&#c*HLS22@OrBy^jrrtvGPJBxzFuN!sT&PzLJ+f$LV{VDnxn_zdR zw-87?-8thDpNN-&HxoStBMQHz&ochYKhZc_xBxE@8j!Y<2Fn2`)-u|?5VWPGI#D9a9$_b|I#)1|w&Y2wK#>g31TP4k)^Xs6%C4n7 z%i^qS*e|xXN;;Zy(><}H-RZdOg} z+P}RURk(I|_Bij$&~xwh;2vL6Aco{=l4_m>-|A0kmogPK<=Gv|n+27I#`=re`g$j4 zidY=VRAmJYYGbt1$c{Ttp{g2yo@+4HDr&Z8{}mWVJyE^}w%Dpw!@YrUN-G}UDL1n^ zaM-qZ)o!mseA2bZan~5D(URK*!jf+C>7GS)gISJpBcyhr?33exeKJpXQVADUWs1+zI%NUQWmXPHM}D13*GqF0wB6xdx1E@5)Bi z+N;2Kj}A0gM{A|*cbT$EX({^ zv>k;^+1NVHS|gv+2^|6~sA9_0*@X*|X+X7qL}VH02`)B}shEvU)dmYr`n$Xa70R)2?x zJU&K(cBq%y8N73xoh~0E+2BI1us8xK%Z_lWx^UY8jl8poOOP1WTI{#2ExbzbpZOZ? zHFsAc9dC;jvL^`cA{AwyEvO(y@DV!i?#8!qqpPrRax+;k)y>ycLFL>$q0L!^MwmrO zdn_F=1+=|F#{XBp-*wq>Om~2O*8bf(#JS$^T}L*qgVG#BD3k2>D$@cP>>=e&WEt$? z2yMCHCS*S7!YZ*<#$lQzRx`HN%Ct7~e+oj34ZuAXjR2&+97&M zU=TMFU1=l;pYcCQS|tg{RBV-_vwm@PJ7yZ5KL-fW&kcw4KlbA_F5#ydm(1Nb2T?Z-^>VFj=MUurCyWMh0WBC z;EtorA^ryv?F)rYj4HT<;rvAqakeJ$*K~Mh9|<;JDlcv{u;EA31Ynkw8GAU#X=6ajZYHqhoAW>gG}_Qz#|0g zH#|3B8vY*pANGT}nKKq#;dx@{j}*aEDG$Zn8F%Ol`S&XZVroaem3uq{agvl#_EVwaA%PZfE?j+e3R@xMCV^{M?gnikytz5C{mPo zm*^ha#XH%VYwPWQB>LrP5e^pY@^nB&bcUynL#{T$Rj$o|%W@jekk-RO%D?bD;DD$? z!11KO>E2x7Q1A!s3xALN8+`)5FFId19r{f=!3s*|lwuJCUiS*3!uEZJ4@zPvV85 zZ+nw9y{qubKFHTH6)dIPWDn~R&@Lh_LpKq)v}uePgamb7<;m(u({@x!I8gPAc(3$K z#qY|}f^Ak#yxn3X(n>0$XBqQQjcjz%6Vv^YZt8i4Sp88wOzIa+i2cE~CI;haNI$^Y z_HJqV{$0p45wZBdj;8H6vSk0y@Q%I!IB0oYaoh4eX-IUfeg##9E=K;*AA(02W+GJ6 z;OI_W*+{YBm3Ag^h@hQwX-q-MX=9D0j9Ee2LkvPxBG>td`YM6vctW|3I@EF4MpG`h zNUx)?;!X6gajMdO~` zLUtiCfDekSW!m=WiiBJCxx|0=Y)V+~u+f4BzWqEPHzt5_NDc$7oKD%qfLO=Ku;H|4 zl;i~CD!81xL$WeV?b^vHWQ=z$vTZ7y9FV!bigE?7!qCY)YI^BWC=~;lFOZG#ZS!ri$|HU^1kGi%+N+<-dkI@iE#&21qM<3w3Z-*Ch3#iGiZjAh z>>-j5;SF*g=^kVejn$TMqwu%hzQS6rj~~k;*alisVTqth265*=JlATkMQF5aSBw(w zV+K6uT$8OFDJDR%7JGsEx%&t;i?))*F;0~Wc^z4>^|s_!=_26*tS|qMWGlYTz1GKe zcC7nbxxaRiuOnv*cYxr7z~`$_JjPyD!HjcEBif&IK(d@`hBpiUXFHGY3@&1wVWal1 z&H>#i{-wN)mK(uN4kx=)P->m+Qo1`yDv+M6X}qoY2Uu@=z=QmicnUriwtD%NWymf0 zaq@Z3SoTr0yZthD#5UNXOenTv6z%>`2?NNfG)HVf{)+hNQ7ojI)IPj^?{0Au=9Uz& zwrloR4v5kAnA#1jphvFREzpq?wvwx~>tapCm%tcsFfE!e9!W31W8GJlRAo}nWB!x4 zhW?7iDXp#Q*u85xKlUWhK*;DOAr92tsho`biR%p9tR$Is0qe_W?0=$PNkL-{YyTz{ z)8@vD6aP@o?N`L>)lZEZ^m!Ff=|pvRP(|KrilzY3YIPGmqRLO`s=rB1)N`ZWlnt%A zryr~x68A3QVa0#Qq3Vl<0SRr8Ma)%Q?G+D`Xx*|BSDTpS>u6c|ZOI!zf6}q&y99RZ zf(jLVLglo|Cf6Hmtz(&A58tioXGVAgWuk440D@@vOsK{}<&BE^%9vyOVT;E6dB_5rvkLNzj!NlWWlOP7#7S5O%TjT*kf59w+#y1-W3*1( zb>_up9=)S)E5>A;=k>#E($v63D%*bx>SVnmHO3)WB6`C+2)V=hNmtpKfB=d2Pm)}y z`(*utzce&^I{CA?|N1fjfUm+DVc0T-H38l1uXBTN8K)g3%e$Z5C~z@4@IaB-IZ-lG zl&0@morx*1o?s)}Br6tV^ATnMT@RdO@i-GCD!|Ie&I7c+#_ z&XOo(0s0(#h|eR9L*`M3mGpG5bO$LLiBkzB)XC6S>>b4gC`{3WQuHDdp z=sr2IrFuuw6HwFTPyWDS~0Wf?odOxo(;efu?Tp>CM|nXac{sgn$0zVBEu z>qdnpcC@J*oWzMQZRfcgGbe6w645-JI*!o4x(}CSYo-6kzP3Yl zO>o}*QnX#P2^-0ki&1eE|BUFi`#)UgI^g)=A0;{yKJWeVnuWat-EA*DmH0{T47UW{ z8(f5z3OC!w`?CZgS^vGIVNYbF2W+{;n_XKH+)??D?22r8P$&%9-f0`4i%yDtnlL+v za<@r4DaR`Ii8?g)R1RvXb9c zxw9q*A0s*BUk075jSJ59s~bxE=LL0mGrrb4K5$GjLgBL+1pD9#@@Cf^N!Pk}b>~Fu zwYAAAlrQ-s@Yk-9zOSFAU*7zSPVcKTl3GX0pJKv4kMCWzsI2j#=0xvvKbv=<= zDVLlM@b7TO>e~lTyV~Jj!}djKjg_+q8YoC)&*SNlS8?rOq2Z?U8d_=?!kguU0t?sx z|D)@1Rc7){_a|;2V~_eJ-u~EPd=(hobq#qSQssE*PZnAswo8fs0;iyjU>G!@$zX2` zjKOrqTlP=DHOCzEzR40?AUzBI4>Uu=8PN`!wARv5lZQltb7h#IExgV0BZ-Wz3A7h) z)u+-W_&euFq}5VJx+WfiExlqu2&X^0KbAwi$@>SzokI2_|6k=&>Sp8{ zB!EHBs$v5tiPYbDK{}ZCg@Kywl(t58e0xH2v05!NY_Iys*bv2m`XPhLYiVnZ|3&uE zuqaW;eEJh&9P*(!rR`r@OOL3^(F7gTQrKvys_F?3p#4p6-3`;+?Cpk4hWfIlni1O$ zst0POgm;k-5?HjE)a7x(gmK(qR4FCGmP<}CzXiz1oXQ#M&Kx?WQ^hx;PCcWnN6`de zvu+{#FW{j0RpHalRp3!uD6XXY^7u6dm*!^YUh4kv&XVc-|CAIaOhZo+AF=aT7^&C( z^-&h}VdPKJJi^ArhaewmHSVF2V_zfbj8RoTV^Wd!+p;@{xiKqEAiaT?iTa6^7;GT^2!QL~uSv_kb=Nnge!cqa3#GcGPiKAte4Ewf)wl>T&2BY)+49` zX71AvpjWITg|Xe_kCNd3-lwiYsuVS&nMQhy2qUW)>Dx?`zLS> zCz4Kb3A|kGGnPMkqE%u3>_E{dXe?B~6PXV2b#PZ`OYA4zVtX7iozc!%2%V^6CJZH> zDIbbOBz>cc>1F^VG!u`}j#U3$y~frRIit55Du}%Z<@$NZ5incR138M!CG?@>vux() z2^|Wb()xgNT(Vq32AT%~%aV+atw1?*o-sBqmVwd~Y&+$&d4Xk@WesH+ zJtaD>tcVcD*vJrA7b8B(;2u7zTXUNw1G^(9k+00jrWL5p*d{Wi?2uMlz6trac=*mb zBof)J*{3^U7zetTTe->f0QGy)61&Uv1{gto=|tdt)}1szcE>!cYP2c+EO(NTbYUZ7nzByrK$_1X1*S+p99Jc^_q}-eXR%wd3xj zs$6IAWyZ1i591>MgM#eyxB&TMDMGd(okio>dl|31^Qn8Rh+%M;Z}Q8&1kp2w`8F|G z_V!RAN;7-{ml;ObrS=U(lcn5#EnlerL%mWGg=&p0;Wl^_g>1T@pJx~dUSt{~ei}Cb z`>R)1-)80PIV5Ng^t25!@9vVS?NxRO*bvh_9rn6T}m zI*Q_j8HlWmXZgteKrwg8bk2#-RySydSX&4(%@PMteX>J0YDxGGw{MtS^RlX^KxCXj zdt325elHRY$Ln`h4XYlfFVjcTe;9WIlpc5V4gBfAbR%HG(dVA=1XEl~I~wb;`AoOf zL~YeyV6>spJg%UL(w#NEst(CfU!fEOFB~(8C7d}WHxo}YhwD>f<>cEXbp}A#k2Eu! zR!?!#S$~ARog1y~G+&TzikGntJgp36t}=Wul^I_!xccqreOphA3_4u1MIY@6B7}e3 znd_$uf}S*}#d*XQ_I;EBl2pk^bcU4$uaekE6_hN`d32b~CV2=hsf@!0dgYcR#x~Z9 zu<`X#ICU=d-Dl2)HsYHTzIYb<3cYG^fjb8u>-b4dX7^C!yWiu`&)VxuESS{SInTa6fvS0c0$asFQUR>3U)Q7WL-3p9S7i% zY$GLd-*#IrcMOt@JhH|D_Y+U4hoWD>F`j>Ll<^4Y!0HMa;E8a5_=m;Gk=ULP#Ab2Q z!mv-fRC9m#59)Ymob`>xhYUdwqr75A%mh1tS~yMMI9n0jM*q$@RH4Q)SSHRUTBNz1 z?dnd4ZGryS)(Ot7RU7T7`M4z!Ng|DP{L3LTRObJXO`J`H>Ilrfq4P1CjJ*$Ai1Y#n zvw0W_CV_reKKr%(Lc&9Ow6@Z{$U561s(Po*v@ND+t$N^?j#Sy#a+5O#G%}YEo?A-T zJm`(K6i$O6-Z-=^)T|dUJ2==PrL3c84ZnbE(7km}O#UT$MbB|`MjlG*IYWFs1JkT^ z;#=M>!eik+>m**fZn;SF$7Q`+Tw4EnJO{3gp`- zDqr#%xc|WKm}GF7%jO&7j>Xg_5BFcEN0JWys_PNDDE=3o?WTA#WaXj{{^6E_+7<3s z@F)Iz2VY#p%;O$ll3l-}cUpUT5Baxw^Z5t7Gi{H}4~<#D3xb8>ZvIHw`QRhp$od4| zA7~xvs;6Vk-&(Vc?mMpO=v{@!*)H`^`EIwVzMm%g=jylb}NU+(AR zh@w2T#G_;IR$o`uvPv&xFs|N6XCrZnew4Xgg?Sx zEfC?~nV7K5H{T~0Jm)b@ya>Mzc9Xs4Z*VS_bupY$%skLXIz+n7bDlNXGhRMZ zF2eJJEP)g37W4=!q!wqdn*XRK8=E^x(jU+9#s>b9)%kaq4{+_5oUK{H%~nO$w+(%^ zf8$P3j1cyR3zP&WN%vEp)^cULLTy89eHZbGND29@ z+hXT1jKC)KRCJzYfOWio5>jDO$hSM`;@(&js}C?d=`r66aA1>VuAw($F=JJ@gV+-? zlq7j^^da}XdY`4x1%(?a`!%K1!Gb*ZGdGCBgw1vuxZhR74nS`O?*zRIzX^U@CBQAT zgF6P<>Uo7XTjp9q@H?^v+7`PHI#rWx$-$b0L`@gO?rxx~1ROe>BQYde$Hnf5x#i&5 zpVQU234hI->NpZu!L}Ct#d=(|8Q&tfV%wwBQg`E*zY$F^q zqqEIp+6cAn;ypugCdr%9B=J8bVF+66~YdpRRKZhOA1!RoYM?R=e-CTdkq z$N!cQB|W7leP<=(6cKPV;uVwab)r(o55Yy-zpSIwk5WY-o%Mi23mn866bIra8)Jj( zTx8EcP6t6b4-gRXuYqY4mG7GeaPP(ptjmE+-cc+`*adsW&*4Ugv)pOU?#_cQo4tr{ zb8qne>)A;aF?T{4+&F87puo0Q+s90z)A(BnRO%((F)`7d1Jux`I`4$fu`7+4k|WIV z=wsav&O7cj_F&n1{@{lGxVmAdbsU!G|AUWlFNThaL9Cc@#~6o=QKm^GxIr*d(&E`B zy^fXoCrf?{CSm`1*n%zK>B>IfdKk4E!ZwEefq1@!|4vdWd?fkI-6_ti;rfS)VQGEv z9~WO#g8Wf52`<`0Xpsb$o-~gae!#nXoN%o0Dcj=B63+4`3vLP?h+wRf^px|RyRGy) z*G~$G=KG|=D|P1s5Bc4k`NCnYrm{H32G(h`gODeFD#pc)iI=_MB?3#RPvKWeF1tv~ z4XzgM{P6asz)1=9wEY$Q$(+N*h4qe)-uA)HSTpLCOl9TxM>^}g+iHk>78dePas6bq zuc`5~gMYc={W;JQ#6v*CgH{fbFB5Bk<v0yg$g{#(yqVdlv}$dAm$i=@Zg9XSL8nfi}oW|`icELm?@YvPJ7aGwgI z5g>rj=COAA&-nZM=Sh#6k9uX&Q|M7np*xHH(Z9w2gmu_)pC5zo!;iS1+9rt9fp&sZ zxY|n!FI66jYte)B!G!DFZ`LycrRSY%0MN$2k{e!mdD9y1zbl=p*e%#8+~o9)Op7&NoE=Y@r?F4UdK z5};#35B5D`cd`tbZWe-{wQ-SE4ml+Rih-Uk19FSPGnmw=+F$WK1xKKZZlm`J)J%#H z{mc4*-Y=Kvn{_72Uiy7oJ;_DCLMPc*qYG?q40inkMZ-J9lbAZ@_VTyRYZQqggiZ5w z0p`N(^?7~=`6!y*xwOQ@Sd1NY%(3kugB3&U0j$NG%AO5=6td!)kiY2bOXF+|?e~fX z;;SSTi9q;Y`BVxwA`cC9zFnqqzCk@b2o6F7X4Q0B1cRkpP+=?jUa^t@QD^M_q8?FsrSy2WC#*CrW=^1%(T-OE zh@pFD#`u_b);e07xLp-_CUe4r#KY=7^yP$&JE-bXS}(OV=7wgnv8!QJ#iz(QnlOu$ z-qE}zhENfx;_|v9UlPxoZV;=iXz7IF0TsWI2L@rqw#1>O57CF36w9iZFku&*RC--6 zAl^^#749SaASjv9@lDosfRTdk=M!JFd91%pl@t4xj1{b`8Dr^hld<1N&vB1(%>njG zVxcLXwU}St;_EDXAIKL@K!{kT>mcW(d@gGdx6)|>I~mXN#Qw#BcbJmkmyUAMTnX?7 z>o80~`NZe)HiGM954csZ$-5AogunMJLoaa<@n^|oNQ5OS`Z|8F=i2_3c4Suxoi$?r zNlv1Ahi0T`4D>KK02}0)AnYPR%27r)^-NKJ8vz)^KAzIS1`;De7i>iMmm!@~BIqS4 zcg>dB9FMHU@LNxJ?;`P1N<5{V|738J%gL{xRm0xt};#1W=cAj%wJR z7$logQr=RjqPK}dqI*}5i2qnIUGvI7*VU*aV+&bZ%zo{l9jg*!sc5)!IyItg=XOhH$CnvWm&$KaD{fyFjQLyHq&P^gbACCIYlS*uWjFnby4;%Ys+uOy=WJM zpd4ekFQo+o(et}CAv~LqoL6wnn56keTidmJ$&%_`3C|1n89vYm#=S_fZagj6@peH` znG|~uk1L;A(x>vX=6GBV?Ovtdm#6MhVrIUnWbQ)BatUpcgh>VFUSQXx3)aZ0o=F{g ztU;J&jXK!%m`Fn#$@t64b`8hdP<}x3oHl+Y++ZXNyI25of-4RPVl2}Z`y1hQ&nY&= zbAuNbzIzzwelHv@&uPC8e1cxB5U7)^V+bXlI`bK_!r|q#fxY&6Czqee>hDLP-2DLD z5nhNlC2U~-1Fb+e;M19;3b}2+!0P)9w*Iu*{Tr+XwOkWU633`#J{Nza^0zYCIp-(2~Oxz-3_{c@{L+K9voi zmyBgFVo!maL_}L(w3xaYev4fWpP7PA0Cx*VvYLTYF>B5JfHi_WF}+!n&FkrV*)hxn zWDXQ5$OO(f2Dw&JKJf2Yc3^Fw)kr0pW;kXocQJ(s&&hiMsm(0D$^KUSPW;O@6Unw! z86H?8ti$++kv*KH>;v2bz^3pXW;pa8wgxo-UE^njP410$32T1TTI+o<8u{BnuuVhO zy6!;B%!9l`9M?@gaIJ{rcx)9_OD&@cuOk&$f*_rHhkHa5gVm##xJNhxZNs^5kxh6z zT29Pw_9AYxahZDsdXmurbpz=TNXT^#bl>sRy84iUj9hNKZ4CVfypm{Vyyo*vvO#nHc z%-3Q++s9B+kfWfysDQeNd>fcY9H}1xT(>sx2mptFf4|U{TJS%{K=m=G0q)S??--W!2Bnj%ybER+>|npO|0)vCyNivqUo4NameZoCE*=f-4~-;u(eE#e zE<4GJ<=|u1x9kMC#r<- z70-AuAFzO@#oKLi;H_jI85eGLw6}9ju|AzIh1#g$z$4H%p3<$`?Q;G|*E47Wq0kX9 zmD)Ocduc9MZ1g?AB{dU!39WHcoZ%lz#yu#Fy(gR@jC5d7C2bq~yY?OM0-56}LMJj> zsaInzxdu_jf>r2p_!B&~;uPFbk^+#KJu9!0j^KIl9qVj(vTQzWGHAr!yG_^)Y@esa zHXhQfCRv?$9N z_|Cf-i-%Gqf-Axh4hZyO$DC?BM6mmbuXLU~)Wkphr^xxJY>K;Ts zc?HTw;%x`Dn*=in0^MP>h#N~;*KK;jNc{)nU_xYhuJ(WN53ARJ0MH}pG~jR~(nabY zd(Y* z!ReGH>S^`WgsrI3Zr<;wJ~iw{?Er;#I_x*=i#?>zB;|xD6t}|5;_!(jem!OIEI>J73*2MbRCdo#yxC^^UxTX)REbCACWQD zEMwfQ*c4ZyeZrj1I!D}Bx!ZDyNh;`7vcj@1ZUS%@oe!DRbIe2AtwJJgd}{8lb;KFv zl~f7sl$MUnf>OJ0(yfTEsGi6=iMHYF0!Zdb&KTQU)>R#v&=>eazHDhVwl%z@6j0w2 zuU8B;Nh&vV4^L~$f2$80`}W8EafibRt`eFM{KdL_$&004kH5 zR{XGfMnzA+ku*VD2z;V{0Ho0q>5--Rl!G8x=|{Nqo}}Xp42?D`Es|x=1>?9jT~`zz4{S!NSY<9Rs~SCP;Das9g_L(C2dbhCgUMaVXA4Mi zHf)eSSv{2gLnBwu4V$}s6rJWGikG}V*pyn+^u5KD=gg7%PSm%i$&_i3VvWa~hO{+aieISL>Kf@ZV1RyxEs4^BVT{-Sr2`ieyOLfri-0>ZJHq6%9=f*X zI9d&($kdT`*%r|)Wc)-XKsyaM7=ji!(mDS*-yEI#F1mlnj907p-D25*BFwb9_ga3fkF!gU9uf(!-o~f;^1qRXF2aD}`jUo-hJg zU`g{ob&ueZx&MRi!iS2w3#+Y%4d-hgp*c2-Bb7VWewI(S$$*F6dE7*3bk}6(8~bID zf!`9<@Lz)(*BRbNV=iE|UzF(ii@6(}Mgc7Rp>gO0(Ou6}M3>jQ5QRLr7d!>W?t!|-w^Vw-N`NXB{^81)>7AOda?b)sj| z8lZ&~zbhJ^C41su?B}4Z=mz&FSW8*W`-<=N%2}I)E79@XdGyU(ig=MLk=Lln@DP1t z(8I>To&)A5Ccs&V-7r_$D(y>H7sVl#RXD*}9Q)QV&HlIH4Qm@e)85w>?Gibu$a2>N z+P}gOIt#tV^ED*t&e`A2_<%@gpC0#x-ej;;zGW7(#wIjJZ_$4--PEVhI(Hun{UPL*)d2%_bkeO@ zTNP$rMAv{w)x+pCWGrnSd7{Zy^2WRh85iA$5=;C{x<|}F)-lG%Mw9n+Kd)I=#ZS0k zngLQOM^+fY8x#j&eE7*V7gP2dwk2MQ9#cJ=l2%NmWm=BgNZL$NfDlpsO6wv2p$58N zuV!cy^yf8J!*vRg@>!b%jMPvl00l2dK-dGp6N`)fy50&3vr1#qI_SN zfVG4!1Tx#pp)av9g;xza3)`5ho&pBV>ovDQklKTsY*=H(tA$mmx(7vr)!BQe8~YW{ z={yITtfo7_aS}CM9aZhROumDKwjau$O`d8B>i% z?bp=Nz$RTrLR{t5qEU3RZZiBA=Sk&UMj!29{cdblQilB;aTWNLjMB9gQ$PX4XWZbg z&~K#wh+h@|gq~JC(dot}Bz7Z=27ek}>37rrXINQk4_nx+U{_$Dc_(KgGQhgrn&0_5 zvDdCX>?ynF(iOyKR3;cjpKqK~`GO(z{Q=$PMZm~{!hS6f;ik(jQ$cm_?mrko(3$iPrEk?U&UWWBeKezqy&F7*JTV><77O^i zXwDz*UT&TQ5`GX*v2F1ztZDMn9FI&tq$3pb_}`tE{a*r6*btXaNDw>`Ff0biJQh!Z zN!!c2@q0phTu0bWhAR-1{p~d1K5q=`h~)y$<6I}3hL3h9<2l^*{*8={vRwS0b(3&O zZGr@GE;}&5b4o;!%=WKwoCoi->2O8Z6EHP!O}xha+R>YTk~YjA5H}aU^?jtC;pxO0 zVU@CvKM#DV|5wo4Q|ifn(0enkify&GL|W}$aZGrbVUvD!X*^t+gj4B!?7WBE_H}~}hrg37{x9C@K!?y! z_cu}o=d(l1|6F#;ZWmr=?*XQI%kg~01>do1j&Be=$`hfE=Uw1;6rXVH;?~O!3wo*h z1Yp5UUkkqk+vpc~`uf|3`4{0$BpmJD0_=nKdFIk`SPF=2fMEgOA};h^_f2qqs$S!v z;?H=kf+W11Z<^;nIR_eRxkE`yD%K8k1^IagRag^SYVhM-;=EchG56I7-e^YopS|!uJh7_=yKvY24;B7TS~cJL1ERFglv2C z)w*?Bfw3QcBGF!5V&G7&66VmqVT%YoJsXIrAQ4>7DnO!W`9_Ga+q`?^B*S5h13q!zF;52TF%H#A+BQ;%ETCbR|XK`LDWO@<(yDqN}XSi!_-`6{5>L0 zU>)AUd!OCO)#z^#mWWfBAh3zD5x?U<2&Xcn%uF{aAht(H+w;x_drRl4AF#dhfMj(WVLJqMoFM^bsHfXm_RKTdlNjjex#%ZJ7uq5{ zsZz|}BOniE1kQ)Koja0m^SgM91TvgRyR6s z8$6QFv$V&KCcSaZ@40Qw^_4C@SD32zbV-34fD0^za3wooo1WV zg8$(RvP=hhGhoMA%PC8DNd|sH?>Ak96)*+(1(ylWmYwJQaQ5I2OPme&!(;JObf$Af zHA4D8>&`2L2l;CAQ~eKUVPoW`qh1Oz@3L-LTr^f0(LK{GPTanY&e z_kce_M*atThI3_&%i}`{#sJdYPz`i)PD2)O#@Xakf@R*x-cQ^biA=mra?RO5+s}e1$&?7%bLV2l{qSP{H~YA-f0!wz^3)<@ zYZB?M#QRvR9i#r#H&8RdgW5{)IOBFq9KMVvah+#mAx9)KF4f$HUr-bv@_^H{RQSxU zN0bnW#{I=?Wvs*(KwH5fgi=ThhtQkeoh&6i53Zz-hX&(ha5dH#05FxoOzDHK;LlcU zvw!o{>nk};Ko|A`AkMy7(#7128nIS^&n|S(+c#QZEZ)olvyHv5DBDxhBdqV3JAc}@ z2|tK!+H~O$PD>Gz-Oac+Sg4%{xqa4Px~JZ9NgmH1Aq~k=J)=?+8}-~ffL37jpYUc% ze88vRR#rRyz1rETE7B@oSEZ_PzCYGa^zMe@lLyF0*pnU3x|hgA>32aoUE#f;SIdvN zw<|h&!j?wtFJbr8X^OR}0{mLd-XwP*T9v~lDEoW5!{5@|dlG72NOmed;J>lT)Yl=4 zYXqic<+8i;ue)-1LuDTfqXOG%&n52`eDs{~P1m1t)QX?rb0~v+2wp1RU>pd46RV^# z&VE9e>6Tse_Y&RY50k)fG&|C;IQ3bW9`(*Y-tjq@Eo^Nl_ofR?c&`64J~88O{|o+C z;qv70@Jj}GI!kWW*U5GWT*{lNcJ=4lX^L{y>)LvVV1JS-W^7Xes%aTZnZr|DP2mJHD!I+!jp&fXdWwTVAV=H)@^H!l>Ez|?<060Ng5PmJXs2@VzBje zgEKTt9ufLA^c%dblUX-mU@PH2iwDfN=;4I`!Jw7=_H)Ay+6_%{bhO%Xyg zqmB@j**B0a{O-LTh!LhX<%p`AhPbB-k00tFq1TgSS?+ylgF@pZOQ-`I9Iniac{sCi zwU3^5PS_*Rl|D+bJ+094MgE}SR`WV#b(*8*h-W?CE%o1k8n5vGuv~6A;^7Ml{nwk< zHIA;$Zlct%ecNk#guXZ>DsMxR@?@&S_DQypy~6NDG&H$QK%!VKtQQXm=g5^3mExYH zn)co^MsYCpkY=Hiq^L?O2mg}aN%>#KHUEH=y-m;46I3zsf4uZGr*LfGsCbew=($ki za^H8ZYvH)H^8ET6saxu17c=FFN>RN=8H6tiz6RH47!=c5cBE=@D|9;)^U}vPeRb@% zalLh_UD8yc-UBrhO5XUbEz1>Kf-KoZX{stR?SuPFO+oModOvkr<7cn5=CW#y|Awmr zW1#Aiw1{z5WsR`0-rGvdAJrn#6S0mZJ^{xzOwRo$y!F7+jLBXeARYXhn87HW295^RP z=i!1rAvJTeq?lJ4GI;I@=hx+yY?S>czAq_A7Pzkq8jJ73arIqgPTzsR`ua%vbwP|j z(!J9K0f6*_^BbNm3fb0p+wqR3xm80OkNYP!uID`tK1`{TjPXPxt%Ahnjgqm!6zLzi z(enu^O!tLGyEZu6(#3qGa3a2xIa|8g8ejMS?~)rjjt%x^)cmsdl)MbQ<{`cx!9NNI ztO&f6++otCO3oweRoO0Cglji*q@OB&2A%NjmVXlXYhJ?dNWTMx!oI2|JWH5_DP{B7 z^7Jkav7`>Yq`cwzSK$kMPg)lkB})~n6ZI8dp6Q`KtIZ}HkYfJ;T{z0mt zMpU;$I9Az4)jh43^k$RiP<+lhr48%cIyU!#{ihXmPx9|hGA(1(B<^s&F4}!Wt24V zGrow9_5_r@R4b%^XI3_s=8nzDKF~gENXv7@&88)S+Xp^~$2WCo?xs4>&_}Ucz9w~g z%Ok;#^u3K!JU^2&8(%fvUf&0gRW&=MzqhG*c=OCqy?<2mIY~qMgZd-cOEWe%$&;&E7dNG5Qv-FdA#+W` z$VPp`o79hKmANIz^g~a~+#I%YQj_aY@9a(rM6_5L-zd$U)cU^lbK~EczYild*N>g) z`7My$v`kf&dIq;QuN8;+sOgBTFTv3&7ktY8w|>;|>seP>VinZVNBOJevT|n6riKw2 zqVx{FcSpbVN>&Wc0uD`2?U}sKf9?>`zoo(L-ISqj7I3|`o(IRLHZ@LAD1 z<*Irg`YkHT9-XURv)IG_gC7aT=A-n44 zio|}As+(%DRD9s^p*`9Ck`L8%YPIAHsD-2_LQ^vb)-^QrZm6kAP(J5osXRTer*29y zb2_HpO8uFWmeQ@3U#}A_7G`DcZJ8@vkjxQW3J0ifMWnO?ylLJW&7Ylhnbi%|>2Vo% z505yo_(ZYfcBZwtBDcKxOR6RFM8<5%ye5vzf1rbKQ`(Q_O{qDJnTj+;Wm>4^fjFya zQG>|}raeiqH05PZ$dEQ3lug58npX#C4NDYVn;Hz6p)bvY>+)2;xy-apo-K+s&F`eI z1o9?vnnT*uN@>olvA}g*caQnEldcocx z)p^Ap5tP-q)Vl+-T4sj}z1!VVX?}fY*C2&b*|xbzLU%rto(pVCD+-?B2e zPkKEC^F;@yo2x~GWiKTpsipRGbBg$kY3e6E0`sp7*g3yX&XEc-b({!JPzuBwV_LjyEa&k*Lft;?47m! ztOpynupwKK&0oErtht^L}pz597wJA&f(jV3EBCRmg+uNz(bcR>5J>$CfG?%N&SM_XI z-ue{3-O!$8ZhGVWkUrHiO);^dRNeq(x!Tk=N#3R}Z%D`I2j`~MHLewYPy6P~4ivFU zR60p#)pc2*VNgq_x_Od80S>guzc4kyv){8AvGONmeyZPsrv{@$(?WAYrj()nfsWBN z$`FJ5P$_DlD^pWa>zp}1n&O4$q3njg8+SKtPwAZg#j^$9-cpNB&)U{B5ZG}jFDEu* zV&XN0ydf*Iy<&!F6yJM%dl1iQ$v#uNRX)1qKl%0=d)?!fG}*M&jp>CvFtfOMge|@G zd#F%VnSIGavu|ns-gviVNy@*urUs2_Z*y#IkKW&o{*VmLdVA=qFO>V`h&;FKP*m;V zgA+1~nk{_6kwWqFR7Yc$;$~Wvbw)Fzab*@g#jUtsJH5H6VGUr&^f#DWJ9z}Hlt#gU zo3a6oMXFzor?Z5igW`>~h)QTL`9GGIMq}?K>aq9k zvG*1=c8%!)LK$ENh8mKPgmim3_TIZcd-tfv{^wom{RXg@dDze1_r9)XvLr8cXy&4f zdky_sHmLs&ENjeCS0CP{`d{YHhAaywF}|TpQrQ$ya6yj*L85XRx*oPtm2V z@mlL|^g*hI^->*vaVRArv`?QjN}p5yTfOIG>m|*Hg5Re8mCw{Wzh-*c@T!T2_Gi_o zB}b{Lq}oBXa|27{w+_+7Z;sA8yd)b8kdFe&gc^RYll8izT@KPb?11Z#M7b;TaCWim zMClLjKUp&so5hV;Pb7lW;|)Dk>a>;4YfbhBb}K#kRrQPb z3F_?uM@D$?_uSnx&6k=nvR=~Epj^}-PWCrG$^7m=+x%aeIeCUXuJwl{GS!yRmE8GX)IiQx8{QQYs&k?CjdFV zXI&D+QHR#vtwS2i(@%N!dE)DHl51*D2;IEL@@2Il@nUJUD%CAZ9o}F|xu)osjwaW| zGX2l0s#Q6@m93kFzkTHdBdVtA08&(4o7BHGx-q3@TGh$=u(U6#;qqPV=Z$yp2kMXV zn~CcK%7%r_b9)|fj&5wH?AR3Pz1}p;GR&>5<>d2{vM#feFczIIT5f12Wt!X*DAS0?OD8ycAH z3xy`emsI=FTv;IXXKIt16c9BQCCu=54N!z{{G0v7b^DSB)h=DMd<<*%-&(Ith~sJ)SO z;e=Ne6@RNbC4NxQTdK}_Su0Q;Z0cd;)p1lul={RC4KEaU6W%sDGrRGrXI1N(tk?Xm zs`Y`b>Sc;&K^f-bVD7U^f-C7sMxb?cy}Nc<%Fc$4hmySUm1~r}>@vZT^lfc>L}#jI zR?p5%PUc7tH)RUms~VbDBIW71fws(J4Mz@j^VRn4uDF~2wt4a)PsWu7w>qkYD!x+N z+w#ArF=9_cs#=^ZYxr03NmbY|qNRhyk@8P{jrCT;tNKojuac|PkLr1eQ;2I#9`)&%60NFP$(Yp!+N zt5-AsQiPC2W7l@&~rsq+ubmFk*8lFJ-Z6`GXiit(a1$on1 z>^a7(uJ<>?fiA62>*BK-10B;IwG6H5*2{Hxs7jR)dhm8j|He`kwe5=dYT(B~LB{&# zxnf@Y#|&1=@`eZU$0_m6(&kf5)$Xf_w%YKzbcH1#N^zyB8YDeex<{vzf~Qwje7}al z$$zpp9(2?)TjP_nlk=5W%SnYl#n;A{XC3_BoYM<$7}&5neWdCGv@T;{Lp%A0r1K5U zs_3I5D#3Ipu~jW>XspVu%TTQHuTEmtXRAIph8!gKywb4UAKO~pbA*449BY}JUgn5* zJxw~S+^r%h{}(UHDr{PpxV)-3WnsdYtOM#GpP;@>c2ssPaJ1!B_qBdj;$bzE*d48G zY!GD$5?h<4pY7=<{FQ#M>G#3GhpwjlmA)`NLiw-ba<#RoNc=ka zta@#7Qo}dJGiA3%Ps@J!_r|ioFb7d@O!;5q`P5^nD;ro%W2}zmTd8jYCsZdJMAAW# z$6I>U-fG;V*q(aAjU{eSiv{%#ZB<$A@v3q13ynFom*vmYC#3V$Nr6$BDQc*3Q)2ib zO|ZnOmbj|&Fk{lJD!Dy3VKslev{7+Yo|VW`j;JU5mkZp9OYqCgR9SkeJxT0Y85mjr zutr%qDlpvbtW8y@JZY(iBy$2Ml41x))mLA!bFCt~rHxu!n~d0MfcpJ5s=rn>xOr*i zz^YKi@9IBkbKSkucPK{qxfmmHUCJ|gL4AGQb+eAdO}3BM8f&d= zwo@EeJq+(k;c!8?uvKs`LD-N2u2245mjWGxQ-mjK77?A0-MqO8QzRCAea#u~mFm*! zA*v!j5%^VYbmk(=L|9et8Bs|whwyabZ`CV>NcmEtvO3z`N3vWw#FC3?k?jdlfqB9I z47*|xr@H2Ta(Lw-l^XS1h3fvU6kxts7=P8Z#NE`GTf5j1i#+G4?V0jvt`Cv9iruhT z74UaVTw49sx5rmmHP?IvpBcn85#G1BI^mIj7si!)d3w=d0s4x=~rKSSwwP#Vv(v= zwL5uXT|a+5zco4A|HGYZ-jsT}YO60N5YjSTO0S#KEOQKLSnQjFyh-Wan4>tzPqjTq zSk>)(0~;PR??m1rN>+5!)7r;CiMvfTEoGFn57t*v?+>UWlBTsVnlB!DDd>^dFHMqS zlEx|^>GeRJWKr!!4<2YI&zCi%rzn3`4Oj4`(*yn#T%uCQs~)?aG)zbt=ij5sNFG-8 zt@@2^prkC2Uouu1>YLO|X4zL zFEg&@yo=coll;x!wfbVCq2W9Cb<^F}F3O6=9kS4-9Vuaf`Ml(W^_9hfA@1#}og#9} zK7Ne6Q*A@j7kE?4t+e32j}zOrGj z{|b(~52^f(y6PcS7v(ejjI?mocJ=Jq+X;F5Xo=?JO4WKFQj-ovq&BKz0~00F1F+oT z9-H1txu$x6Vyo;){jZb?qOE37b-p>bKD}v#6I7i`=8KXVhy%A(F%2U;*A(lkI<&Ml zR41IM5~c1b$xqO_h9*o*pm`X9WeH6+W8CkPJjn~ZTjS3qF6XVUj<$L0%Hp#s_3^(G zMte(AYup=ygomzGMRtvtj|jYP3?l15u~sxLS=4wO`(KK^X_90v3ry(jpD%x5`6)iE z+~$x~P4EX*W45mHG0LHdWPhCUwKSMAu1{1xb+gM2L~mq;Y^i96rK6UN&Z<7)Ipn*q z-0HaQXvb@xKX-sJpS(hXr&{ZM_ZSz22JQLRgx_eh6SKUENl+1?XENn>qocfns# z3fW9&XX!rg6QELFtbZ>XmQWMV_6wv1)yqgIPr-OSn zR~+H-{i`xXfLK|nvS_TW5{I(HevhQ z8CxTFanD7)Az!ne#tTVvc7I|Pp?h^5u^!RW^j#@dQacR{t9P8_X{<%G6KF@Xu*_8& zQ5;3v+T*LSmu3s4ANt04&T`G`aHn`D^4?!3Df^*MTq<~5au{R-NQ zyKCnF<2viLm-q%N;*}aarr>sr?E~>3~h2;Q5Ux7=pg%RIM8@N*Qg6aX-C%42ME$ojk2an-7NQ`ZWeVO^N zv6;UXk7R%3ec-=F;?cBVBFrrpAUCj&=r41ar&{;YHN+{xJKDz(9QS+NNMA!-F-hDL zcwe|ZkoMwhW&rB4^=0&750`VpW#yL;QTRCXKFSR40^M&$mUDw3-@X^V;=BUY2qy|> zvd-%FV@E_YEpPBfem%5?F$7u1SJ{?fu~<1QtN2L&$qt1Xwu!_H>n3leqheQ?gGqpb z&FGQf=HMygEU*gik50GT#jaurC36H<1!Ur$D@nH-T}9lK4nt$}*u=Y%qoR}ULcSPZ zN6<~-$Y55f8%g3`Jv)6i-cIp9uGj8= z<%`98Oa6x?+D-{xGquwADiw18y{A}1U*sZJMksUf4o#7AwKKO`gd31O_S2$AyhE^q zkV(e!Rz>&`x`b@)>yD%lBp#v95@-mq!>rIv7WB^Fv3U_~PR!70JF1z_=X7wH`cyrh}A z$XS5*2(oM1nSO)Mpvep}GaBzfa}l{Lp~+bDx96{h8;Mqn}PEp)dcrn7?c)HV{)T7T&q?ZX*>m7_ZX zr@3BOzVZFvVkit3(f5zNln2NVT`>{8A6`NtE+f?{) zO`(CxAH-9VVyHt6lPkiA_UL-^svt>pts&O*fqkN4b=+0@1>0EYO3aUf_OSyH6xe`~ zIaJeOiigC);L^EZ1OKc;UHXZBKtGZ3jrp1O2wH$01sGN`j%p`U!VDOkV?7HTA&CvW z?0;d4G<}V$HH1}Q-ew4AzTGv|u*PzZc^Sm@qxd7aiPo6%hZ+vE24QoP%eQdvv9e4; zOQKa`ETeu4y@WqB+>#uO{0d(6gmPw;^)=mzId8s<#6@ZOJ1QpOh0LF)`p5iN-CP#GG8LhkmEI4WzWUbgaW%p~-2VkP9O{*vYO2@;y znYns-$$ZfY!!XJp9eK|Ohpyrn;*2~>=}yf?HnAdAQyE1SQ^_l}rPxTO8#+VdGgcIh zMJ|@yq4jojQLuy0&V0AXSWEw1I+#9-Mw1<)US*A^WeOQ_VWnBN@llPYZ@e803)Mqw zrg?+Rvn$&3j-3=rxY6s8JTAcvUkm$0b4uE2R)xJTPo!^R-DXxqPd9ch|DSff$%!Z} zySy@lT79y%n13c-EEITo&}&6b^;qsb@nYv^d4%O(VV;2LG5b=b^WEbETzT)BPSP!2 zw8m1o(yf)6B^Rr=C>NK?{hXTniX7h>ub&v|>*9h{L!GPS&2~TZo^aTjE1MN{)wg*k zMaRW1?nptqTA4f2caalZuMltdbW4~Z?;$Sr+$Da(w;d@pdhbiArFvT8P1`Tw0_+3Q zfcz!8?fX^n+&?++PTu4`rxeMi)k3}ko>QJJI4@&yvwZmp-x%37y~Sq&rQ_nOG6 zSm_9Eg5<8Z)^}V!M`{tzQ*4o5^j;0_3n$n{h!<2&!Xhe1_?l!7;4F6PAy{wP z3s05w$1&tJpmi+pY7E_>`;ZYRnA07ajnrj_* zxB^EVjuI~MAkhwDi7nDT&r%?sRk#T3gSR(_=yo6_LK4mek6}l}Is7Ad30g(qBA%q9 zTN%|2eq)r0#`2Gvg(8=wT$9Q4aAPgaF1JYJAY45qH^lK0Gohm$K@rb-(Sed@md7@h zvl4b%UU=3C185U?o93>a>H-N0a0fcT*@@k=h8nK$hM2j`C_uq#A9PC}wG0O|<&Dl8 zmT&eeymZbXjh(>M+t=y^m`AoH!7xLYt7djxz7%r9T6ZwfkGJ;Y@I zL-%t=;(T;?oX)C-|7TS5sSY;53p!><0+5&Oyx_TsWqVf>HR8vNjzPw4ALs)0EHIup zuarU$Y*hl3tg`7Ov*=s78!#U991@6H&2rv08v|x@v-yiqp@jnPEsjMj+}-+%oM*P> zrV02)R+ez6@CuhD)JWSMsKzehkMQdRXvpM_sfgppmNc1dSb!Y?-Zj`lJeG5!spx4I zjUA?4%i%?KDp{rf6O(3IP5xS%g?^$gU_9Th2F0uZ8g0qM`XKuWkky$K%a_K@wywuE zSpK6vrMJW^17~d)S=DG1Z6Wuurj|CqI1lX=Y-F$D4P|Tr+w0pG1vjF>A?RY>V#7t= zbgTnN!`+5YBoVd(3NJqm*l1+I1Sq#mM$(vXg{`Ky$0&j@EX2j&Z8HwS9T1P$0Sscz zw>_~=#~73vYdh!~mWD3iZfEW>yn$FH6B(zW-G;BMtuWKhE?q`e1~Y*MOHb!oYdGsw z(CJCV?pa9Ms}z_ve(#~uLmjv=UpsE1WR@xRjoPIx$+L~x`Ic!Y$cf5#XNQdp9UYrj zP)B-Q_Nhk-mENr_>S|dP&9Jq#04y6Fcc5c9sYls}Zehm#rBWb1sxA02=TUBTl9P6q zJYx;q`-3#6u!@q!V8^u5HuV@&RHWUwZwm8Vq;Ut_?c;WR(tIy!)`<6y zqV?1LR~Ad*=yzz@q_O4S_*n&C!rhb$8c%pb*{m3&?li4SFuM$gl}2tPm6BiTGTMzZ ztknLd-O_X{ua5~Fa7730C{dkDPtY4Ur9bjd zL{A`}Mf3QRF{$xWTp!nbqTX;0j$^H0V$`Y7DuAhSpsa0k1-a-rumiLmEOPt>#MxhY zzQQ52FhFDLWmV((*iG^Q+b8WY(dd+15FfVR|h z$<#`%LDjK-{UTDXW|ij}I!yBybCNNHHpv*n6`8MT9Tn>VDRZhG)>44I`$B*#Wd|6) z7~zH`+9Po&W7h5wjDcpK&16HdGhst@nN%?#!$x3FDPPRh`i`bumicJc7&;nX@y>eY zz+AYmuHKj~Y~pmq&agfNH;CNGW~qNajtE z_jZ30g?meRj}3PrM%gH_0b(O`1lDQ+UzVt+%s!T|u# z@XO8sOOYVj+qlEDj9Vi1vO73VnEN?d_%PWuhlE*ch(tC3MHSn$5#;mQU?}-}nRtJG%vx;>c{ZgbdZOh^ z*h>A`{o~OpN_xQ+{gi!VN|HuqkXP_a*F-(X+hWI;Z=>7-PBMN+Z_TfQ09l#)Jyap^xh=7F~KyO zF`jucTCK^~-z5JC{g-j3Y*Nw9y$ES1oy-2B-A-L%KFYn#h=Ig-56fB7cymfAqsYvk z6)kWi8O`NYMSbF(fFx#I&^Z1IEfk~!+ckGcVd!Q+7V|G{p>9T8AI4XcPn)DyM^84i z1KxI93id-V$|a*b+G*q!AXK9zIrb#srtf0opxx3=8)jVv?O;@pwPlAQQ>edLf5XS{ zN9;$jOQ0-jj(rmQxaj~JLQ(Ts>kei+_!MwnT92bt640iP==c#5!ugu%`yc6&&86C} zyT8K!Zp~&D>KMEN^9^k}w>yyU_-NRocQPa_q4k9Jhi);pnl+?is398_qdTxpz-_(M zwAwHz7H4(>9`1wfV%0cBw_tiY!IkSy1|o?K9*=df>k2dw8U>#AWr@R0^8vd1BYuQU z6OX`)Jmt3L@cZCiZiaX%A7d|YY?co5bYm~#-Q@pce$ZW^9|&znzE@4QlI=ZhuVF8| zzCIbN1FtKc_G0}YNp7Xe{#3Xb<@#2F`3#qU4>gHchMBAod(In<_vB^;cN!Gd4Y-RxQcg(b>rK3bxK~D+U^S|pk9Y(9?%4C4RAJ%Is^8+-^Kj@q~- z+;(y`v?fT=R-@@fanvCki{4E}*nbI6m~n>KqViTdAtFzF*ZCK=6Un1baZR)QW)5Sm zCZxR0);`#4AR1a?4RGV^%YmMpGrYANt#zfj$UF(iA^Z8u9DL4l?k^w|?8d7hzJhYr zZNWm$K>Rm58JkXei>t)vOKr@uq8Mbn-9y=KK8SVkj3{OZ9-I3zc9upNWGprNE73!w zc8b|42ETTIV7pM|>BcnmD?Szj8j>m7}Q~13Mo9*4XpP>#)HPV8`$um4O z>Gq4GdaV-3ie3U+s{)(zHt%>8it1B3BJDD-KNJF=&Lhb zy1>iYVbBdu8(VB2Mr^|<6&(#7jMI^L>pfyLTgva@F5p+{OF&iJgV;;JW?MUu>F8xP zfX6M{$oJ!h=u`wm#6cpoJ9?eH#nwkZ&}!8ci&MG_tQ(LYyv{=0Fh6J)e?t8!IAHi; zR#}o*(_mOKAAeyRhv(Y{bN81?Ne_e8zG-n02x#2}mztx94t67Jbj%PuBk~H=oB2Nr zSMVRoFncWrNE!4cL>T&uEu~7d7Hl@S#PC0K0Je%-3;*N03hp%Rj{S@#Lu>RsI4C`u zdK~3iYt1jg8Tf5r9x$JXfoe5RX+d$Ljb$X!O4!FEkI^=ByX&8uPl7t?BPhhSy?kLg z7#jts=s!xhIK{l6lxbfCbkaT3$}lG@3n1YCv7;T3A&c!caFJbo;6HPAv9)|Y*qsdr`G}(|(=8Hk zto54qs&OZN3^HRPOA2c!f0`woF$Rrc{;1-+id1i%RC&2OfmiHZByCN2N%(ySfDf`i z!W3|tH%Elyn`JvI_Q<~wNs9LMLu%rKwzgL388p(ug>tKuf)4%~S(@Xm_^ow|BM+U4 zd=P{ZvxLv=BoD1d5{?$H4BnF(Rfn8;s3kr@3~HZO{ZfWX1zsK1>`wE15Z;z6MPuYj z`Dyk?_e9@u!9(kD>^}Ml9fW9tHoIUiUNDez(=i?0ji1&Fc>;YO$LqKXAVkm)K95{x zkM-P_udb09^t}1}Oy?#0dd+S8Z^6I#B(xa3K-6244L-+givno2@=WtY67vk>P(sQX zhtKBcm>zTUgC^_@c$RQ7+SfWMXwjjSH^#m2bP|PdI=YV0XX3l~X_m{TRh&w}dcj)w zblFfxCNRl(8J)=LN1tmli{H8*LjlotS#wx+=X=0)mDQXjaSGZ@=p?8Z(MIE@`2g;- zDB|ig|6_JTf_5At-t1sCvp1NZFg~GeypLdbOe~lV=b1-x?%6s4m-X%JZ-S<+b-T-s`6!uC7i|#t+5=mq=9Qk+Za~(DJ485_GYi7(Y+?7xYxXbh{#_;r z*9i)(6!%4-$b3@hr*p*`=&U5EcAj^pk({_)1O+Lqp+QGoCwgIJtDqc{0Yf=a)>2M5 zG^(fou~GYK)YwhW7d+prbY2z9aT)%V$>K;oZgZHdjb%o))|r+8-~v3>I#4*;`&fI< z+C|(GTmk*#U5=2%_eE|r!||2##TqDk#F@xz#X88}`Wi)-EEEBTZUtc{Nq&)8EB`18 zr{!9jfk^RpR~B5zpKiE_$0KdpcC1|aY2J5qyoV)z%BMItn!+4M2uNQqehcLS4n;2d5uGnifwJTN@MjfngFX>H&Ov04FjU`)jm9>x z$|}-ap9G7SDRM%uBxCSp`jesRi}A*$mRdJZ~bFpl6 zRfB6kB6KYmP^9CKj{N@RIdM*0Ja5}UbTOnM*xQkqd?_)lGA9sI#3VV)ct5_{NZ(Zvh zEP24&PNeg%i|+&sx~LICEt+Z^1oD~Lzu;C+Ym;bXAb{Eqw(?5pE)NgW&j zKJDLtdl>CCo8W1ht%wAfC0te2=6qvZWjV?F$jtL+GEy8PK)0I-yHQshi*pU_&+ZH^ zXZ-{RF~1qo%~S0^(KyBid;+qAbJThYs4m9{g(ckZKhkewt>vnIDrK7eH@F-+=UAol z8@_|v4Y#?tb2H3EvVqM&uwSs>%j|$Y)1<_#GmjI^a-Ze1(VcK1lS8Etb45?+b-d|F zaNAx#p?og#3A@PY44tRs82@H=+4-IkCW+!KHZq-`_-x<@<|*Eacf#r!bqLDX$4}zF zGv6^Q@k~I?m_%D(eg%3+Q}p%5Fm?#zsl}mpFc=|m6%hGmI0gKspBXhnA4wlrAY{Up zXZB;};lKdu7IG-JRKL3B7ws)Fozlp8Z`}lko5GBPjXh11N`{grP%8{y^Di0t0sG-W zK^77V>%wU!U1h0oCLhr6X1YD;L2t@llmd6=pNhjP$_ztDPoPM2wfVFmRDVI!1!CzD zY$jSr?L<1G>15eu8dOlrV-c;U4A85)1zaKZaMYJ|V?TpW*ANAG)J-hkKjE# z-8IMgnDf}R)6kV8;`gZ-O$>H4a~}$-jeDRg;(-idp|0@GJy%+_mWSy?GIiY~mF#18oqM2s46usSkn#`wjO`M;F^bTfD15<1ybfH|WXS zQmC_2gY<%!ve!ZK1x3)=Jps9l%@VJME;x2tOoTw;agl+}g2PyFho8Zfw3BZ4Fy#Nz z+XV@ye}QMDBzb?Yi1Ps&b$+TVjZk9%9>q-(!q1f;F&ANx>led{-@rqD=c5ansN$K$db>1k-Rnnl68WS4J z0%cMEM3G}ij5ctq-t*+oRBgbNp!kozjMAo=M~x1eJ{k%tBh@8s#qr^5X~)^W%e@pnL!$pl#kP!~Z(=qX zr;{ZW@5p0JUGsU-6vp*Zsb#Y7qARa7g1MQ!%_FjJg+CPD#tZa=#eYGM?KP4OupLke z(y$JuPRL0757uZ6=PbtVhsOBM#7 z04Dq#%O-{p`-p0RnV;<5=Y4Md1ztn;;$OhsaD=73okVQ)PjmeRDFmG%Gqj1(h8zRN z7+w5xk_zD(eyfAUEwDGEv&~LTqoq>7bcaS=w~jaei%y2iz_k_ojo;DxrlBSUb~`u? zJC6lyMUr27G|tHiW*~Gs9XauHoJYiu#0j)aT?k^g+sf~IBao%6p3;8EWX~waPyySS z%j{wsD%^lD5>F9({hQgzwGo!J`~*&_8#D0*w}pFU74~h8Wq2+SCVn05(JZ$1mtP^i zyZghZTshq9)o1ZzZVACb+*r2bk4PwJHouootjR@u&V|GXWT)?tzfg7oZs7lCZpF7F z!#%<7zxA1LvS%G;L@KPs&`uK7whVrXgvzE%D-9G`xHgvY#dH-_;f@=KwM>*L{(9qO>c6MO-y6z?1O1oQ3*NdLB~dSKEdd z-f&TL2|kQ5#vXw<7;_j}-e=x;(^YM${bP^|c7nhtsNtF5esPe!h6=4&rR}JF#4C!! z&6C9=?E^TIIU_7;<16Du;-#%xGzh-|^Z{<0g1!wF3@d|kp+rPy_Y-1vRgm*J3G3&s zF&&0;-Fs~_45K)?j^CnY?6xBqX=Ca%|DegZ%+}0Y$k>3S34*zknBO`ryahSSYsNFJ z6RerUPNcV`9e!Bbi{G^(NKT|ocKi)LME?cetg8nR@!Cq{-s7nX`qp8q^xgo6M z6~oDO;K?E|(xtzSD$8Vy(K>e5@g;q~;PT8ePr<&jBu#hn ztUXD(2#WN;IgP;HjTuW)P{O0OKxz7^VDz4N(-D?3t{3AXGFv;6KB}UU29&JSZy~QQ ziURK!%rZYj`!V{ukM54AtH_!X5cKbPz*td`8}1oMLdBzY9?1oFK_@Izh1-C$$bO5^HHK*7_271pOmZ~kKAKx-Xj!9M&^R1H12IZJ)HCyIG0XokFJI8-9gzBR1Tch`1}_7@+o zn7nTr6EUahGs-Sg`xm|mny=jyqkbKAP1(ZGl$i2PI}S*gcIHjeHj=$SYzRRgmcTm> z$L^%PEB_rgs`N=kXZ;w)`Qj|Sh!$ZSSau412>vL(O6$&QiUZL7j1gr=wX%|<^b&eU zbSWA~TDIbX7_r(4(qN#MMn`kws-?HJoEUm}ai zPt6-+e?~rGG}Bw1KOjO9X^rI{lkbW(1ziFzMsGdp+-82xAE!IdyJs)7;=!5F2V*qv zhh-en$G^qfg|>!u2aX~3Q5Jz4z1yI%zHh)C>Lhf&#jBk_Y^Z$&g%LMJm*CCzowf~N zA!CAbBQna$vvEvh>lsh6E`k;d-g1_5zTh0@Tzd+P(0e%Nhjzug^Q({;Mz(7imx3%5 zl-eRtC#W@X4Xt1i9O`Nl=KCJ-K4Y!Ih@iKpI(R|qNpbF)ChCYEbbMpefLJ#f9tj+0 z&nV47(zIRl7m>>X*q|l;k}cHqa(ng1Xdte{OC+tlrMyz{3i$#(!*$FQrb#5ONs=P? z;7VaPR7#qnlQBt}7%kV4MBfkbT$LJ!qX~6eTEHei&ALQyhU%%a^~YHm!5QfY&d(U9 z@kICKcpR_H)PeXd{$TrMu|Y|RB{Up6Zi|;Nbtkw(y<cAg==y~ZHpjUfgJ?g%c& zQT$qSr8c*qyS~yw0bGWKdJfo(cSoWf-%NS2!@)g7jOjA-Ah6lknX;JYLmA*<^onhC zoQg+5tq7T($|>RbpfVs6po1wuH#mdw48Ng0#8`t33|0=a+3yPzX)7!hoT)ZB=_~z? zQK~2j&%<+hCDzZ#QbsNgh z1dZ+sj4skrj+dUNZDvg}94l>OY-FVLXed9rOAvE3;yP_m><`Ky>crA}T0^Xj7ENzY z%Q0-?_OqNv)!{aVl=^@@hdt2LH#bs$L7Rd0$}iTx)IMTIx1w=3IM3L7WT6UwA|Il8O#Q)x?SU)@Mvok2_MsehuGR(g#7kTgcWz&zd5 zyF9Oq0d!$bV`auPv5Slg>2`J=YYO*t`MG^{F;q>D*bM4X>Q+rW(&J@=StoIqDbjqwvRDuzp3^~W?cuJG z^d~mTt6{lmGrI!Z&pf?j7CcCB%syCN>2Z05k~_?6>|g9e&k*zrG|3f;WD%)?->67* z+s<}R!Pmv~vRu}#g>Lh7yzeYNGDUKm|F`2H@8Py&fQt62%;k~M5Z# z54_2~hE~g3V=2Huu!ujw&;ia6rHL*u@@=1OX&g3c+&2!JX_`oMjGbpA3DybDG4|2p zJZ1cj*pgx&`#EP9E04EP=e8&E`&Z`kgv>B|5me9rRJopX9irGXm=8!1*35!sy5qnE zWE1S<6c=0wT3D6gqmie~NP=U1jUMBVv@L_iL2=SD;6JMgy+evNw0C^re}Zpf89Z*; zyWRT)n+T!6W8QCAh;I~BU`IiwH6Fi!^>AD?=fD-5^(eq+F!yb5&wK}7cZ{*c82-_P z10@`RT5igL21(ahvW$NlTJ_7}E{Ncbicsk;Nf+S@?!+por#Gi3=Np4$E2*3Qn*{(G;XNX0)v>J7-v{ucUUfM3ro)$Yf1H zI@#6M7GDKxlV+&(7RF|iOfIgLjMFj-`ggovRL|*SePx?oVREcD6>vKuM6hde6uUqv z)sJU~Lm8GA)=6>ynI}=}^)G-VY7t|DCPg3RrdZgx9+*=87uiJ_=%ACTBKzPPC_nf{ zQPH9Bxr+B@z5Wf@$}s6x$4$lB8{PpY;W@^C(94$VL3{Q)?0`wgI#I4=od9^EMq8QH zYK+PG|Hz|LE{S2!(PUF0NROp5F zUcQ?#)BHV_v#&VhFggwEY4jT|N3XKcv2f!`U?`~sT3g%)o@RW>sMWQXPugE;D4^Zr zv%v?f{!m9tY&*X7rnO)3Dduu>Uu_;lVqSuOV>)P^eh00C`2n2AIf*4Q7+7yxzU_%2 zF_>K#0L?^R>N-O`uxAVad~UnQeFfY@mHZ3fV9PODitY*}5$g@bm4%sFV&+h1bFPA~ zs*-^=xE*g4r>k*C!E*X`!$j>pU2oKCS;1)ozQuabNZ6{b$!*!W>yHSaIMQ_EOne9U5$W4X>cZEs{$ zun!3Y0>1f)Pf;6iUnP?HH>}5PeOWB5JH`ecyFp-qco=6P^C{lNh9DyW4&Xto&K%@A zUR)k-iG*I1C6>old}jWPz7La#ucTI2K6I6F(^6@_0SsnGSl8rtg6Eb4)_s;nI1w5O zWLr9uuEEK)VYV=2DfXY|fc3WN3?&`w;M`#OZQop0?&yZcZG(yVgr`Kvp|C@VKFpoL zjwTFcVVi&^K}So3afWBw%<-RjCvhESH z^g}_qF%~;WED`~PiZ=x7Ls@SMr$#~R!qXhn1sPbAtp@JR%;4YPc0k$Y<*ehTSGFGJ zrSN4#7IMk#$8G^y>j(6cr3~;@l(F(k_d*AQHfyM;1T0{NGcqj*J;&4v{(}XOfF;K; zOMjMMtk3dZLl;TjD-(#nk$7zVfr!d5xlLH$xMwNW1kWezIQy8r4;E#O1}6qHvww3w zVBLft@yI>AG!=TG_9qe;_@+L*x^Nlqs5Jb8}_?fx}ky!hq3t7X&42O)MVq~HRmdDw| zn-4wVZ{loqtf(Tp@;z=rs>uzBiPXyJ+;e=Ad#UKXs|#Sj&q~Ple#G*^{(?!yV>S#5 z@m%8W=M0sPgZtZ)+|BG!$WeEnphfMm<-5_2OjdmItaJ7=%LO9ISx=ff4jd!wEx)~VEv1^I zk(p#X6K#vb;8*j;` z3yhP@WkiHG8k^$JqrBo-$$Vg*!xm^}T4$aS*l&~=mK(I8HtJC|iH1V$qnavgWYj@$ z8B`l5;m6W<83i{IZfEc-U;$9H(Ms@E=6JuSZYh2gza~u3gv=en)q!5RdH#FYA?N@X z(k6L$!){y%+!Jas?}ANGlRARO8+ZC1k%ugAS!1M6WQnhv-YYC6YfCkWFVvOnxJne% z@k>2f@+Z#i(R0*je;=x${xBF!IHYVS)fi&o^AxaLfLtTXLZu*rPiy2Z~ z%r|ENyS3^hHAPeGG4l~pV{MJlRnf#u*d3_l|9};6hjScgz36%1xFMGDXs;{3fbX?) ziKfsrk})U)P4MxKU3wjpR~@ShsyV2!ww3Ev<2BMZo}CIAc#$vlbu@ep-E=n6XFB1~ z4sC+(6a3!5Ml=2Pa7XA@OltneZ&lyHV%Bw{?BpQ=Q#I4iESeVeD*Pwj(l;0T<}gk{ zSSYsOdJ9YNRL?Cw#0-a)khyd^qK|xtCj9Md-m21|yO9m42Bf)p9a1Qa4DU3q(3M4f z6jD<$pAxE<*{Evj=3(Xz0*tTrbZ}1arH2O_db5VW1pQew)-Z-JMM>v6DIn%Hd?yO-_^}}>2mWur? z*n}T`Szwfl2lu5x~E&f!C}Pq{r?bb;a7%e3nZ@8n8sMMhQTe!h%qgWvFj!9=E|xr4!!@i#uqxXD!4 zatG{Homia|x@IgA&u7&2)4@!XG(%!;e9JMatfo+tP9X?;UIMgTcBTQU@{L?JtKaaSGdgdX1XRc2(4|N8Aik+Tg8Qf z{g_kABmy?S^?{)s#46(#?s@q?o~guFD=Voh?XPw#A6q}j&N@GNwA5YcP=|sU47!*B*^`1KdXsm_&`(5aTlP;h?d2E%2%_apl+poh%?HstA>I; zx-Y!aHB#zP-?c9;bJ*4;ypsjIqc=s}7pZ$)tL48bmT+m#X6_=Eh^{$nD@HkzmHpFd zX{ONwQIk$o7c@*nI&0P?jfJvlq~s~wCvTN*9x%@9Q5dwhoCm>`RA<>XMGE2a58B{mX!?09(rKqkWUCyME?L6(ue>(Ft61t^ht9t3CmkNt+aP(d+2YaC$ z4|ZkrjaO2;m@$k}vpbp&AJ<*O4!9rC$=JgDvrZDO3iMyPoYIrO@x-zSE|2cp8AV-Na8DQIN4qH8ahF( zlypXqsuRmJ>fed4x*5pXL@@a#*GN}JZcMI9-0pK~gNlZZIByORUu5rr}-*oxyL;>)=Fc5Cl_3>X>>>G>3cu zwWDg-Zq^SeV~9!AP1Pe$J!mh|c;i0nAH{yh1-skxF}Biilj&`ZbDNZGIi5{abfC6Z zlq-JM-A*cT9Mb*AjD`kjZII16lpWx?>0YD&qMt+uRMmMVUo1z#u(lW55PjxdzWtD+OzbP0oPM(H4DXY0bGnxf6p(&RBjRawGDKl5G&_}1C3u}68&^GO}sM z9X2tWdP%j#S+)L<{k^H1qcH8D!Y4!Hy2>{x3aoYFljI{4o~-R5$fcdrHk0pC7CP%n zV@lH8-CeJ&|AFfmEnOqasca=#&+DCY+^aQrKx4%>F2^%Vu~9SC%6RX)|ELqqSIJ-1 zB}6SjZR|ppyZ;6t2%+K<>N=;heO1eRZ!6zbz5^%7d5xWFRJm6^gNX6QK>s8hBnah^ zvK7R1&Cd<

6i54mp#l-eNCFc

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

^Ezj8XT<3IxhJIR72w&lE=W1jd7N0qSc#yYT z`GN;Rf2@nFC#{X#hn>E{SyBf2BrrD=K?V`i&;>fBpMkF;ng{z5BY+!1d$cz#L3z?L zfzzJ&6Kx$Oqhkm=N2!=vC>^8xj%U;uZfmf*xR9BIyrKsNo)_MLc5QIT2cTjsQ!NG5 z3QWMJ$VWD;cPG)`xO!A`{nV@#Db09@9oh%2g1pO@;N<19ii+JN){4c7t;ic1qPExD zESl)|!3W`Az7vuE#rTN-!mWhzRz_zmn~^SPggIdEp;CS?-azc_azJO%Q`FJOf#MsX zy2@K+QI%8Jjr`)GZf z6E@smOZu$yIyDW8gunF}S2O=KVW9J{MyI;8EN4yOD^bo-D-wcV0I8mv{D>-J9lOGX z)J!>rX`V8vcu~sB=q-%_NLs??6a~T=;3O_t+oLt6=QtWLaZGb!19cp`E2cQAGWjdM zuImY z@>k_e=tb07q!vz;_K;WgUag4;slDHrimnpByZUJT$eYAmB|)2G$+KQSHxoaM(;ZQG zwv98E3$-NGY4Dv=u>5cx?P%_@+PnZ*-rM9D+aUa1O5kTe_MOG zYbS+lm+gljk2PL>>exXE7N^`vZ>gP0Eb`R~PlKl^{n@e9Rc#!wj|qls8}7^7BUMw< z@w8Am>qYfFaKJwU{DJcSr|2xB+DP9%T%}MPVv~$ZG6_)HE_L6!yLW3)cXwA>oOos? zqYqo=haxK=kUT#lgJL0xd%~I|UMqPl-&~SSu7oT*J-B-ZK%U0=Q1arFgEat@jSpQ1; zNVG1f?H!~VF*=8{{ZI96#LL7A{2&z)(#e~Kw+VWvM9zof%zD+kv&EkR?kG-FOyRDp z)>|7hqo_n>mH)Ts4dd`7{0v{7l;K|;*WK`_=oHo0d(ZWJmFR60ileH!GCdO1 z1G-&whnKZ?u$$ycS&&P^k^FoP7%OYiwVof*xWbqC&)(BE@>F$7N*J*OwZAD zcZ5FEgknEEr;|Q`C)o~gFJvEj7i}W$2^-Oi+62RQEZM)#RLV}#{5J2B9~bm7&xn7c zv+!N`b;2S=7XJ%#K?~h6HjgMV%)%T@BM!{PiOc$d%BbS1+G-wC{0fQJ7a%6jSSFHJ zGq%hnW?Jz!;)A}GNaiF9JNUPOBZddcQBx0mveJRx3e=)TnS4f)YRB{m=GZHU158>d zQ`=B+8}AfVcb_0+vzd zc_zSIfX+A<2akXo02%inA8-o3N^BwWSJV=7z#Mre6eNchrSf6@4Q7S!gfJ)c4Vngb zL+hvpD-HMn`i!;E?u_i~>1+Unm5811!x1q<$TI$Y{1fS}f4Yardm?|39}9mw4>&3t zzA#(>N{mOBp+90X>@&qX?iElKi%Q|ko$8&=4e=&nEsP}F-M)s{mxQ~jV_n_T*u%sg z@;7{z+RK-?8~A>Kn{{8o>YQTi<14a%1LNFnjPHe}ObdfmGYZ(pL<=(wP5HZ?i~7;o zl<4B8Qvb0gKdVK&M{B+`3|d=>A@MHq6Vo31OmuYtiWLZRir<>@jEMb(QzzQN?ZP5s z1}F=cmu30xxHcx^u1Z1*KB>44yVCfd+9a&$Oa`a&%cX0Yz3>Nw^RIBVhVrSQV12fB zQdgu}B4O-{pCpW$Lzt{>>|c-J$b!}Tm>)5}oRIe^IomOv>?u3Ycz^#Df7=Tk`8iq0 zQWNjl0ImQ;*B;VL6&JT>ILV_u0iDLq*f;PE@MiL9S3X$kUdqgZR^n}397>BF&8;1e z6EYkf#2)s0Xo_#NbD*%!c~vDD#$qiU6LR{xV{>jp`2te-z}$<=@Q%P*LMlCu&+=4| zhlF@+HeQ#@bbsVYAA;=@=leNFQSv}8J;p60i4*;km=Ana$OFa8^+6O>jBEAh6WIiV zsR_8t(Bh%E{7#;zXAtVSJ{<3C#Rjy!sHu^Ez8`oMK9SR=&=2W=^Qv7aJi#` zRUKnlka4q*@#W4+h6lg_$NfZ*jATCh4bm)jl0U|^%~1u+GG?i+6fM)I*g!SpzRM@H zDaIGZ4zR~CTs| zWZYgOIg+58@aMIoy-Sc^+yk)_=9LZeZN4?OcW#u5tZ153?|3o1EN4H@ytN>}|t-+OEow;ml_ zdNLthZ3~nsj|mEl6jF^1#9M04d8?ZNOpfW&J_sjf`hDRve_xj%+`yYS55i-~xRf?T z3iME#qMq@F^S8l|=m9Ul*W#nlbp9;Y3g|>yeH!a)-6|*KZ|Dv2qvd+iEAgVaNZCZW zwIzm)maU>d^oH*7K3*fwwGYEg*ckCC|At8C*HOQz+A9Cxgrz5Bm0nA)(8ID7N@flT z^T;B&3Q(E4mee+W4Oc19*Xq*F^#2JQ3gTF1Nd|Q-7%xOObo)Eg)ep|X=nS_r8_5?44 zM!?glsm4KNpk;x<#x@ii1sE`+)_5vK&a_`Bf6S~k&kicVwY3T58ir~SIn~g?jN`M( z$7PeiY4Vr!B0S!L8Lox;lP}$?p`lorb$f0rdOq9Abt5FD3TPL4Y{>@GZM3cHgtVC7 zh~0~cjce`gZH*0Wrmx}Y<>~yx&}MEnnqO)}rBp~}glE(iv^6s#^f0A*MIEuL^q^c% z4pbV^9QQ4BoNeY=m&VJTaFh8He>v%yEvkG?DNB&f6@fb`mDzn|KvA}IB|JAgS*qZp z%I=ycluQfe2)>{$ zsx#W|X;;l({k=?;#V%zBj3wUJ;V+ik(DbqmiFXnI1@;x`dO=sol*t)Db>8ZF#HB@ow^7d zMXd}xR_}jnyJq`!)pU{P4z$%L}QGs_UMFeIR7LaD`O_1&FLb}=8-?Xp$F?B=J%Q;2Qg znBYcpAebRMKvrOB-hTu8(y9f_$*N6?^?Hkrh=cOzQZhNQp5C-u*)n>>-TMzVh>Qoe|9WDP>E}`zy zdVEO!O)=sV>wfJ{HPO5q$u-%{SNUD=6{2U#obY4$O>r+;C&+tAG(~83?iBH*|wyLBnJ=*qk<25Q-X%lh_SHf30T8L6U zn>IkTM2&D>?4;h;&B#nVMkeKn;y|ui$}zbsr3iH35zrK|Chv#lx@y1;?dPCUa|&Dw z_#Gs$zqrUiFTA_?XrMQIk!r2a$99yqpw2GcR&z+{P2((ir6*B-W635-F|v3YI!`(s zJs@tVVQyd=GejAzx#rs$$nv!kqRl5Qy-jPR;fdy8T|U*sy6zPWUW-aq^a6GxQrc>he~ej2X_o7@WXLI zIG20HRH7S$MG4D+ORB|Wp!i01$QB`jdRVf^0Q&|e^56$_rOKl^C3cjbDYc}Lp`P4l zcyZtW*ElpCy)Gu{R|#jtGI@VQ8giFyh#yg1=Jli-#=pr{D&3|zbX6a3=xb=_cK~`) zZF2&&DT%KYzFD})ltMHtn?c;bLg*1!Q~U@AY14c^9RM?vxQ!>_8M>B^YWN=^hyK22 z47bld))&h^$@v?+qFF_p=KR8-d<$IW{NQkq!{3}y0 z>%FWLs4tA9`k)JR2}S+2YuVS1Yvx1DtisvEbg3DAN8jK5&Am9aEjn9iXr7NBC>+KN zHSJJ1?5#Sx|3`=nr6|K}1A(4W1lmViL1yJegnxo_6L6bAG+#f7uEo`n=5QAzJUA9t z8E63wnSo)pB=;QArEQKbB45e9sbBWwc(3)V?wOtnH5Hxm42w6 zp+_Ls*Vwb(=J9SuD?I0*Vu20D7HXkqjvM&?@Cfhul1~1+f&M%k+Gr?r{gGN~FcSZm zA+C?^y-WoEM85}DZ*OW?TCo9Ygsu0+$QO;vJTDOvMA*Fo@B1h3 zoa;a1e)fbt9_|HRbrowCgPu7f#-tKxA`A<+VRuX7N>)WLN3PiU_*&XdXUQj>zjaz9q@T>omBrbv@5CdxMsrtL5DvX!)3q7c8+~hGgb(pe69*u-u$$00bgJoZka7Iu+q7;C*xccF{RMyF-s?L? z<+8M~m&fN>&oKNy#1?LP&P!|%%q2u%Dvh1K%I@F><+lc32p!S()Dx+l@SU$LpC(QO zH=B};&At24K4P&D9q@GQ_x_J(g1 zqH7?&l zxHi7N?3o!hn?siz@8rrw56G(g0dcD5Xu%2hhtOE28P))6g1jSZ;}_ij12>w}oL2TG z-`Uc~vM&$;0%`(ajj@;VC%;8?Hyl%9xB`PoF_NeG=lluYTk{go+1w&#o4f-{q+fea z3m;9p@nP&1a*KHrSV!qVl?5~L2(+bmM{Xj1LKCP@Op13lL2Bo(>=2lQ>x7O<*l|_U zL)J@u$;Te2MNec9%i%I5L;NJISJ{_a;IZN^yfeQbkVo0gE;GX&LOKMhn=eSS#L>+E znAT8wP?TX`4Q89}tSI7UMn^p3!u%EUX!?-{aU|;|;Df|$sIwRKR0F2*t1V8hNVp*W z6Ejb#DudGfRkh@~@;vo_eP=X_9xYEHtAvcg6f+^*M5f@0XiZ8hE>&uP>9k&|Seb4o z*x#Yf#`!{$)D`Ni-E0`QtS;7GGnhNcXEULMMTzC^nTBkzh)Z@qR?{R)_+jjLa0Vp7 z2DNz@GQ4xQ$J%S_U=wvw%mI8nH#*@y+XThrg|2%14)S`;zus?5*pv&k;zluRv|Z?U zeWEMbHw64E?9?sv?8laPrl42E0l7UrrT(Ul1*Ycy(Vox7PmI=>fK&ylJ9Yd3-8SJc zv=J#G{ZP7)=bXisOHXss(>w7)*saUXL(%cWFmG}mw3_LeRO+D z%g2)chOg^yQb<&Ld=vVrV65?&HUZngUQo|3seCm6mFneGg>0_9=upFFPo2Wed_Nz> zW-Bz>Eup!mx@^WIWVU`HgZVkc#vUh%y>BprF!1}O6HFSiKwDzJjVP>_5c#EOy7#-c zKlh`?VozRts&dYa;-friKs??qdW9zJjTXO1HeYqsNVP#Yk1cZ62NHxQNGjXeSPA^d zHwH@K!*Cimu3#e4*nh%zq~y3^y|H(cqOb3~0IJnpOk`m!I#j#hcwUxj#~NyFi;Qu* z(a!E`CK`L~KcL;I9CS4+z?fG08Qe>3y9X0C6vt~yN?`@AiYx%#DILv#sMv7xQ)th|8QNjw*Z zsQroP_%X^{@)J?ye&sotGE;v_Y80LjA_F?YkxEfx@l>_bnVoo%xeC1@zJ%NH-Hbg` zdr?O!7TF_R)wTWA{Ykhi$@fDOUD6C$j?}NEQ_1KHl&hgxi8lU_P{pb*UQO>e%Yi52 z6w^04%RFEc163h&!w0zz^d@|sd3o@^(ofWljStf*)8%S@HA}eaT~;u|veo+A{{QUS zrR5*UF!dwYg1_W`W$aA#*@^k0dT=1ItHsMj{%-h3bfu5R3b$freUP@?-YZYp<7OT&+#S(3f z5w`_#c{}vdYVfyTfS;39I8{}1` zy|)xEcHar-z>mob6=tekXk0LwNRvO7=7*X%s%&1#90`3yzT%0?f+gwXIkB9tUrt-* z_`5wJo_~9nbINo8D_!B>lxyplB`6fkUuf>>^iXw>YaREyzQ>Pw2+3 zv)7~7Nhg(Ifu1CQca&)V4toqy8#)j4U*Bc@k>Ur$zuvQO5jeY`FPz1nM&Ahwk32degjZ0P)qK-!`A9ALi?Vn zq1#?4tK_3Ufd9E_*^9(XgC5kQzs!xey{b{?43Y!YLKVy5U?gzKS0&Y|iLoW~k(LYe zvywUV_VC|$18QjS7}U^tO$wO*5Z{nB#8dQB=?W=IZTWZMKZ`!O#sh1xLbRRmHGZzU zoykWHQzvbI1kxZSCc%^!%0_+?D{`&)kpL*V9rMv0{2qEn(jLDqxX7{Dv__an{gC@Z zTRhWb4f@V@P(6*m!Df&rxGda9oFdXU{7qd!Cs8e}gufP3JL!#{b^$n4a==W-?2CV_ z`UGcKx=2atl&(RF#3u3q;1@ZCI!4X0>gm;5lai+gOJL@LuLac3+%L4<-A(zxV?`O{ zW-0+7%sPCsd9KPaejJDqHl|*8P6*_%vpi!W8>oIJyAxMC?w{=A`Bi8X-4>3N<1D+; zze2~L^S<^l1&)oqF0(36B7!?irW7l0=y8r-EjPOk}_%I+g{RtRf8-I5wBw3m1DqojKD zOI^QUX?RJfHl!2Zd-~xY(T&I{Wfpiy|3S2vF0%;y)q}HhLz{#$6XNd6heAXVg;V4< zXEgfS{~R1-W#NW&bf|;xCNmg2BwY41SBX`{L_I8mO0dA%@8kfl1a0Kk`fKDydrP^? z!Vg~t7U4C!%gtIKp<15sCXBkXRbK2o<9xo4|1l2a0`cfhOp2>ObA+jD^$pc4cZHyD(OmAdy6>zD? z;B3HkF20M*aLkcD%ny!=t8VVD+CW@K=#S{lCvrWYraz|zn z|Jqgwd2a7-7{q@FjYgV;s=>|p9#U`Uv~FT7>(Qv(t?^P%Lls9&{)m)qJ!6`T*c}a% zGR#*0G8~~sfw^Q{2rnTN(HSKVXY}}P?!3q_oq0$8N6}r-AL?y~`B?MsV86s$Oku$$ zNt-;6Uk&BaQSPs>l1TAQjq6RroORWR;ds|bd9OTGltqsEDBh+Qv#Hu}&L&`kWw5q` zG?$wz+%L2-)#c&vEp@X~HLtsBIqGY!8qV}j;XWvIaSPRB@?61#^;aw-&OoAvR-L|Y zx!P0^y3UGs3=7gX@XNh~fj|w57NnosLA+|0sjX3X{>G@GK zn$?IS%{2udn+g8ITII=!f3yCiMeqcwEm>9Ro^zhx1RMvZgly(TNw$Ph@_!x>&JmY* z%lsY1`oI-$PEv<8a?Cnue9jC1S#Ek9!+e4ClG$4zzV=FdE8lGSc$EdK50am#G5kB# zCq(dnpjn=VY>aqJ=@&|78e&`YW2vfAYp^4=g31#1DlO%8+z3KLty{MpJb>{06S<$% z6~D(P^Iq>v-Ew|_`M3Y5cauhgc6BZd4pQw}9b}X}&WpZT%q0IjJRUl)QK+YqVt!*- z&AUyFz~j))@Bn#%RS)Dt5#ZLu%4A1RA3mn20SBAbOCLR}fGy^g&g=3wUPcEOR8h8> z$H_jr5!%AkUn&z;TfT!`lA6&P>}R%48X`YOJM(Lk(rMn;34P9dg!Tu|6U&i2UuBhq ze;J)F^i|Rz&^;YJ#C~)h;^NGH0SeT}e+DV8<=W*yv~t1VMEmhZ)~GthtNHT%PsNtL zx=@s-d2B6Xb^pS^1oRf!mpM{(uGA+G6Y8Y0y`GU3reEAEHb+w1Cy?2F-D?g;f{6KS zel3|T#?dPLKKh(tiMxgT+HRC)YpyEwEc;v@%%pK`K_@fZBH+aghm3UZ!=9i8RYyV>yxR?F z@%hGI>_^jVcYQ!iE;O|Ohw%V%!spU$OFo8v2Ujv}VAONi-%*q2H~DWdSCVf77v0k> z|0K-O8KK?YIK}`z2mEmpqgY@U1|ct?=KAiSXZ<1ZMDg0V(NL5r$#_Dlfv6Q%4s-qT z&dAU3&rb}4<+=_?yvmLm>;5~biF*i$*7(K?nODN81evj=4-LZx0|BA00(< zZiD}02!9bp2J9~hIeP=!M3oveqPAcf@scNhy9%niu5VJf{gT_H2&uFd} zcV@*6AQIf-eI_hd&xrdy7r0A?3!bU#3;d1w)dhJ>`@+HAXTDibEZ+sKMOW5bHlW6! zJBxg(slD#F>1uwAY?DuD^vvFxd&G?348u)LBcuV44p-K0gr4b_${OC7H^9Rs%`??Q z%F$tSJTjUkPVs=Jjuq!f`ylyT@1-_{oSwhPl!za!s?BjSxj~Q z8JNT@=j-b_0`*xP62ummr?D+Pi}Oxf?WD^|X!@ zw&UnhnbD)&Br&Njd6%t=uLykMzw#fQt*Kc^6ZA9x9ku5F>)EPbB3I|;Cr#4#Ss5?d zz*ygXbhU4*Crbo@cq9p}sru1+DJ|pn;GLv(QZ29pY)08YRbz=iPTE7LYF_HQzJh#0 zk<7nT0NFu1z=g1v)UvlN4LpBfR(vzCLCBCIn78n^JU=+VS;t!kUPte%*2njq7|xEt zd&nc?^MU5(!%hMp5A4@w&|8D=l z5FG(rB!A?Mv#ii=MRt%2+@!uf+P7dOTZLAM=*XYOJ%Mq=rm#`0$y@-s@L#bk8TI?X zK8`7&&g=&9AQx1*+XIxD!XJDOxgOL`3Figa!GTHUMj`8((M$yN%Uc_pPETZ4v&}4n z{D`;X+TQ#tB+he`x(2;+-o+0~mBBgWE!8%Xr?ThfW3w=q=BBtmu^UH2XWap@xBImG z7QUII$1~+6m=VgQ{^tK=m!prIr;=0L zb?|LrjVTZGN=BiFrMt9Ns)o;?j)?(eu$-k$6=#``qG!NP+y-ecw^cq5^~N{BnRs`N z$^VgGAXXKP_(bz(bURqpP+iKA{DHH+BSu@1C61?5TJgqI|p1s4uj% zu)iT(#t5djv>%;-HG*b3X|${IC%IbVJd-uKC)juK?E|3P0qMd`;=2UT2unz#(#}1ct%DwB4-q$+U7o+- z&UkBWKOQJ-rddobKJbGE8&Mk4Ad`m zgm;f^b>ckZom}-Y$_}G8K=q3}c;nRakjv6ry65*%Y4A%FCDWBc{|{_c*;xFkz<{p; z1Z}X)mOBRe$p%`%T3M$sHIusslS3ELGqw7MA@E2L53V7@@HK;gw<)m&$Dw894r(aw zlA5BcJp-NH1%Vl$oP!pDP38BtG1zZS z1*V=*4fXe)!LCqyQ--ZUlF5CG8V5c^e+p526QGG#Lq@g;N3#r}=dnM(1N-yCjTqQh57TFKM_#DE#m6#SdLZKRwoJxUgolr{-4Ro+< zQGQ7S-FMj%c|-0Z4CEFXI)=QK4d6Ia2?Y8VI2WRkwo7U@IaMV%-nMliuLl-}r%(du zF_<)6<5~7mxdT&ad_I#l#j=Aqs^ka`+-B|OPY-pH%BX|Mm?Z_e0%lTk$t%H*@L$1d z@MLR>{43;FeMgIwhk^ddh@qPtLz`09BUejq*hg6YQ$VI0ID~7S+_ofL`E2W(e>u=v z`jfkkPD7R$#ux2^s!@M{I>r(#13SWe&vNe(r*enGF>ERS0O@D@&i7_Fpvgtgh_#*)*FDQKHpVl6 zz^u2Foz7;9vNlRt%S!iv~BUgL8mzt+sjL$3k6@d9>%vfuGHeQ`MQ|2h%;2Z)Tz_X>J z%4|a~V<+*ZWk$Gm3Q}?jUTm+07DCGnL9n4(TpWm}>eujl6P}tXp*TF0stJF#=I|bI zOlYokp7fV_tggLp5A#reMDC3~lQ+o*GM}HIX<(^p&;}c#fAXUBs<1%0tltp23AQEO z(lPCE*rPOr9~t}6=};RrnKMJzL^>Yb5}!c~AiuMXl{4J?z^>%$%tE$|-;dSh4bltv zM~s0p6s`>a=@Nxmd-T=nc!JVRn-Q3$%i`|XV=n{4{)LC9%V)I>cD&{Z2E}?38*D_n+ z-(YWJdf<)emE{1nk@z0C8-lR~q1#L)_9k-x>g@kT7K-}RQsXInGwi~>LT}$Yaf1n^ zR#UC`(|CPD4@-_`7PYLbtH?9u#jKiSxJ%y^Zx`k$0y8aqz%>4_8x!^ctvE=tA1>xvz=A7lr!;3mq-( zX9LU0n&Hpc4ytD9C+mFh4VnObh8Bi3o{ipFqC@OS4Uv&>FErA4N70wxEj~h44bG*P z0j&)g?7X};8N;Q~9uEEKv zhM7WBZ%gCn@*0|>+yHD()l#h-i5i)r{)R2FC%tV|C(0RbbKRYaP4Qjw@9OUvcy5IM zGx@M643CK?oK#^Q)}M0#Wt{`;ePb>sF*d8$1MhL)GVU}+7anuXfh@t%UNTe|EM_+G zcfEU6PE;T8w)J_%&;(YFWu2BT-U`b!UuW|`r2;>qGShmat?0FOB!9fA#JH&_)0>SJ z)>sRCsr-k%Df8Oh1Zm~jT=X1{PrVwS;X$3>oo9R>yqHo7VL)HwO{InVHSiB40tXC~ zwp880G%l$T8lK|eP75zw?t-S!7`3TYIqfSL=>nNrZo4tl@Ie1BV?bJg-Q8#DIVN|4 z(=>x`>^%fEPsYecM60}!g>g{x{QsElIheGP(St~l7f#h@#bKUohu(KNZk>HKWGD8r zd-VGabK=^on1b5XE4fEmPUSA){CamTx7{%`znehT}Y9p&8# zAMo@O4?Ai*5co&+%WN}4Z+Vo@BE}e3!2hr(K_^s|-p>q-fL}1) z@bBT&zCQHBZRd;8Vf!4OvhZ4rp|PUj16rfNw% zTNp`xU@~j8h@DnY2$rfjCV{L#@(jP+Ht=or*{1eWT z+F4zPsZ&71HQ26Fns*wWEPL#mk=rnPitD=nZIUIwX0<)S6>cP-p0mcI;T9Oi0GNM~ z$6-2!jWVZ2wc$GF&E?MHgRo0zB-h-K<*O>_(4EMicpdf-JYUnsaNP9OcpjgpRg$;4 z8$+n$kT|H|C$L9s!D$S`^=pm4;Vt@Uz;(|OyFl7RllB)w6#3+;IeCC$?kk*ydMF$C z|DlUOf1HEln0i1>XDjJM-WYZ|JiquA&l;D(nS8mhp>jQ_njrGa`O$n0%!_8DE8;r1 z&)QF*2hjJf&&zgdM)}@pBDgQubm;)o!#LXSWCy!_#$hA^x)bje-2_%S2OIS93EKPG z3U@bY0d0d0l3&;sc)8pTt*&OHPYT_oaW7Eu&>QhisuB4G#EXDx;`_^3&y@phVKA2sn74Wj0ffGpEy@S;Z)Ie# zuC7B$i?WV^wt){26!?S`i(ky2F~7Te>N{(cU-IRXU(kUt!P3G1pnB+x&;a5))XsgH zD2L9d6nbCjH)8|SZ}&zPJ&BWqI- z;*RQ%@=JZ&(Pk`+*AA@+bf;&l6hk^3YaGDSN(FRGo`N+CtfUq@=U6F}hfH2Z{!Tx) zl$BIdhO&&oPW(_J%)_O{@)M+x6Wm?Em@=Lp$SLA}uBvqnd{-C=MtlE6_Zr)TuZ8E^ zy256$8s>r~*&3CZ=>HjNO8?W%5g6)&B{AGpDMGh9&B2pkPRcCnQqLGz4(!+d6KJ85 zfT=EbYRtUAx6#B6<|h@N_b~ifvgk8SR}`8{tWfBhNV35A$k;#RjmuJi80v z@+bo)B~k5scB5wLZ~rOP0u;~uFDC^o^(|q37|cwZZ6Z3}d)^oVSJIo@kD*9xwO}-- znMLz*Z@Fhzb`(<`IpPZot97WrTLs?sgx711x~$LmJ{$1O80Rt6GUxVsAX;7{)V zkaNOv?*wiva+RM+_TuUoJ|+KR;;{&Hd`?L76p2vZ1yElZ%tGeDO`y&t)AVhaJ?3Ze zJAt7LWXtx)hVHLRr@wf;>RdhOX0Y{^pKJsC09wp<^Jkl|ny|Jjw2NI8*GsjjcZbgk zle8Dz11Zs4S5}*}P#@0(^fuiCIj<{I!T!_y6TRz&=YU)82<|uCHVR+>>Z}Y$)0nQz zac--(rKh6B^-v%5y+_;3LzF#FX0ndVFfm1jk6j z)1)IYeCKJ>R7L3XHdB)`yR1hr|C1B~V>8YsHrGce0KYm0F$a5nPA1w)6{b z;IL39;)?i@+O1L*#{@eGs!c)iS-06+m}?O})iUZ(x{1jFKiOV#Du0<6qG!o9b}nkv zwhQ(q4;o5xuTiJPRxWjdpz`UrirH8dODg4q>Q{L9!e!E5Xj?kGC3%F+N^gOb(32AVReuAjyl$&}lvw(##rva_CT6n#4wt-3&F zAu9vpv8O6AJ>LDvGBz}VeP#awJp+r(wSpHb<%<1@2zIY)vMEAY?dup^z`D$fc{h^` z<{(W1-Q`SS5SI#Ft)8>KjG9ihwyfk1F*Cf2e5$#GTF%%|SZZT(aV92>0J&KE5w11- z5nCk0B+jrG!u2h7s8H?>9${{~20(SaLp=TEqMV1G%?b0EZ086fI^nH!+N?K@R(Jgu z@G7Frgo)O4WwaUh&D%&Ejt=$LAu9*RCjRHHrV?0g>U-q;=RZpj5W}}5+7{Fc{Oetg zjdZ@+Y%+fQF8p9NG3JaQ}H6T5*ERBj@Gd`CYBCTxNLdrW*S z=#-*<`=H|r>c}s-hVolZXWBw>tad?Ca1ebX6fpZ6d*)38o@E%t%>^*N0s6A|Wz29a8rMU#Obd%K+1bi8kiX@xDqt#s zXN`AHRE)hDzKBB2=dp`cdtsNrF=}$+r@ZCnIn*q$v+sjzYw&OXGtY6&6_oJp%YRQ6 zlP-KTxiV-WQQWPuAc%ht4~p@s&qo%Tsja}6a8KoRuqGGOKTTYznq0~PXSg(6m3)CO zQX7)Hq!NiB_14Ywg0Mzy{`XP3@=Pd`osEyJs(qtZpSHr043 zKMZT$cNM?68Akai_ufaf2DuL7MuFQgk>-Lum1!!$KBaxW6hLg9aVb*z1(sG zer)~B8|rl;I{_;Nug@Xg3(es&lnxaQ(k3KLwbt-;7CeRv__EN`l}GG1bc?WIU?1Bn z^jV+`&lTR{O@rAoD)*`7Ca3Dh1rG!Zbz9|=6&!S0+)2j0 z-c`~bO^Wkg&Hr33V?VfW0;OgwmZ^S`2YRB&;puK1;JIthMx(O#d*awTI-cJII~~_? zli{T+*T5M&5SMcvgF66=`-X0yCX+FF$Dpf^F4F)5oh-9W=ZnZPlOrDQ_Qa61gXLtUsi1L=W>9OD^=?q=zV}VWq0P z7i|yDXV(ckjIH?V#8hgQ50D-k4br!SlH}|D<|=>vGW%0AKG1=686Q9`u`Tvp#$C)4 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
PfU&+mUgKz>O;mOEStVwe2#wa7!Cdg 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%0A22{3?{I!l52u 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%0DcLc% zizNFd^ELm~&{zI)(Z9T%KW{!|jQ6|P{dOU^Ss1ixoZV#E#g$lx71fnG>FPodkzZ_`4_&EnFT`3Zr)6@LyfX`*gxx3@xoqtmWeKxo*V|+Dhtf&mT-u%Y! z%#3(12l~4zC(qJ*KHHO@TQ;D}6E+5RuWILg2er509^6t1{$b)OTtbav4bSG#mehKd zcDCt(d!9?CWP7Hmi+L5)FPQ3n5}L;Dvmf;Bw(d_Y@ofk|MqAM{E3nk{l{0cH%kl~K zn)Y3miJ_rsGgzB>q%Yp)EdQveCsZ}vRsym0Hz~QbrCUsboelkEAXAs6#Tjje z2fjx|$Fgo#ZfbaxKe*^<;T1#Q%Ks)duy;u?8WJlhuG^-Arpn0;3byC)X=RFY6Kfc2 zBtV{s*U<^xA{n;hXaA4 z&eTizFmsAZR%o%j`zZI68f*UqXV{jAxA?9RJ+G;^5~upsn!MqB-6>m^ePza(UvZus z7FmZ3WR^r#;+ATORAXJ>6X6H7)k^;7dY(0fY(gN|Xz|{vzQyk?FOzPIR;sM!0(MpJ z{xwrJLk*PGmN0dhpW$9&>YM!)<+N+~XGwBw7rXNlzy=s*WVI`@p4ll@biUx9m85G= zgW%i4tAVoEOvhuo1izQwM{ES<;AMoxAWj?U zKN1?A*c(OYbK)ViVPv&59h9Z!N!8Sb<#23b$g6E~bOUeoed=PWiP|}QMz|!{p=A6j zJdxWZKY`xLAE>!v&72M5G%cug(Gx#xe3;w>YB|V2!@xqFQT2iP{~6B-*HCr3akR66 z=&;U;{sr$U+9OQ{g!G&oEDa8KO|A#MM{~n%l*OLbk#_pzJtw@>`9%8()-o)Vz|0e3 zk(8*_Ioi?592@?|4)Rk-6wa2KQIDkliYqyjS{NK=Kk2)OKU3dDUbFl3=KD_WU!pvm zDjw9|iF$T9y@y<*6F>6Cq#b4EJCgKwP-k(kno^|< zGz=R>4R!wNmE=S+o;?koICjWm;lA;9?imyn>uX)G3(gC6+)FWE3JuyJrADN2Nk!)- zZT+%kju-R_^B7#`6Fc8)qOg*v;jCg$kNn4PiToy{Mti6$k&A*rrKK3yJ3uot)ripT$Zlso9O{+B74D)z<)vwg}&5R?r`v%e}?|=#ZqN+ z8)YoFeRIm;YC2gZ#z~1iT+jLa;$X3cGSzuHC%4*&HH$S$9jp6x)xZuKfm#Ws^mOMi zr5G9qHzHDjK}Kv$m~<42090l8UzTl{di z3|=E?1M(EF7hsvnV7YG_HB%U?EJv48@dgq+mzrs>Bd4gtY3sDx;T~*#%X_B;3Y_=J z@kIA(X6mG)x4wC3q_S*{{CV0w>j`}y-oVjVYRb>kTQtk4tibT#M=VMG2KG=z^nTwx z>rLEGd8xx(GMl1af`3q5XaUs?+G>{M<^SU{qkj@tDHk;j4q+0uovADipha0t_|C*o zUD6vHe^ItmZ}IV5Go7pP0@Mra5vvmebr$NcSiH2yenp$3=#6gaHk}UZ&T+$D)P-Gl zq-CV?RRX_R9Mmq-rNy%8*ciAcgGEqv1bx@?K_}q)9j}NXPdZ?%T19erZzYl8SGoQWGvem71f?^+Mj$!Y_O6;xrf^wwBskp-ue{a9gi4lrV` zLOURu{$%rWWa};*|$mOtJG8LTRxGOrJ*23dl&9UomcZy`UkJ5Y_bwX zsTJBtD~|Z2T57I1B)pNBl_3j=-YR~``(+=y74MHvGCz*g_GLlI_FvR~*2Q=s^h)ea zmN$M;NVOwc$gVaIrWZ@gO?lK9XH%&i@lYN}ctQozLBdb=z&Xb=T!nTyJ473V>neqM zHnu0dAbdM@F85!cvDnSrLw_E2+9%%1wX=Q@gEcSVfP69zqWeR`ZpHoc0Io{p1Wp@qS`U!Fq2;GFxoL^nkzHOQAKe_3$1=Y3wPfqh$lV)6y1RjFpj6rGrR$K2-Ljb2Kts++(cB^|l(> zllJA};tDH~9{{Ynf}gjw1}pIbsg_(lX%O+A9wk+A;LL8RCOShZQOo#ksUw1Y#go2j znwvXXT%KF#+r?+4Wm#7EleK=t9`6dw$35gWkW;aRViRpb!8WCo{}+5mhA5S&w(Jwn zBWf*u16vX>s!LfnTf}|9x2USzOzWF`5(+ywVu;w2FB1BY9B$?Mq`BxS!XmLNdmZ<( z&A3C+BK5B>y4iC~bnb zjnA9KRTz~WBV3$srBG;RfbxpvDeUNX^Y5* zYDa!Jdb#9vxY!a#uYdyOHEbrUMEa_SxO&K(kcm1cYv_(h+u%ZUjJC~N1N&J}M{dOY zfCH;cc5KGdjO2seu(+_h~NR6Y4j&+z;mtO<2vZc@(l z?qMQuA5w=HcCC}I}9NIv=M)KLNcx82vwI4SD$0VIr#)XOFY3Fd8G9RQ-c1xl@ zNqLB8%XgJ(N^S0h@f17U(gh4H=qUffx3PW1Qo$3fKdiC|{y9N`n8wZ^FVddE>81+_ zyKP;;H6#!IA9T|jpPCn&X>1wtiQS}|@N9UFa0Q;?v2kOlut>_=@s4;G{+3t|Ge{Pq z4;Ti}&;*N9EGA5dVtbML!a^Yow;Gx&9T%S2FDhldsc8n$Lx{?5r~sLQ=kSI=*mN8D zETsDmQx~{ex}owpd`kHhJ0=Gm)zLBNJ|P~%q&WE+nPJQJjKnVr7pXUL9^6%^%l&Gb zhU;!kwz8GO7Aq@sM(+=#9=vM#5n>tC2RbBFkh?ooyx zA-5lCAvg3N#(RictnY|o(*{_Qr|Zr2kHTYdjC2y(ggp}GW7ohzzEk2(_>&@r%8P|5 zi*3ydw{YEygy3Gdyj&JqEsw=3Z3THNlF`}EEOE|cXL3w<87k+kQQRAXq~O? z_)~IA?<1Ern{yogQc44#s5om)$6DR8o`E+PC&XT)cZ6#hP3LC^u{QgxI zXT5;i*;u=&y`*Y^^H5FaEohWFi0d6GLzU#sw~uqKE1k_lh*PdD_?2tYE$iSkHRDF8 zSZBZt$NvK>m70D#RZA%=pH|!FE{)VDUJ6%5gnq}3b+pN7Q;hjd%zW~-a)utGqQVSx zm3U32*)(lY&x56#q<}6j% zJ#xhMNL&pV;f=Guf4?>_^a@{Txj{+zXLrlMC;Oeq2X<<~RBkIcmHt2H1m+^tU0sTH z0TZ3&@Gr$V;UWA6xEL$pIQ6UApMT2c^Ub48B?t9_J#BCW3)8LgpB!28oR|QII3@fA z>%0296VvYKX3An_mENq*6el~g#4S>RZhE^DS?`#^xIi)bn{Gnc1=6Fxqvffipsce! z9@3^oM*P3+oOrK$%9Aw~-hu6q>JtfI7V?EsOuK`>IoIIFGWtqrs2_(C8=zn8%kkwx z0+A25(dX%5-*))`U)OeqRlsthY6OQ`7roKd{Zh!m)rjnyXk($AJjp1jkGZB zneAu!ETX)y*U~-(7b)?R_*J^3CyrBO54BQUpmVDJq=$Ic${RVe`*c8Omn6f8h9^@^ z0aZB#+Q6+LWPU23cvDiQxsy&${!g508z@g_SLod7`N%>4UTIHZgso0&O`U-hBhQf! zK8&rYzXNI+rpIdh_$q0EGWy271>VTEnuEmsSaGa8CKvV1(K zdll6u#NVA^%Kx-wzG5$X_7ieT#gutna~Mqe^HaJ7VKf)AU&b0#h2w* z!GTi6w61~K@?XC5+mafCx%kF?oeMhr>>B@vQ3GK{!WM(SPp%1k9DuK z7yC-%_Hy^JDYnO+k=!J`C$Rw4{e}9xOoVTU4ZsHr1Q)^a=wJeZ8BbS!hdu}T0mfkk z7|dMe&ioc2*%=%@e!aEHiE2ebcvccg6PMH)|p@c_rbe|?};T~ z1w>deTBc)l!j0TAUQQf@6hdRM24oNTM(`2$J3ACPhFv5nvNiEu$|n2ky`IO)7Jij~ zmeh@^MJxf?YLff3zY7}TkIHUwg_6dc@V@sgg3F<-Rtf)%m)csxt$kau#rQjZIhD$N zklum$a6Nbq+9D|>l03~5_< zoq8u7@F(*3z+8V&D~}zdR7^LzNX>bJ9G{#?Pd1+M-9fG+E%2#IHzf;w2M<$g5E}Ue ztsEHu@1*Lmcc8QMc6*TO3ARBSgFVrY=sc>blt5<{H4pr)-C~>gH?w_&V~|E>Au7~V z6a>4}QXL55-5a1%;Lx{%qje@=G_99{NxjG%Jzd^|bZN)L92ZkN;bI^oU1c1k;F zppvZa!53o#f(rzL+CX`QY(Rr>X85)5k%gCY#r)+(sQw8GdGlr%9^oy(60j=xP_c@? zh?C8wTr;S<(8coE*ppgHmeZW#UEi$Y>3kx1lk|y?&-aE}fPZYAL6N5_CI!C1)iBz* zj5rQzhX2#E?>nSlIEY-7TpH}i|Dp4(+9)n_9N97W6j6m5u_8zXDoLu zE~rR+%{mQh$dS+n=@rxzy{5I-y&KQNIc$tD6B>wFJg>cv;jY0hWE{~vHd$?^lR;*o zkNH1@Kc(LMyp#!OH7FawY$)4WZp`n3Hz-d%?+l~FL*D1yc&s3lt`u_p!xyEAObj09 zKPa3MKfybgWwuY)Qly@}tTPVEkQ1rxB23pL23v+0et{-g=K6A#Y|sX5M}JyofDh&q z@-EwDS@QeA2={)xm2wC40sWLHe2QBH4T3+CG&K_;P*Sd*mgbt6(nN0@9Sz&$EW9h; z!=A%G(Esm{*qa`Py+`gykHHk|s(d#*4qVSmWk=AH^TSHzRp+TI?+8@8bM>2BqAj~#r|YIqj8AFcTBlwP zj#2sug?u}IrEm+Hi^PcC$cbDGv=iK?Tf)_;tH^Qqchgekg5-?s3BMxuYGtM8%ysY$ z-3eNPM(DPHL_dI1#Ri3k=xGU*-Z-2Cu0XGpC19mClQSEJ*gtw`DiIxJPlYno%hpAK zs2UFz(Czf!N_+IF+D6!}o_AbSVu(1r8T7{36+?ynat-wrUM29i^@I5iJ^gI|SS2e)lgd8qwOod8NY5f< zvGNiGS}sk2nrr8@61$`2)DPGv?5^0;_CViW^8WRvKg2@gGIW$WL>|ek*S}Yz zLM>|ssGC+^T%g=8?l1VD>i9>L%e_Q@Q{PG(r00P%(mSG}XeV6Q8G}TU>Oz?19>V-NVa#2m;ca#;nMRDkPIN#S&7)zhy_b~DDV5p`VqcO-g`59Fe{p3>A zD`G@iDt&h3aZ&al^MNUED&r}Imquz?j-&D6VyMU`hclFF)KlLV)+jsF-!O>$*LI1x zlk+{YR=1+v^pyox(T$GI8E@@O?C{vbm!;+UZX!r7agk|o8?A`Euv`u z5Cf^f_HEBS^b+e)w&-MI9egCU1=$^~r&brToMf~Fw8d*nvGh)2IN3DGX)r6d zpe{NmsdDtBFaxZF+NK|4t)(-=JE)Gf%+eM5rr?znAF@V{@a1h2scg2;zgxKt@lu#t zFBY@Sxe8!v;IHTg>AsY!ID=d1EcYUVh}U8hk@ey*=t^)a+{@8QPVzR=pX~vGK6;Kb zhcIYgr5i{EMZ^A*hSNiMDa0K|xWt{13T;w9M1;0;}C6dAP z#D{uHum!hUc~5VtI+^UN-9rPuU9NWEqlJ>T(W5dhk;g4p;o0i%`mW$79}(JBJ#D+3 zUeo#nSsgj9G-Utr&(wZ_FY093t&v-{2~c6QRp}GFkT?bPLI2gcY;Rn1BTWR8e;M2a zI}J~coWMRoIn-zAm~DEbBiYSqk-TI+ULQD#Rrnx~EAY+}$axW^&P1kaEun*syGRGF z4wz1M5Ne3!s5WrtQrvMJu?bJ1WV#z2U(^~Nfo6FwY1`?md^1g^j#CgdSbgK3Z$5-R z(kTB3=XkP}nkt7a&m&DTo+eK92ae(-U>^8@NAdO)J39I4`JI%%=hqtYxLM@GcZ_d=I?E?Qj*XJzibEDOrRB zZV3Mwz9p$(chyCfY0gaFSn87M7QTQCofjEv834AyvB*)PvUr?&FN(=uh%~7yHrd_L zfq{qO5N3z^41`Vf@wPN)RHA2*@oI87A`}sAqz;x6(-rjy(IRJ$Tog&-q2bsvbdux0auca@(WS~mr42pPpCFfWmU_P$VwrbpAvi3z(FU>inb}TD zIC;(A#C-M|+z;z%QQ1Axz2Gip6mdpr8qFg`fZMa+y8fGRLQyrHOWe+J!gEMIl0K6D ztec^%(!@v$rm?&Ln?v`a&q4Y2-8`{+&5|wY!-e|d6F;Q>58IyP**2LuE-hng8*iD~BQ~iTBr#LX4(^IR#j30nqG$Oj*5{r!{>#|h z&`RtWrHLEl9q={#b>a(Ji3;Kz4CO3bnv(C=+eRnAdvcd=fqa1dODW0zD8|ROFRE>B z=_-`xDMRJiO1pr`T^Zc5*AqAS-@^ny1^wZxDesie;TYZvYpX27iY&0Sn?~)eMKili zoXkH#2e56h#(7h%lt1bdP1ohF;7qJn?CG;(hs_XMnd?P7CSdV8bgv?@`d-2#&lFEp z_`6hIOgEg6&I+$ByDHRI#-f7KNoNBZB(Haol*M(CMYt;c%9>~S;n>ha zVGx+;$uOOU_ZIhqNvtr?Sm2=X^f9tp;EJUf)I}(rN7yM!{CmaSA&j42)PqY#4#+S0%fdRY9`wY15^Yaq z@N?KfSUvcg?ukI7k@oq0}tV(xVy|sq?f8{^JL^GvhTzh-9KEc! zP55_?q}Ew+Xi+$Z7|e9A z-VbV$zi6T3HQ7Kb6K-0#F)@pooZcYa8+-)D2zK$IJfC{VG!kI(GdO~s#^+H_G?e-# zwg+ z3ldjSs8FGb)L8n3^g8?p1Lzz*ndwC->Sg3RDH6NsCfe6XT#`XdLmcc+HHCN&h5Y5D zetfpGyZQwG4Nj2$477&Ml{A(Qsa+k3`Yh@KQwnXS`>N%cdlH8gI>vxjRYwWs^|yC? z^fodj?KM+N+dxuP+ap_`-;qP)EN5MRYpquNV0;TyUvfuhab@UL?5~=L9Tu|!-uQ9nxqu-|pY(;cCudQx~sjiNRq_4$cl9rn@I$WWfIK{Yat!1rL&!+OIgx0ya+ z%d&OBS1B&{X0ajJMCc1X$-I;jnxr&UkATIFw*JQ2aY$zBhChdWo=arC(so?6h~T&b zA1f)B{zteS(6kUTGQ_geI70diwS}tlImTwb-wVgObK$do3VP{Zn7SR>8Q%d)W=)o^ z1=CHaZC2s;+(!262?>EF!Y0=jP6x$6`;C^AhnB&X1g8iBt#hxs=cJy@ zTk6XQjM1ByPH#E!j>m{xa&5vs+4dnFjRJHbF_NGa@rEXdkL^Br6bdIh#X10c@Sh`q?3PEJjI>p8>?kS7IVZOPnA*v95ygYh}`*C|tT zH&?2PriS)mk4+uygHl$*xxr)RzWhY?otR*1W-(x$0<#L|!cC1Wz4*!}A)9Tby%oFA zkOD8}qxu{q$o{2gNb8we<9jS_e7k9fKR%^_NLUB?1nZZi_96Y*vo@?ev1s4Y3x?6= zykK3p&^JA}kh>P>9P%a2M;Om>F4=R0e}kxc{3qp38A{AbUtTp$?a$_WT85Ui0Cl9bh;u|m^MglY#F5A&g0nO zx}|owqcc5B>1^CW?XuURP+^)fKKP2Q75WxBF8oh*VJm2ZHpFs4V3cjS^{g+jL)fq7 z-xamAWr&N^7I+1rs9A$^w zvSrCrw!*ZV!baVje3(s!W55db8=EOE7}XQlp;&tWF)w`?H4a5k-$ zIU?>~hT|evI=1<#G#M7oXXnSmSnFdJh;7BBj-Xb<6pO9dXj+CRiNZkTeiq`1K znb~-G_O9E*o7Hntj>Lc=g20`FGuTAB2DM1sCGI6Y@HXy~nuUqneMduMU8q>8%h%`X zx{H|<=Tpmk|Jm{v4CBB-%T@0_?-1BZPNd?LR)vC2=ynnY-TgQsxg$GH; zdn+^_>qgXejfRodUh>EK+T zhd+QlHP1XZhmYc$hY;yexLM)JkO%jZ4lV<^Q(QkKg2h7#uBm}(L>f0v zXP16be%ERdAut;n9cTvcM6fWPd;p)o>}JLxi-{jnRk0a<8vGL<@~>d$apyc3o34Dt z=F$@|6^jWHo<(fU`1T#3eSl?jUIOQ4sTQ~qFdcBo5+4l!}O8nPmB_d ziF9>VauoZQW>itRKKo@#fx}Ew< z9*Iwm%o2yvQ8j}*YF~oq&|1zb><}}NejGW4W;)T5v(B<5@4}e|mPmHCkN##T=g1e2 z8G7RFu`wJ*Wm1hD1HgK9BH!FUhAJ2Nf{NT;M|!vyb!Uy75_HeY*`d?4)>{^fgg34# zs{NJi5WQNeI7+=7x)rDpwuakF8Ig9OOIS+DFQ7a%SKF9A0P%uZ(Yg5NfFiV({)Jkp z&B^h)1vE#iXqn4jshl2NN?qr7<8!4t)rW@Rz$KmNVs#{l=cu$utQ4=dbm|e;$n&TG z1ph|rr+ASh;hOxY;9v4&n#PhVH`n)KWtpq$s7Sg@MojKEOqG&a&Iz_xz)a)`PU13A zs#a0I3-{y_G^lTV+fm!nJ6SurN~wzJJu}j%KEx8@oA2xDu{1)K6178v$?nolbth#b zKA@+SAm~jFcCQu>>oSvUhwS=0x~sSu(J*qI-y!(bQAmtdj2NI7w!cY}y}EgUP5`FW zn@Wi2PsS#j#2kl#-VKifhZ$dF2lOP)sZ%^J1ul`(w2PWwsvslIM0gH+mTx77eIxOH z#$;t&G*|eOS}c6@jkfHPl`u^!2|V`_8$+GJW(aSoW59-#<7S(eNUgO0C8hy5ED3kW z;pkXLGvuqbN$#vt{&7fk!!=7sT0@TMr05={rTP1gN zLy>*ZZ&V%evQMEmtCP6)K9-_F1EUEHtuoHkOJvW~x^kfra?NvoGL6^Z_aD%Lxu zfl zH(Gmw*3SL1gs(APrrz*B?7xDB5RQ*xL&`6ne(DF%3}Q_u@$uAR)D1tDA2VN&WSNEQ zfV!c#@rLY5ONP8>xUSn_E0VH zLUo#uBO#81kXapWzb)($OZBX2R9WI`g*}y))3N z7q2JRq$Oe`b68I17J<7yXLPI1OPuPhla3P&2qD@Ko(S$5dkgc_5!PnvP=5_MDJBZd z!?r=kj0lk=e*^!M=SM1%BMl?NANcR|cJvzm7a_B0N+(05=k-<5>8^Y5I3eEtneU=jkm_TtjqSiJbqHITCAn7Tj>B| zxp2^5L%U*liydch;Elf2uI3&ik3%o)b?Lc|9rj_`1aQ{&8{1VmPu7X9qEo{S)WV|8 z*q@O+S`!3v}h(1e)w3evkd6%}FJ+77LM)o-IwQRO$g9?0$ zs6(m*?@8_&fy}e%+h}5>1tHN^$cc9mzfje5gJn0SfOsJ^r}DLZc0-_%b2-@u%fl)Q zUMY`$Xn%y(q_S5{C{E4V5wI#YiL$KY}7W7Ze&Juzm%k(Mc%OQ0H@wjRbhnlEq4uZSuZHxu|vMM zp%eCQ_;7z}h_h~^y9eB45y`4^lmYThah>t1Wez2n2u z06ph;&r$`f&0DCh*S+!;3tNOwF^>#p&_fv|O;J~Je(o}w2kK(iLt`JOf!8XxLTHDs)5*Hh zS7$0yIUL7y2$h95I0MuN^a1d|x6roeJx5&-i@#Hxctd9^kyaOk3Hn;N2p&p%*duro zJt6TZJc+ufez78vH?{=q3-*KQ#f>4pkQ*Fp*fPS1^yhFBd`;jo)gELj8DKG5m90RUOa;6E8>QYdG~nx|FT_TXa~(6ITZqa;Y4RJblAcbPDd(ck`Q+e!{Dt!a zOS6lqky^Z-X+5GIw;%8gVar64Bbm}Ie5?>BUuNzTcHdgGlJvKf$-V~P@JUjfI8R2X zhtyND09q_hv&Yi=r2f&-+6>D^$5KOvG#a-UW8ojEy|6mCk$oYi=^MA#)?tJh91H!_ zKkqKbaUvTtmmfynmQGT`x%#1-oY7K`I2n8e-xjRMKC+a0o4ZmyitURWMZ(I}k`?}w?13nCW$DOr}kZ*c=)Ah8u@Eyt_*QZCSN2F>Btfa6VsUBnqT&CLTx5Pp0 zt3rJq#V^D+VHwJD`~N`DAJAyfdMgjvuP0W44|EQ0|_4JaGZE~h))^{yqw5lnCq8HI0%>Pkz7JgAIY#TNa z0i|}Ajp^E1!0rx=W4Fh`?(XhZKmm7QW4afxyN}(i$IfGm`P%u-_ZRRZc82G9?)$oa z(uE+BqC>uhqs8=CL=6~M~SPNR%FlF&`c`Z4Aia8hX?Oo!e%2O3k^q2w5@2G%Kf z0}9b0!{U?l47jm;S;3@RCco&u+{0t{5Yj652hvjD!FCH5h=71+TyZ^xhnenhN_gcM zr*uS5>C*`-bkAx9?}tS&L-R|O-0u{a1n3vR@@iG8k$OpMPCfQLQp$(wV-F>bos(@c zfze*dQ|O*2CGd(I$st0AtWshpP{s$5H-(`#)4Oa;gI4IP;X~fTa&P}M`*4@Ca;$5F z`FnV#N0~W`@qsDERem3RRBJ=3%6!U}R3P2cip-l=NSLQ|reby4e$b%7#_q|~Xriv? z4hP}Y%5MM`^Z(Pjf&kT4*(olu+?0+2L*dTIcKoT)9=QwOkRK@jv)*D3$venV=Im7k zekM%D>yZP3b3IXFDvMe!^N}GZk*ZvEwquLwTGRoeYT#9vbN5qrg-c*0nT)PhV)3!a z2Dcq4H2Ne5kwVK7c_~J!dnsgHbyp`aKwh^y_1*a)W~z9f8z>K8{`vQIOP0Y0dVNeL>AS){41aqf#qSn8HF#*rqx=6_2Y)Q6Y{ zjMhqOmz8OHRpvgEo8OY`33|-g`mB~Br*dt?v&3>on$!!8lhRmKnq>cuUQvRic|A5c zuEX?yLT{LZz-6>7Rv2tg%nf9D3ysab$NpEsP@omrli-N+D(U~~~@ zuoFCsjJf`;fu-nF`4SP2)<7{(lXsZAln05m@Irh%)xd1G?@=leV~CL~13gu8v^w-J zr4G7G^gDT@g}a$*Aq=1zXoVa|jGQizX4|QQI$3D!T4b7ew<<2Kom>*X<1cA(GKYl; znv*lPSbBa|YcUx;>Vn)KmG{U9v4OUfc&%gdC4GQWmt5|cn3-m6S11QTKM;?hmDoy-D)HkWlqvf#nOE)dK_)P;B>!@B~9GWkt)0O2d z#x`WW{}e@dngVa-)mjj2DotnHlGjzuUe9SIFJiRoB3fH}fOZr9R_-AR^dVs`LL({i zByqO};+Lu8V0+_cei8o#C?^b4UI~@RUOA1$$(R5&LjLjB!ykzsRr6U5SS%L>nwY{g z6I}j9Vu0$eCTXG^sg#C;>Qdn%bUk!iUx>67diaMDZl7D>Ovi$mrxert81oIfAhkqh z2DY0Vy*XlgWxsliZ2_)CuOjp05f+oQ#QZih%`Rr3S^I!kFm>yF-8q% zvB`MYLX{Oq0GaDj{9_mg8cnP-T`I$ItHrwIGh)h&m@xESm~ut4X5ZA zv?BRG_PXtOKvvh1C#C&lo-~;57Cuj|C=$aRu~l>l97J1bmn|>Cm)&>BP3%c`DsoWu zgjKOJMXJrB4Rk3qj|O;2{YqbgDwEIc%aT*+uH;tBXChG^>))wOl_RLF{4u31G6Gv` z%*G z_XdukXY@O+b?j3!dC^r(Bj38WQ5nV_eyj9^nMXBs7tm8yJr*L(S*n{WLZ!jp{BdOp z+5p+APt!)KQEWSfqor3qA*oaw5SkA^#O`=saj_3;rVarm0P$|#Fqph@j8XS7O+y}%M8^WR~A$2CBb zkdV5eu%q-+nXSB|9tKZ_YA~gj?90s}Mj2D>%g9UgGn=8;u#|NrQp>Sr@@tedrj&Mt zk2v}(PteP90BtXx#{1wc<+1LU@D&|Y&kOx++tCa)In+^aptYk~Dt_)5QI#!YOcr}P zk_a0%GHoX+(M zZL*w_hI+=Zn(`H&B$a_Wh^tggsbg6dwqbVwA3j=X#?7P-S{QY>y`wkACGl3ieBiy- z*L=z`>}A}V)J6D6x>@sn1Noq<+7Gpb9v5s$)mLYd)sS!0A;#hS&5OifY(04su1H0N zdK#2A0{Kwuiw!K0cnyDjS@VJv^g*?&Y`_QIwBK zoVxNt=(KkTsq%7ciryn zFSao&`{uRG>ztI9S0(30j<)K0MXz75X5>tF-iy7nGHzw7%*Y@d7a3nYZ_bMTan~~D z`n_@2^2DGkc4AyY;-|>R-Y$O7s^>d%H`yloRr}y|U9E4t^Q@_P4Mi~igI~*OQ~qc| zGw&T+bN?O7B5#SDR_h*E=B#7Av;9?bHYSGF)&)A}v_?kSZ?7BacUYT7K8rqYshw~t zZ+HG2?}R*C-0%c4et1%O>m_GY$e;H(FWQpjZI~}U)kEm`?N&! zq`cg>@y!A;j&t5ve`%&uiFwW+F)>Vc`w0u+ZQ*a8^QmNH{2y$0sZBJfZqN;;NQSqu%jI?QzS+Hp`uM`W7XhOF@K?# zEvIk|3P$>TDf~{~YkVVoRe4Bc8auIlCQlt!V{p{Qa9uF<&>Y0Yy)7)aoxwf41 z9M_H8g!Ff&xvqtHc`96AxE>e@@5DmH8nQc95^pFD@b5%2;EuLk@Dh6q^(z1aHv+Ta z?dTk6nd}4xiS=EZRy@q^4c?KfnzxvJ>c7}(s4f2%x$kdG)IwC#&b@}e8mcQ=wI}|c zthK~b-!Se%ZV7A_BC%WY=J?O4FO()=igd$&o-N6g;D*apkUjZn^c&|^rVy7 z#Z7Vrv4*z^)=wVc?}E-i*XP$jc81c09>E2|H~Sgjs#F`#3R}U(75Bya6A>+fBmAiC}ZfBGKT$;8NG02sTKf zk9@Jv1=a(c$y>?2N~xo_xsYh&+Y_8xm!3W!JX{z)U?RiVFyA5yxJ* zOngnk4-DgegFhS%0X8WVF#H_bAiIVuc1=Ox_KJD--ICQm+;>(E1Ap-^nR8q<^Vth> zhq&kH`Rx8VoAL?QP+(N%LwhY?8yIE=W!?|g;dFjIazDFr-s;@{9O+nX@Oa*0NRdrq z7_>OQ^vapQku{?N?}c;ziq^`y#0<)+9^8QJuzqz`wN>P50>gqLT+-Jp6p)6&2W<0& zcR4F<<&oCVbZ3!wtG^UnHegw~0G$wsW|f3!a3ovLwZq|zf2`+HZNPzOTjm^w>Xm`! z0wfmbSGkq)W|DCC^`wYT1DVJ~JwS9q#;8Z5AYwVug1aT(_f_RCI0U3AdRkgy^d%-! z-G$a#D)Ao8(!$6ERX_@DtI3(fHNj2vw6Bnx0!2b!`4{*XbYGh4u8AEGiq`%h+^UNk zqC3osRtaJXG8u82o^%-=tv*y1nJMbW?QnOR{|Z#4>jr*mI~ie=ZbIn zcb=L`j`}*Lkvz;|Bgd&vB~TmB?Q!lbGu)R4H8VRkWy15hkZ>ThZB?XE8;u441E7+g0+%{R?3%uR2 z6f8lT1vmCE;w7Y-w?4Wm#wncPIqY9B8ylau7awQnG1HWqP?qx0YF9>Ty5~+3sm_l} zG#Q`kLrc&%CegQ*x<;An872In*8-QtrSkn;Pk03B3+ECd_)X}>A01^q8 zliw%zQQXF2!y_``8Tbi&E>)hmKrd8#o7}sR^n8V)>Kc8>zr-cdP-C4CLi;6WO4))# zdu(19FByH2ZJvf;?QonQSG#C^sD^wO?R>Zq<D6SjAqg2 z)qF-QFIS^kS=Fm0;9wH{Z zPA*NR5(^v!$|+T%r?A_F17u}s7hAIMCcPhVWAVA$w3(&ZyjwQ|4wY zQ@m*UXHuMlTzx1X_Xs&Gt}i%@*IU1oT;i+4-B(%KL-fIhu`P|q#9_Lazl`0$>nJCQ z&7Mha10P9#r?ZI}C@D-qA;&oI0>Sa4Lq{o8>KeWxbgML7{;v4)1`85Z60e_JB^DCp z)JCbDQfmvBsBPxFWL4#YSDs4o6O9GQ9qCzUO3~kFlQjoYI}k@Dr^&dC2TrKbvHVjxa+KMN5-q44r zRBa~iP;Z&0ix(&sn8y#&$05y$%j!qGluJ`|tQ(O=PBGdD`xUFwSv%vdqXm?q@CDhe zp1?nv&kiQeN1k`R$lNBD>3st<)m1cFwB+2bK{^sCIC(@OR)lLcd4%Hy8A%V;>ikNLm00yR^!(k|H!k_ zOt~7Gi2f$GtJ^Cc2wcR|m>a@Pw=MgRF&RFl7;0H%5;4U2(YU65(MAw+kd_7wS@PDP z-Q3fR$!a5ah`xcoaQDc6$E1e4qZjn@>JFjmx?-^|xt06^Cjc@uIB-OoWOIWXlzmt% zlwcL1MBOhabT8vIKHk3-1JE9Mn3hD&MDOE8$dH0?D8B zP>jazAqVs3Qn}!M)2R1rZF$pq3GTozgTSQ0`3=@hLQwnRq@e$3;HGOklxy7+IGpLq zwdS`5DL9Tf7uC*HDfGnlHnFt0(+B1aNz&Gw3bbDNis_B~=N;wz6O0ND$$RfT0N!<- zuno@( z3vgB{*gSp>|IzWzR+{h2PlSI))D2DxEX`|yJj&h#l*;aAd7LoFG0h<_?guxqhg_X) zO59xJrQ;J*6Ss-pb$v{p(nar$v zZTa?=$(0>-#E`d|e49(NH1$6XObk5cBOD9MRknYNdLFps>%y&)CR+0CR+rs%ge|n) zi&+)y>TJcauKUb(=Z}PkmLb4F+Y~Mm@9{=!Box`g6?!-vU*f=$qgiT;0t>)y`uEe&4eB7E^{m22DM}7)B<>oc0k?&??=91*P;2)QF1^0 zD5;;cTHeZ7*cNLB6FXH~hR+7&% zZG|b+Y^jlO5_?}FTCUBHFz-7-`~Z&PUcEol#$bd3dtY)SA4!-gVCJMW8<-$(GJQ`r zWikN}#B(qld=069`sCW`Q&qO%qiwzUg!4+6>eCyz1qlGBIGjez7LzEJ16>bDX z1}sFH&>cG+JYv#-Y7up9`=vbXiaCu#&ZTTC`7ZQ=U1}eoF3EmpcA`?OqlNmoxzFT2 z25S+=kWm;XUgEz%Q<>M22M>`0g=xxT?lxM*7ccz*F6&3NF6$=qhwbSxi^WQOBKcHl z4XwZrfD=t?jLFb;mc&m4IwlPAPsVETcS3Gwq*T+GkXHh}M>FIkqHZ`1Ybq>7aCL?9 ziMJ>3qItR4Xd13boKp_jhB)e~_p}}gPV7aOlRsA#GlEn~Rg66JGBI3=uv8)U=&i7Q zaw0OF@H5el-;_IWC2V0gJL(!U!DsI4B;=Wpyv3O9US)r;BuZ!Pr6k4rSgl4b!xTO` z9OON8I>yu2h?Deu5A$P_HOfKqmSsP7o$4V}@i#y>^ONn*L!%3LeUSY%dQv=O`X}D%U9`bUO=BqTB15L5 zxexwE;gMFrC|H*;w7mUa5!TH{Bv)5O$1`Qf4cEbJxi*J<#kfs77xhJ={|93H^)8E0|{R8R7lzc>5bWLw#Sq zl;|opGq9S^+>z!~^?4iw(S8b?7RS7Q@2Z4(K0{O>Q z?CD6f0xE~EqaE<~g^AW!pbH#}FQlF)mqjHrVfEHsj%={z6Be18>FG+PtBtAgYg=iJ z#g{}Uy^zJpEYQgn(#_qqq)tUiayNJl;4+3V5psMo!!Tk)<^;0V{VusV`P|s&8k+pv z_l|7LYe@}+#FUX(59x>42pB7@!S69w$YuHtx;>f_E+@1J_x0qtKg*}J@A9ak*2o+&wOJ9o6PSow7cz|y6ywX`RXk0 z8PrLy;GL)+7Nd>d9tis)ZA70!qm4;$9qeYfkLECH7ri9v5u36uKzl<0Po93jUfHO$ z%4VI3PC}ok?+GMljImI*Qis)o(4oR!f;n&Co8sJ#VueZ_gI%DGF`sqAJ%Rd1{THt! z9rg?tJy;oPUqOgURgM~KghYjadLZSI{{Bb8M|O=KR<5do@`~xq6w`y4qeM&2YLT$7 z1j50PrC^VK*P88}op@K=VH!oK)WxCkMtwe(?c?6AG*Er{C-esX4eVEPayW;}4xco$ zIX~E0+(dSoQAvDisT@q=K-$GV@buMt8KsaHTDV}lc1E3T`a_yaT}{R+<=O4;oz#Uw zn0>-nwkgvVUxqIy0K^9OB_jEi44yp$-^8^H-<943##6V*Mq;t_6>Y3`hw|A^_-gqa zp_!DP(nJV2OU{Kl*ahR5kPX~HM=FbvYl;K@0lpB|^9gp8x`)=`7HUsIi_J`)hrA-Z z;#u(*^ECS;+tIlM*r7gK`x>!0cPE*4NO?EcTbNHKD=QrR)C||b&=_$#($3_D|5O!k zu4Av-%H*r2X(yajnTfy<`(EfCZWvo~DI9<;RXNrV2*5v4QOZbSx63>}jSj5TvPZ=sq6(>r2_3r|K>?ZHq-J}4vv#hei`ma{G+tga>%(-hTMqr6PMV`5Vx=%N2Bkhl|nl=1lGs>#D)0! zLMyrza1N}9S;aBLE`?AI=pD>l-SnJOayfFmQa5QowOu+w%;b&=|1c^3Z(O|d2@p?T z!UhJ?iPioItA0q4+8HxVunX^|9>!ZcUvXcl1L!zu8ah|m4^z%jNQaOQYmnH1EQK|7 z>_jH9t<)HYuJ}wUNKmu#vtT1W0^1;E2o2OVz;n1Wo62uwdE7#pJu6Wr-Z8I~-|F_@ zVD>eV&L{foT@&{ebl2NA)mAkDODT&m%JaYlC7}Cq4FfF z3@GIkbAyAybe7cCQ$G1etc$Nken}kzhUMX*- zwZdK`M`>z)?r`5wrAanG_90qQi?I#-+tmrkIy1>=0{^%1pP5W1Os-#dG90FGdUXTHT3g#{zSeM z4=Zz!8tayk8%<{N26vR3S73FX^dHh9{M1altdyrZrUIx;7k09jGBaBJrCF$mf;56Rt)0SP)6EPdR5JqdQ~k7ZhD_B6^8d+7mDLBr4NV9h!*&`UxMUa!)+F~5 zH|S`}m-i{}C~KoPc~7ZcqOE_qI9!b({lY;RcNv%)nu9JY*hJWjL&N}W0Gb&1A6Zml zEs^2Ai@Eba_dc;IosF*m>Ixs>t;&eReDnzNR^3GSvMD2yI<2K>Yq446EVHdxC=SWX zLK1*UF_QwJKpvOIG2Z)&fB3Bawc-n8y>JvA;$@liD$^{I&_-D^3Uz=6k0scDvjdU=bJOetJ9PRD<^b z$ABjJ9rE6IXWHii2blF-OvteJPNn%+jnk@uuvHe`;Lqd))=(EH) zYhjpcB6m&iDK*KP@3kkq3#?yP7C8+Jl4(W)zeO*$4;5P?#esNmqZMP`aes0TBSUku zov8p>u05jKo4V?O*G$T9UH+`SjO!3wlDTf7O$X91N1JSs8Ji?C?}0%1e$2M=Y+w!` zIqGW%p{hXtq)o!UxXaFVVpr~HUK#$WX}a068e+=@wmSO(UDtiiXQOJYnil7^#{+Rr zLzrS;A$<<4RC=y|VNVbJ43r8ar{+8Uww`s|vJ4NLWR40qjI|%_VUMA z#kG<1LTlNfz+?LrTQ^|2{coY9b05&vUIuV5KLR_z6~R`>kU(wMV_=BcB^eZm0GBA4 zCW*MEqpWXt#H+l9fwBI5U`>0-9xr`RK$1hMA|@lIdwdm!iFh zk|-_DW|NeMiEET`;w_XF76m%1@k$G>SPg-cqm}cP7%TpUWOlIPm;Te1K$XR7aI)6f zZ&42m?<{rI5mb3+7^gzlbG9gN!Gr2*D1YT>F`yiFkp7KQz`lbiC&ZEU>}MP!;4I); z0*hGXI9oMjGWo~*m++9rz`gQ7Wi3_^r-Z`TG}HW>68K9PLXU*IlM%d)tE8@FGfhXm z0PXYF<{oKX70A(;97>F|zBS3ii@{Y~*^nsIBj1~U8+hA5!f~-RI~bn_E)woU%{7h5(dYo6 zqVYjVMGZ5dGz5N^Hw3JOf0I<@ckn#`SQ`7Ec)x}wdYeHTFE6U}-3cZSAP8&2lwC*GXT{NYQ6HlFMmLOsAxx91`Nu@j$d^lGxBPIwv!#r6Yj+ zSvexx4jodfi9o1cOey6L1QEC83oA)vzT#E#xXO4v>9)BaEE0!`gA}*;EH>S?GISRb z!I{7!v^Lg0dbnxS{wfjt=wQ2*lZ>gBqsRjBGOP*>fYCrMR!;bdm)7cugQOVt8&Qw{ zC$Pu)&@#>1-qn`BB0e!Q#2e+?QX`_Dv=yCbuL$HIm*wr?FY71iIUY85iq(N*L}&bv zSl_poc*)Xh%Q_07R3b7k4vmQMh8!r5k5x$c1mdgE%4}YA1r*b&@>?U29&v@}Qpu$~ z$K+LAZGTaJ>2;I{sAb@(?jdI4ZKdm(b;Kjc%!>Vx4;+hhQx9=Nwf}NwK?Au`K9?^6 zwnO#Jd_#`h%jAPaX|-MRnBKY_tx#bpp17bybL~u1(l8L zJ|HaWlHyF_whN^6JCBn!v66FP#T0VB5St_l^P)cjn!|#PQ=Z0;P*!tofW=F%2M&|{ zgvA2OhT?lGC!MX79_oCe6!F2^OzH0%7sy9jl5QpF@40dxYJvi#Ddb4sKyypEPZ-7= zWjfn8$vEJ4WGD&xCgp$r19${-tlSwS5~!=dj#EHL`_1>@`;u8`F>o7;Mm{O=BJBDB z#>)qkuEb2{km|=K%e2`@&h+;&?FGHWChFhfkyT#nd2+R!iKUXo#9F({pDz0xbJQWU zs(wKAlc`A=A(o4-kHtk{W zU>j)_l;2E!Kb_qcTz)T@-B7Q|PsDs; zIQ9TjQHS5xK6VD+`=&Bi3p+(J$-~ zWQaB^@@)W@+a;}Mw&%5TG?KjJSRnzF)zwlZZiBd6pBcMH3WRE*-Jk|aB(z#?j-E5W zixdYBTX~uGri%%fNpQ-vjXjce=FVSOX@C$Rj+6plsKg(LCU1s$4rDQld_`TWir`q zWdZC*KhnOLoAr9+XtxzOi1wyWNmpGz%nfc3TOu3;zXB><$u-6}0@j3=VogcE^jqvo z)XU$qN}xZ3r-jnmUegM7FTS<#Ox!4U&=waea(yFRG080YEM=JZ0Kwcjat(kcN-MI^ zfr%!s;?ps&FxzB~P881yGq?rBE_97)1Z!+OWzMtTp_y`1Wt=crtPQm%Cxc5h68Mgq zHwPnD>EYRn{X$R31r_{q2Wqx*g&zVAAtR&At7B|I#fW_K8Ok+5qTNc;8UYMDq`!nC z*~8aua%VcL3IhtF;DKsyvK+jSC~sdR#{vU|5?Th-%)U}NDgMG9v!87&k?#Hi&ZxLR z-Ad^ukESP6-ZPY#Ximr&E}Bl>e}ND?NEpZrF>>MRCON(i6{D5%cenKvXKK#yKvy@t z6fq7B3y-PkfwVQt@Qlz5(JsdkjkyP?U|!Sb5I%Zckm1t;PwiRkVFn@h;T4rn(g48= zoFL}eZzzwYF?d*R=Pw;T@0+Zxk~_BWbh#awZqF?@ip ztE@um;oZ@Ld^&$SB-%>`s=z~Sjp9ggbSNb3LA#PEYIA(6a~Z$G*G7zu_vbFtQryiP zKl!qzGrAGCE0j`U7;?@z35!z`+1Zwur0>9p{Qjiwibc=)2j$&iy5W7rW=Ku8OTdB^ zt&L_*iYuJgz#Nksb6r>yd>@>`j?eGPIDt8EEASHg71$;1R{(z*a74^|up7F7f4#OE z7EhMI>QT@5dV%uLZtxEJ6Wa$D^RN5>@W3P~tP!iiCM^Sa&G?*k2o$T02d0wk&EIK*N7_4pTco?GR`B8a&kb%7rQdb?{#g1e!~FAV()~`pwmh8Tb!>)MEHqCj zfvk^f4g&rgPK})>MTf={ulTT-Ks{mG1h-cFTXxA0#92Za{w>}Q+3r9XfM~7L!u$Lg z$aVRpy)J6gEyN|EzK|`Pi=AQSOj;s5RJiub z{H97gO)~ZU6R9%zd?DS;3r|qGxL=`G_Jd+i8Y0~1BE&AtMq?8+M)~3#30A`^7=$oY zm@c;C+iTYdIJ^`1KrN^JS}3@aT&}KC$D((QS%G@tOriswh#bm0Nol-ARubN?Ni>_i z8F8zj^FopSLHz~eB#$1_KN1sMHI+zYeZg%pjr*mRK_k#c;C}5Uk_>eOg4hes{@ADC zhTz&|ZM7qL8OBHB1(m2&Q_BEd&53v-5yVRgdy;BNU64*feWJBmOZcjlhR&**?F6+J zEH(m(+r$bcrE8>cL98PEPjpyM5PPXgoNb#OsP zHUi|njAjhhtg>}i_mXXvd_mP#{iLf6G%MjuxTtuGx#9r3`3 zc>`C^D+i!gmg-z2`Hz%r)G^*D*Z8fWy+{rGF(FhS?Bh)OS}cGi(xZbNK+doo|)tirkWLL%3vZ&sI^ln|`nI zmZiWJ20)HPgz$sR>+F5>F}aMzPzE!bOyS&ZeJj4*IGoo9i!dB&q>-ZDSHHmB<5$wX z$yrD@>H+&ae-^tMEQRJN%k^pG9wkwJ%{&xCIP*+u0m1pcquvcg9EkuX4crT52rh6!SgH(NY!Ya-tgR^t`)Q7*x3HmsCUvQRog zb`_$XP5C&b6LZEn5YG#fW^(z3$r`LnNlIs?u4}*8G_-?Cz&KYU=&dwMnWGMd{Yo}i zIWUXc3ZKkOJ#1 z0NupWj?JF0*b;RM8wCu)I)*l`n`kC5&anpFMRLNBZK-(}kgIyI@xa-T? z=>MEAnf~THwgK4;EQeaqOI2Nqp@x|(>z_!vt%LeSh&5*{E~%%`kn2~#s14*-#987Y z`UX$uw7^*K0V?4<0oo&=h&Y>6Tb_qrlgft(Wq`-PS9qd~k@6(}J0;aILHK|*Bi#8a zSQ7IoX(m<2u9Wk)41A~FA(PdG=x9Gj`QsacHP~Ey2n-gC3#6KT={8&;bQ~)*XHs2^ z4c5jn)MUnorV(9z?bQ-)JNZM5G?{aE@mTS(nJ4)OHX{Ab6~qa9q}iV8DsE(#*%t*L z0>81*?x{|zZw&lM^&lS+51L?nrPc|#_!xWzx)b^BtWmHU@3!iWV;NM8HcF^&zHuV- znndNK%fd&g6EfRTj_)U4$O~cJoVrw~t%1L2d-&MUb@?5?S?a6IQoj><)JLwb&?z?~ zJPw-9d5N=vi>OZfxZ8w`)L@+CLt#-)T}#QhoiQD>TkB$!9fnO!UA6^m1 z%?2qscz-@aw$V#L6p-$52Vi! z8)&DewbrDN7n9`oMoq-+Po!rN9kNr1UcwkLTZ$%lIL|&%c|*?CvI|`5VsUi=h;|8A zHAebBm>r%PdL)`GEz}Z-1%PQca+Gjq(^%LgZ17ji8&SC3Q;9XP6jg3Q-`&;SgCH9; zn0!=_Zw$klhEEi>!S<_@h>nPT{UP^Z?K9d+ND+8)y*@kJMt{LeW!b3t+Bc<g$;GlhiX3Kt>` z`Q_Rnp|PP8N1={HUHYvuj40IV5SP&FdR=&F!9)*k`G8vWOvc6RFt|Uj?i9{QEKVr`|5dOAhV0G zU?WU&%4d{7JkG74qmW51P%0+Ap<@f%3U%H2dTY6iu&r<)=F>M59%a6;o!__WYK2kw z6M3gC4w}bLr{=?3vfCgB?JqI6JCUvKqOrRsu)U%E5K>v*W>OCilljyLp=*+=UO=C) z%P=oDUWDYQ8AFXa%1f*ni7OWpHkto>B}-@UzpywlTWA!jqW>peNPGnKv|V895L=Nm z&~Q~0hUghwamkM+Yp^g_Uz|#%Vc*Q`-5>D}&`qZkK1k2GPV7z$5De@o@GlaR&#L|M zs%pj9GjuJK0k09_)DRZIrzk_IcWhez8}zvN4mk>5XZqQbrQV{8StM)_2~lFE`i|h8 zkj5ru$Y2fRA9TJ#kPl3I)^TBvxWcrIp8EgK3v!I5OcTap`!8Yv*idaI!M>pkq!eRE zfc~Il&JRoBCjnYc1-F@B=RQeFT}-RwO8Nn|o_{WO6#J1g5ldccYO?Uf-&{#xcWe8R z`FclTm~czoD!Pr=!ZftJ*(Yv|{1b93=Pcc_S9(W8zcv%oEpr)gr`CsA#NP3rQWNnM zXZ74ja7(xfTUQz=-$P1Je_>yg7$T17px$9Ru%E&&n3H;GGmCqXNJRh0aaLUFhmFLR zA|rx^QU~d!*wE?NC!{ME0lkzPV7GH%!iSy0al9CvEcbJ^FnKE3;8d-(cnaA^oFaz^ zeTkZE8~aFP7HG$spm)@=au1+LzZ5srnF3tbma?a%u`5_14iA_F&>u>D`G>ku`JqoS zyO@e{8B8Q-4ht_PD!?xEY_LOxH)1^39zRFK*m@8{gdoY}&QkKF|I{h$JOp6zmmyEwsd!o0*|E#M~(_am*_rDQ-Dy`%dak1fZLDX7g1u-X^ z<#;C73Fi?TjC7=Nh@h9qj=Tjr!~Q_xLmP=2vQM0ZOvZ*;o5*31%a+O;&vm%V2dia>#G`(tHRO!Q7&5A*X%Us4vQ>HI*fg z|FPb{RhenRs!FfW5A`SJ6+wn(o-wl=6|sfH6tmCX40sKcQ6~bq@H=EJYGzGEjBa5Y z6WWICRkM+&!V{@FTFYbvMTaJ1d(eM1Nm&H=(GVYFNik=M8ASC!Le2r|xe{k4X-ar% zGbeNrdqgHvqF5~UDOj0U6E*2*`8m!Eg`!vZuTY(eZb2{TI>2W5lfP8>6p)E^O8(&Z zi+zv_K&^b0smtnz@L6?!0cF1HO+z2*8N67;TL!v8A2{d zCG%ZW6D8H0t1Hu8J$2mokdDR-4fLn^Ur@`DnaRH#dV#~asZhuF6pk_)==C~%|eJ%mSW%$KBRxa~?Ms`|RcdJSWVLV-=- zo8&A>kh9fTPp4981x@wwz`xi`qnx`PCtyv&^PmMpdnHPbDzIgIw}r&1o?FQ?sNVP? z)2i3O$Rjy@OxQ_wr+2ukFmsgGXd0b?x5Fp!eYMuqeW^%+w1?sLCf~e5;d^-r`AqCq z6zQQupMi(q3gU0RhUb~FS@~cdKu#zB8`Pj=&q(S*g&m}345nbQ7Ov9Qsdp6y3cZ7e ziP4_z`Ut6-yodhCWWkZjarY~I7Clt$t)G+%m(I}=QvkYD;bZrC&u)4geTr>cv3=n+ z?^vObAdAdYy*APjcJD$Dav>w0*h4*q3i6lP?ot6y7CnQi&+g|A;+eu>ZB}@WGM)Sz zK4Lso7HXIIVcZpXp8qRgZn>DT;4aR?t`<_w?%G=ZBs#)9NOharS{_8*)maDGKxnD0 zb7Sxyq8iYE$P(x0uh6#2Mb6<;G0E9)J9{F1l^VhU>yHFPUFa&M)^+rD)|0o`e}*2Q zcg(-VHNZyvi?E9(Acq9X5o#cC%WJ1SJs!^9s5%^D17}U@-vYP+I7axvaljN7av-;Vx=s>?p&PXM0-UnCpzMmsJJ zM*l(=I|%eJas4MO z$I4r8Q6;Sl?DM5vNRyZ}riYLm_=p$iRpj@KixX^P1NBjjzbiPg4`4MN$EOpJ+!7rD z&4{cn*Hdc&O}IDALS&qyvNBgp7b-ZaAY<^o{1aXeZBBfeaycDepjRy8}Yt>E4 z=U@t7D?#9vLbs%JFO2WAf8lpy?|~%tC>97#m$Ix))OOy8#F!8mSRa@q&Jj)PQU2YS z3xVI|hY8D~xd0R<AU% z8fIRkCw$%iiLIyn$2_z=1y%_uMh)UMFb`hL+0p-_=q$sdxY{rb1d9?`ogF!&y9qAA z-66PJaCZyt8bUm~SsSt11juRqViuj zR$FUI2}GD|2MvNMn%5C4jN7giT&9MGm+QN%Bc)OsWt5)|>6ubN)4Vvn)0}v!uk=383tH2u^xn`vZFES>P)uei+jL>St@^^;w=K zHC*nnh-^4gi0kj}?lTqdjsJCBH=pVjl5V?C`m=*$z4QI2*7WeMt~4(?%~p|p5)0Y3 zW*)MCk2UI2vA)d&W`>qKx~md7IeDG(vs;|x=nm1v@zqF6ndq9Oew&MIA@7l->?0I^O>4*ykWMV2RxDwekC=q z?$_5CT!JWRlD}zTHaXU%n7#2&ksO*`=kV-Eszh84^@*lJUG>9cniepehy7en>5DS% z++-L+DxiG}&4kP7y!iaZVv;i3vO!Ms*Ocag3EWTpsK32*OFhE%5$l-$&@*h;_1`3& zI6;hYF~Muf8Kn%<#&~WmWDsg+Xt=l<90tE@#+a$|ASj)a z$D!53zoNV8mqsW2JiRe|UaHAIFKv!@Apkm({xe!16jiM)22Gh zQsZ64Tvw_Qx=*VJrZ~GX9Sn;9P}d!$u5iG6XMLJiUUncG`c#Pf14BV@|HJ)i%AlC6K)dyK6aWE<_ zM20{*)lC{5?Cor=A7VT5zaT5r9PVn)M~K2N8C<;)j-}wftXkq-AW)lN$U#IM(3Kjd z`tc9aATd@hb^VK^p~KbkWNXFaXis*A$7&tr4`6oXC@_XzM(r0NZckvWIsyJhG-eKl zhZao{8AkU*&^iQ$+G6vuM_PgYD-vgh^*PRb`$lzgWUOxm!*T0eVP&v=LWxt|EUyNP z<7s#S(n-4x%_pyFJCUPAZ(@9SfM+K0&5LR`d*CyN+ zw~3>rDgHUINnB6A@ZV7ya9`0gg=f(27{Rm?>lvN859BWGSwfT^rJaxzxw;})+e`QD zYwYKNWZf9|+2K|;XdT5!>_bU)dl(uFJJi8CL;hoU6Gu=UgP=AFe&MPlE@1O$gUn4m z5@#z_slmt?`igW|FQ>*PUlETcJdn2I5$5#dIp|7!w*43pE8TEU)xu$!{GINDUcv{7 zXM|yqlboZS389fhZNgQe$g}MaLl>rB2NRWf>2hqy_>n2_PC%Smx0X(9UcFdu-7 zs{F0^lzRbZ_7QC)+Kc!GYU6#i#_&aHVQ2x<(DJJ~$$Bur5J!BC1JRZN;V5}8ZK6LL zG`_$299OZ>-}%rVOYZ{h;$^K1zmHx_ZpiPBClKf9IpRX%KVVZjX)(%oEdqCIeFGQS zWmG&<-}wuCn26NgaF3{Sx2G^`HPAmAs$ot5=me zgMgY7nBi=Wq`9b2D{h`VM;a?0@jh0^a4SietfGG6atOn>6{-aeND1OKy%%NHUKm^W zDa^%`Cdu296W~~>^2Ss!k^eKflOKQ_@{ub*6jH^`CwhEQweVmyLf(aRfc`1D<8REL zms3jrK;MSPIyYgA*t_^{cw=dEfd@uY#VwY?$`h2Tl*Sl>-`1(rEt}f_VPE+WoryXI;XR_s>{WcoWsAW z+2s1wrLO+2X2}!zGxlcIUrUQXRCqwxOt%iK5F6^H8lp#gB0-YsOF1YFcQyxqic9n? zc#sNFL!1-UE6Uyyi`rgu2Peh-v^^$5zVa!4<%6<1oSt~9YENpoa~S>>Kdz-wQaCYw zRd9n=$vGi>Q(qdjLz~JhckQKOs7UM@ER?)TF^BGmQOR?~!~Sx~>q5V~YHb|PWEm~# z)4>kZ>;M+`SMpa7VU406tJTPP+HCc^Cr?l#-Pe^{K3%O2^(xsLs-Vis4f+)hhg0a6CDnywr7){tz)?Z>`wU}IRlX46em^8S2BSLX6fId7$=J>LZn0)nrt++sc?8CyB%t zQ7b$W8jZA29&*(RqqvFu1LGdi$uNzKlY`Pbx-OiqS0rKh7y4{|8@Q@TmG%(7V&6am z+gxWeBWru#bIV{k9gQF8n?ZBfa>KHzDC2_PHCa2zwbr&9eQr0tO}!=$QU{Br+6mKC zzSny5c28w2IusV0P7I^T7u) zmdTRe%6;&cTy5bDk)hoJ6^%aLIC-XB3cJ)_g$ROzCnmqM{bs2gH-?6d9ph56CZLGg z_+x4czX&@d9}G?mb%-u;L*b!`UHQu34!WP~owS0D6FLSzlu4&fBrFW{VFlkg|G~kWAp-b@tLpXPvYUGD`36WtQ_k;`C*T-IL$l;sxFK|u}=!0u5CsZ+^c@r&N~ z*u>mS^+E_G`X?_4uB3L$*R)(N&oVerTm4IWSp3!T3Yn?hhc3p4;95#yW;tV0W^iz| z6~Xxio2rSL%IzUWs?Ej4&g0}QDw#gxZAu(-M92e78ATajt9`iVIC38zXC&=_9Ojll zek6i#f?PCq5(Im>N@{Mp--eJxL*Puz$s5N_EB1()NNqMLw})8MbI~*^NIBX<*NJ7p zE^s-0Au0zNSX)b3{4V=M_ez#uN98Htu@N}kZo0tthRZlA*q5;**tp36Y%D9Z!w z!-!EiO0*7-b<85O@vOq{V*O&+9kpI#JMfbDT@1*r%M3(Tp)}sme}f~vjL;Q2;@AvE zno^1%k|2MNekmR>+;Z2D%uU$oDAGB^}$k^9*PSR1=Hp!I|X{JQ+pY%)9+><_k<%xbCq9y(Z= zYFik2%f8aQ%X1%}jrJg~`25K4#qHUxTnm3{pa?BccJo{OUDZF~iN(*@&j1=b-*U{OprPJ-vGzsai6 zJ=yQrIe%-cDsshtl-Q>4JoV)pN7Z@Z8$iI=2gw2%lF zCBoa>2UW3hVO*3nkUc3h2S?zKg#Zh1wOGnN0efWsk?;&OV)7jh@?T&OvC%Xd#gqLr ziKo&fafRGQKVX>SdMocu@%$IH&Z?kF1FlquzmZeOe&iu%H?p&7k+u;I=u9yj=j2o7x))Q;8L9 z`$C+1U@pQu)PDS?Wj@u|R|zx#FOJ!{opK}cNbCVH7>w1f!jFR$uo$(i_C%e+ zR)wC%kK>O}UC1QyEa}0|qS;DIdAuA;-AsU)61+hDuW%E0S{o8Ps!cV_v~Kd4oT*2s zR)LbfgLB+LM+>C5Xb`=f%Lv}#*I*lwQf8pRdzp>7p~d)Tt!+YYR}W+_zF1i&o_1UU zrS@Nt3G94&f<9e16--0>KqoXAduPLKO__i#6`N%(W)XK=={&W;evaH@G-Y?nA+5ep z3D9C!UlZ`3wn{#$G(cxjDdaC$5wV{BPE4#)A#^p^222!tke!?!e|CJXgv-Ba<%Ar` zC0-+P$@$Vv5_dE}|DY(nufLnhB2B6P6=SFtMxU+_S`Xr{JBBZ8iz4mkye?WQ#C@H z`7_2hx4L>e! z67PV2u<6pylCwl9{7>jh@-5Zp{@aNt3#ji*Q16<8(%D>NP>ngs7;ZT2xYKU5aF0^s zwIj?S>ZR1#HMFF&;S~7<(tJbVPsY})JKquStcRAsJIV@t}|uh?mW6_QPOkZ$TL z%LN=z?P#3;OFVB5;O)qE;S?qdu1j8$yGvW}G;NtA5sl#Q$V%j*t1%PhSFy%K=kO@C za`CVLZm5NyQG=3 z7lLRP#EIcmOmcw`DkDCMib zs@g+LR-eUIEOrr*uIqk_cdk%YSgNMVx#GKUIVD9{?s7X8si(+knC^V0?)2_gs^Nm1 zs~<3)sRKiAtQCcSUG;=}cwO@(a-DsF{`mTk>9d8Z3f*eoXRG#qp z2BCAIJ~`1M2*p%b6wkOOAY(~MI}6Ua%Tj6RUaNOQp1&tG5I;d?fio0upbRg14(LT9C1S}p{s(+Qfu@nF(9PDy;9QT`ouhcCuwx3P(5Rt+gnw{8u8V0sr`UiIGGM*gPMw|!*Vi*U($<)rVKyv#N|bWnRpPo&Z% z;F?7;$X;=WyqW359!rdep3@WDlk(1s7x0I8bvibTiL0gQ#Cq2obtXGZCDaO`BWgug z3@Gb*042p=4fd8B$+6fb^FnHlTB%eZ`h%eI-1-Iz+w!?9%;u^?RM>e$9P5ASbV!n3 zos2C=!2X{%TWfC?n&&im+Lbm0-;NwL$)U@Qbk)rBOeI>3@3#b?HS|U24&xReow(>Z=4R?|nTxfF+rg`+#`k3%qU50Av-SiKFT|Ei??W`|mgzvZ- z$ye2U`0Rp=4UXY2b-Vh^y%Ft*loZXqe@6}mv3NY+m*fQt2{wsSda+}i|)p2Lim zG08@!iEdl#U-;n;W^b4OgQX?kqvlc5aCJ!5D{N{R1@jj!glep`dslr}Ydev9H z>%tHIsl3Z}z(|J75-ZaCocFbj(r#`65+~-#wL(w1-OxDS5cON=IbDGLP-m%gzz+Dt z`aj7tp+3qB`x3-Q4yFJ+o^R#+DK|rS5b2B|98MKVEo`k9XeTKwvOewAEsSR8=W4QZq{6r+;u&@qooOq`~m6Kx6);v|f3sB0xjSOV5Je{U5oL&>)Kf zqmGlFU!dW(ulz;6JC%xP=3dAIRK#}%7bsP1!$3J-0vPUCCQc+C8~gA>rt?HL)z0C- z=U|5=8QS2ev*MF*z%kPT$1mpk%IU-ss4sf3s50ozw>Fw1L;RHKg0kG`QH&yMvh`%k zn%Th^?xjyvZ;ExjTZ%VuTbMfDZov~F%~l^gS1rWMDoepK<1Mq^aa`PpFEjX>HSmVi zRot(?26lsKLxmQCD{`T-6Whb@pmib^k4|ic1_Yb`2KbHplz7UVh#58~^Pk{^WrDF2 zI||h$3fQfrNgo;dgWhX+KO$U4rn6|JVVkI98dy?Ue<-DE>-kizyM1nAJ9f4~jQ?a` zOs(W`-;#)+IwR0TFyZ|E_U{<^0(5XqLuc6bv2HO{HrX8Xui|-pzLrn@ zlzxDJ1eJV2*Ps@|S<+lAOs>b%19w3eYJ_ojcu!9@{g;;~|3F-HcV+}vg?<#^`1?!N#fgEpr~^B$wK+y8Z(p?GNKUSXOX?P5Nb~Hi*Et*av_TqKkc)y@67LIIecb zmx{4y47Je_k9U;IBEz&6iTkJn&<3P8+p{=9AF8^g4fZCKPE2A4Dluv;qO*S2cse{p zo)SUvB@z$Sfhu!vK?gBNeF;2fnyU4*bM8oKw76d>t3@R*MC8yFer9-QxO!;4ltJDj zJV^uPvg<$vHivOkcJz+R!<~fKvvM(Qg^^AvJ`)q*jZa6UxZL2u`r>q zGkgrS`EMGAoI9So_)dc&anS*4q+a6J403UMPxSKlz6Oq?*c!O9BhmJa zzKlI9IEh<947x#FuGCNhx?6l8EyvPUANJS7JCWn)O+3s_uw5kEvdz@#stY`^ZIhxL zP1tSXir_MQgp>&8XskhLI>*}|frB6? zXRH6G^d~wC?@l}uCnHzjR#Kd~pF!HM!`7o_g^&A}2rz1T``Kn{sr9GQhJ6TTJvz&jW{|Kk{n^%CERukfB^ z3OH!FAoepp%?|cU@+`+Za1dIkx^1g@UaI2gVRY#4`=^jCx&G*SZY_~ydJ?D%Y7*Ox zPQshKOx8ktK$8RA3ZhMMai`_3N+!I|KG_oq!~WmQbKE?(2R;&;>NsTgE8k45+128F z$A0%uZYD^paNq7kr)953-v+!gj76TSVJR zxD25SKhV-qE$@@HWq92G%l>4Seb^jxS! zbl^Mr&zkxo$1D*j$JG?0>|gAIeN{oAXt;j|I2srg{axH*?;aRk@QJ;|KSbLa#XANC8Tj35s9Ci*Am=gCk8wF&!CTy3}3pi z9O@;e1^+H8zp6SvkNXBS!LHZ`3Z1RRXa{7ESr!|Z$l_J@X~DTjQPJOp6cE<*5Zcntg#y!ZcFxQWd*Tlr_uDZWk? zBSYvzb8~n#`)8n>(8;nm&;auqUI&+_4-%31t1rU$GBFg`YE6PivZ$@K=UE^^80KrE z8yJe1B<`U$Ah|YaBDdvKb-uwHrG(w7zMr8gB}-k8$+GxU`jf4doDfWeN6=lxQu3bs zDAY@9CGL^#8)uH+$f5WM+2~t|SHN&0P5!{d&>xh&;S|s~{|56hl$$6RyTS&c!{GrW zLp@V|4XdR)(PiR)=qvUj_JsI1v;uiynCgd>)~3tJN5ySX>3B^%Hn}L=zuM4EOT@W$ z0&Nb)l{^I%oObUS+-VSWZW``_kD@z6-uHN6Xt65`wiep))KH98%eheVAjV`KUMvpa zPJ-kO+vx$I894~tHc#ZM^3^S>ITW_Cjp#a6t>g$!V85YpprmxY!E~}ZL$q1cEPJRFp2erjV zA#e2=B`WhNFnx1Hw70$)*<89b+~3?6FW!`_SCC8ipJ0xjLOJlI74Pa3R6eCaaG7hJ z*vo^nJLntA9fE`qv^F#K%W^}x>q=@9CMq)m$>;>-TWE1Knm>0+iya?CyPIv5e1cEcjscbW0 ze4tor3Q$*KLU&@F^`kn>l}FZ5qHr?^lMTgc$`W=F|4VQk7e)NzIPEMU9wE0a>#3Gh zKh(^e<1XOo${Fex`x?9_$PWG`Rg!+w&IihdN_1IIioHc;Nvc*ZFc1z&HMmnEOD;{i zBW>VQogm_U*OY_Ws&Fb&pSnYg)SYr8HO8=u-VcrjnZCVf1Nx?8y0Aum z7|KSbx^h)o+)h;0hDbZK+ChL3halviG#xSyUXZDTGN z6vcdct95)Rge_&-gIuhPW1(7wrwE6B%=Sfi2Ms2&U3qvFX?e&_{h6yM74=@cL6;Oojd)1vi6wxO6jto)qeTckjcjZ+@>ki9 zMpAclrz@FW9gJ5=`c`p&3NU~06t#&OsgG99^QV~nK$^20Q8Cm_zo11j`7X#Xc$~sV z3Ny(G#_g!9+%!DNcibxTK3^Alk@ppq4uzB++NpwEs+ntxVWWKMrrK&iMc`IBZTe2DWE;WYB4{fFDi?gwM z>LySe-cP+ke!_WjCcGWZMWU2U4n_6}S3 zlA}#`a9HeM>w=}Yx`+<~f4kzOzJ}i@PP3vl;JX^gK1Ol|vCtyb;&lFBM+SaN^I%p< zAe%cPiVO8)d==_<4T1lwu7jJ!vRE&fgJ;%|>s6er+KyoZ$OG@e=FuU>^afbenS2$ zFE-G!_#G5B`Z2+v)$)-(%zmI32WJ`Qf!lm1`Ga`S*OEaTSBZaO>VoTm7SLhuapsw3 z@bu)r97EMY<*PxY=%Ej{9ER_Si}1-{RQN>jq5cz;1RD7oP~Dtu*gLRWixA7Z_VOnJ z)uq3%t|$#8v@h?_#&f5VYC26!sj-3C$Zil7q1N(@+#T6N4TtUxa=ok&F>qyn(EU4^ zWkW2L#mj-7h>LJp77MKsDi+W7Yy~}ux`JYk1`qi-WQ}F1iWt6_b>{k}%G|Dek9drn zYPpNd6^8K-0uy|Pfdegoi#_F7i*F`$19AyLu>ohZY&dtZ5&T#q>G1>6$uqW>|OQM?h`Vw#3XiJOrI za8lk*;$N7++9xGh6RCA@KcRQYW_48MrEJrL)ZhVME}nrLRoC$xwj%Q)oTT)`&*qNB zCfS=9?S-GYF!9zaTL+=n3*vCu)&_n6FSm_L>dW_nenAYzEI-8k23d&CvK@B^*-r2u zyw9L9XS4qmUd`T!v34xH4)lkCeVANekYjo&8^wV4y|5v;U2N)}$9mX!%Q;7(|A{Bv zHZgKqURT=~C=q@vH-%%>o4##&wdxJv?(9u=MF8GvM;1m6f_c*b_FnLB`H*%I2}5g< z@;tzC~*a*39{u1#5YYZ)w5x5W-VJLV#x0JgIRu;bl6@8iT6mXOT zqR2vcV|}qZ0vYJO68LJ?e3t_qyuV}p;EUEQ#fHw;b#=A=Q0*?K;j=NHLC%H?LSQv9 zqj?+=uwo2V7 z37e*#V?Wxqi|6#71Z6Z+-eWDuNzPM7;>M;1m73AvXzAFe@=M!RB%I8k1$EEC$u7n!nD800AX)pjb(P7Blv z|Kc12x@ZeRe=$C#maBZYd@+|7%H695z(}_sjxyRL2}Dn9C$-#2U!~w9{k8Q}iE{Xq zxuAn;cxchCVt2KXy@o4FbOusNvhX$r5hF~FC_E(8mJU$U9bTU03cw)sA^5@9Fq`;B z;yYBe-qpU7lY}N}GFCwz1>UL2Jf$OIpMY%G-{$h~3L2sRAsYj=(M8^-Dj(egaiQB_ zzw?>1zWXMSWE4Qbx`F;=HMU32WXpNqf}#oX>+m^ltoNlFG7=;M#Rs%gSU|TnTY^F3 z4Yrt_4)BD+P$X@#SgBs(-}xlyU^*rSLeKG3c#!yLbxn6E{Dz9o`)B=Ebe{TLsLdWn zZex`s!-gj=)gVxheC{em_QHRJia9~XOSY23!;9?C)I;=U@XFW5*}=FE%|>2B4biRQ zpLUaX4slTa&E4AZ7_Uz^2ukujszqolp3PiPzKS)m*6>tge$mOX1^E`gStYE|Xs2K- zFj6mn#->yniN=4ayt)W0WeR>}G(RFMP1fPwav$g}2D5IHuml{^Qpgyr4=< zYkw2=l(q>p*H(gg35%(vYF#pfv=e+!lmr8tHd-?y&#R{Wz_ zO}@q)5{vW#i&@pFX7beF8TkRfhMFKO&=yJu3DT$zw+@^ysX$nSzRE#zAW_zyhiq3; zEyI=b*aGn@xrkZ?KcK!a^Qd?iZ)6-9Vxuq=`eINGTlw#R$9!wzh2g-hPAx#Dl0&F| z*yg}`wHaC@cc985pCgY--!YT%U9STMB2~gI68lllxfr1-ZY){zm&Y@PE%I;t4=q11 zmR}`G>NK$oIV<22#;@(=n2lFRddhVYdTW|N_*u-p<8R8Rd>7?%ycwU1PApytKC{MW5>N_-FYt|Nkjh=63}Z z_R^ivK|x6<6_z9qh7!5&;tX9O(w+OPZRDw1Z?TSdx_B(xrM~AnSPo`4@{dz@Q4^_I z#b2oQ3Z6JtP88=^;!V4leETZqHZo8})BzYG59fzao3#u|RIlM3j4jEx>_mg4@{87- zw6N*o31tud$dTrW@Et05)H{NT)_!Q!;7wu!O2xXnIzl@DWIDwSh<>6QMo#xEN2;;6 z4N6brI^rkn%gGC%nbB>(k5^Jr@Dfkrv*TO(N2!{c%GL~i^tY!LhQh_!LS({av@ZG; zT;{rf15Px0gP2df4aQJC*uHefKo0dc(FJ>Ds;i8}!wUlW~wd0Ag!bR4xerK z$8O1rR3Bs-9Nk#$Q_!u>SN@8eQfLDgK(@p)f5g<*wxa{-j#>%xnfgxz`FrIF@NDS| zxUTfYH#zEuZyOYVR!DWG3HF4VOxFr0m8h$J2bUR@>&}&j`YG4cw!D+XlVEizx(UBq zaV9?r4beqKx~dm#K{tcU=p|ZMYNXD=PZ=Ak56)>s7qI^S{!B+AH5VEWZZO~D>NtKg z19_yqx-mIBKrRw`S@%*o=IMClK(en*d)3)>3CU7UyqB>bbs2Hsa1uGSSnp zRXGBh`6sA9%*E=zNN?X+e1W&O?;7eSn}}5~g3Urw(ITuAtt<9`#~1XHe-}?V=Cj*P zvDhANsVu?_Ra4C7mm{p?!w(}qw}zfTZrQ&F-rD}g8|94m!kLlgpRr@nCfXn_5vc@^ zu%>Y3f+xTr|Ej`{axH_28G#N#E{X3Fj!SvmM08SczcFJ_&7{_-|6O&BaF%G5iIh zk>!-I46chN5Z~eX-kruQ<|yAWH-ufXf0Ei8{l~wMJ@z##t{aRLm|Y-^kuH(2{~nlS zwWy@h+q*vSll0mTF@`aeoq((rbHwt#s>I*n2>H19L%A!j@}~&~zYqV8Efh2SXOw80 zDMs}i!1hZ0wFLMjnnx{V3$&e76VN=+#556Urb5I+vMY>fs$w&3rc}HvHAOwfB8=pn zA=ZPP>n20r#ZqDiJxOk91cK6vhS922lU*eK@W&wMsXN$r+AQZ%1Gx^GDAb286NO@V zZWUi%&LVWaZkbfacVqz42sn-T=rQ7r%!y?aYY^$$er_wgmw4q1sPBSTZD$obo_N4b?~PYhXjOE#Tn98t-d21n3E7~6RNd48S?nF-wIUJjB}6uG;})ViSI++r zMWHR#X7b5O{~d>G> ze3LJU`i50Sla(y(qp>3?A+Fd`!gryG;I?s^eGdKL)3BdPzRjt2PFz7>;@hYx)HgK=@Bq$*{Mf+kn^T2L2D>Gnhe$Mo+j7{DZm3 zRGy%M6z8vZwJ5wwl^e&m~`!6gKx32Y^cwqy!tYl)&=-Sxf$~ykO(g> zSS_hg8ZivRh=J4&cBb&YumLduG_>C!9qc2~!j4t%@Tr9-12IJyJtW-L-$+Wa4@Wk{ z7J*3ep{Inv%_>};wu_TMqFP0qK}Ca^*qXv#fdkZJVr5_)HAIE7zhZzh1g^z||ac40@|5Uzm6@k zFWP^o)16_=Pb$JMvEB5%@T`1C@Jw#8)Z0Iqx~z;~lEt~`N4=x4la7F2%Z=51P$2K~ z4F;1Ocj03EJuyDpU^v4OHb>DSe4JJVdyq6-+o*hE!_rOSBhd}f@FX^p-z}u8&DpK! zX1*qN7e5^z16z$5)@A$>d_t@a2rWlC!9DV|D*Q=D1Rlhj(2B-0sSEMIGtqEs-}ZMG zVr0>vvW`OjVj1$9Z@kb|JtCBIM^Y7?yCqZX7kmI%L&2zei zJ>YG@Z3TU~3&xcVd5%fyL}%CH z$Kn(4e}Bg_wI&r6jHf<{OO#{bwMe62cgIQlN!$JG?jVde;(yAC_**R-{e$}j8Kpqn zLgRjP9N!9mPY9ASaTok;nj@RAORB-!aU2u7 zCfGdLXfoVG`-!KKtHelkY~ppvMBA)I22owji|~IDzg6i#8~}fW1m*#iXSr)nk`l>E z=qOz+Y^ZsmMc8LwHn-Qg$Z+=*!5y))*}bLihNI!Ma++U@KM$&CCO4KJWNB?|5Og7t zd*C=}_~gv$6sj5Kl4t0V03|9IGlzeeu7(BI0o5VvtEI&-_mJp{ZdW=NoTGfwM)XeX zR`#K^K@N&#-Di=rQ6^-VnkzVs6W3ZwlA@(L%zxrN({;hl$lt+g`G`gy=YOuUEBXE)ru!{qw$S2mMu5wolSg4t7dAyxG zv1l{$+Ax&<1H*xSwt(&7VEQ4?ioB46Ai1} zWp!6xU$8?tV5#g|E!4`%GDlFmI41I#G}YG1DHD>?-_y$X4;o8t_N^gWkt4Nv{0L-Y zA!)Rb7O>NaCvti9zJH56$+KSt>S1La8>>!M7ss7Z@f9q0VuLnN=N8D?I++bjE)rY4S~N z`(vN|E5!gcwxk^+2i}5hGDN24Pn7mjjl>n&`e2EKYhTJd(@z=m!bu=sxNU~wo)n)i znqK_FanQO5cCdHQ9N%mkMLLK-+;Zqo%k&u*qZP@Qbe!5&cx#tu_LHE zq#Bu?_CDSER3eiwYeQu(LnZ@$zH_C=tbWzHN}ZiDc0H#8-d|Wl-GE6buH>5GXE!sQ z%5M;VHBS3qq~YogCn?_)`P$Fb?{YtETUgy5rS+ZvHV8 zLdf7a=sa@K$R}JC%Hd;>SI*j0qBs-l2sWxV#UXMD2eKBd>C5avPGT>pOL9JJNxVx~ zlOM!}*=ad(rx4G;0Os?W0Den3r1!zcVMmBY$V&Tqs*n237oXFbjFH;VeH>yeB>Js2 zl}*$gN+h=%j`kZ=K-uMdB)wq=Qk~S#OcT>}KghYtq@$C8SBL3X*lmuvDcsZHtdPiL(hQLO8Bv4szD$4Xn>OKCSURSv(Z8zGGU7auFvn+!a zdu9K0E!FT<4`1~$yuxT7hOpAYig`_`p@7hL(o3l9;`1uc9diH5cMspdA!>|R0e9Ns z^$TJ{!vKAX@1=W))26*-S80g+S~;(_Hd2aB^k48lwbML7>@Y|BUofYgM`(W_+V>^cst`QJphHE2slVDY7^?6K{#9(r#^{P*^a+Jh`|@n3XeG zzuH*Cb5|P-rd2tlw#3=Oa8a!Cui!^Z(8)zmq*J9nH)D;qHcq+C#2SNvl!mks50=vD#z=jwCU?fvml`SVp#Dj&6;#Dc3MLWR zMBa~Y4Z84Kt&#F}tr}8}}70_LM~jkrd{1@|*ksA%y90dhBlCPCY!k=g~XF3+}A)Y`0v7(TJDb1U*% z%`oWm3R)NcD76J?RFC-Dwq$L#z8n_S8e&%>xu}uc2*eZ>$P++wxvcXpd{*66)FAjo z&~Q<_rr6*|QilC6BQb)oh%^uS$J;Vv->(@VI!sFc+CDURJ;0`8??`Bl$);4J)i&d_Zn!yD2q7j~Fh6Pvkv#bu^CM z_bbM`rYSnQLaOi{>Bb>Y7hwflovP#Ritj{LaD;5MDd?l<*5X^%{^oJQV*gTTDO3ju zh;99-V#%mc_QRFfT(+_pz{XhWb8o;nTo5;S${S>0BY=VI zcI+a@*lq?>#)_NJ=wK_+&F;Zy+X=W5SY}S-M+)2GuGxxm7@ zZn2Mn17@AOFQ?1tN`$hH>|?K`F2}1{SIUu!YUG+uh_8(<$uJ*cugr5iA`x&4?jYQ~ zJY1aWUMR-PE&bp5O!19SOGS7 zw!HUBle{OkY4}ly7Y|BZ34w#ezm0CvX7(=8&wW{0&kYuiBDv+dGykLLEEt=})-a3} zC{T}0GIGvHTBNwU6?b=8+zX4dxI?|MNk-0C+9Hd)F77UiySw{x=l+AXNoUUazW03| zr?P~!+ot-*$WLH5F@?O!b;Z}h6+lu?!a8h()I<48sVjb0^XSpg5BGosb`^GCwxo8To>%V;2 z;Xm-i!X@}g!XX7@j}J8`6J*SHG$bf~aXLGS|AT6(71AET6!t&qu)Gd7G1`X`k(1iz zP}vu*;&7DnwX9@$jRL`C379`2F89JRxvBJM4nKe_cB|2v(Y@TGj}bF(Gy`Yt*^>y9nFMsW7tIx#}-((nv*Zm9dEqD zhrl>|v{DXSV3rsJwv^a_AIDF|b~9)2?F<g z3{tA%57;_<;=+~GNKiT46D+e`WNWZP*&cLzuTIAhWAfbKIa{>Mx=?c1!H!3~d3UOz zZ(j71zYpWAm&{~Fl>dMdrEL~7jQ#k9z!;P3;L3f3u4C(wg4YaRC^HC-=Xc>{L(6DF zJVEf>XuO4T*mVSdNK8a7{;S$od!(J@{_j`m6kce#=`PCeciwk#8tp!WKIq-~!q`jC zB4#3oS*OuQ3(r;i>*tJvLVtX#noZBv7l;WQ#-zI|F!QAzY+bp$*|F?IM7bN_AHhR= zS=Ulypf^PBGAB^`m7>tUAl>;E#KjaQEupKK=lMHyHOVX92skNke45?{|3jaz_~WOu zWi2VpHQI2rw5(?ev-gzt=-sNc_&dNOZwnm8`WJnY{S!+k*ZQY1HQ)uIvh%1>hK^#q z4v5!f3y>%WqnBx!N@;Z$cP6$S+TxyJX1Z#t6{!hH`vUE9XPKv!7FmNqeWEu!E+lXp zKm?n}UDagN#~38{)W13w%D|BfW%J(CR(P$6Hg`B5Z9;oZXTVli1U5Dsb=9r$V1u$( z-$0(^aA!l|j`50_%kJ?&{fkgb>6x@Rbd{P%Pf{M26ygu5iFEYh)l|E{(BSU07S1k! zgC4<8-%^(3O>}oOyB|mJXyt-Af7+NmP`#$C!jh>n>_2KRdYf#XJ-7;}4x(XMy_f#Z zt&vBipmGCzq-UvQOiA^i(haqM>8|6JouT1+8sPw5s=NJ;dBQ$Gp3Ghy)3THeyX=b3 zzUj|raCd!rdrT|rH070R>Kz<^8pZvi@W1n(>1Bm@qAigOrpeotGtM)P2fns&H}}i= zDexMs1F^~trU4(TT&BCT=Z*i!IIW`F%c8sMiBe zn7XVZrXFZbE1)oS*%%HZjE7)=KaZi9Is7^?M)_zaJO<DCHHTQJ>dE2rp~agEvT`bz<>i(rJ~7MGBBUnpbRxz2c_ z;6Cjrx5E2Gey%oE4AJ!HNK<*26yr1Jo?x%`uQb}#l~%-N#zEzU(od#MtI-tF$__Gj zaZkyM!n>@Q&MB6l9v5zo`r&(-K3Vx}ruf708b1Obg!pBTMA=#?y+~l}yhiT%DAG|8 zBoqG%*M$4A-D$trE!@;sg~jnRxxM`d__CUpz8C2Ve8>KCaO4B(xw%`pLl4$Y1h>ZY<65|`)6ZzJQx8zn=Jj8M_N0`Y!re_Fu*q3lfc8ofM z*sB!9`vas1YI^)>sT6|+KF1dme{!O{QQ1R1bRJGCK~$nr1DB78*ud$Yv-T_&l_DVg$G08U<4rX;!8C?q| zCV2|1{M4jJhAQ@kkM$~qjNi5XLl?7tS6yt4{He-l_>l1notVq{5%_ni7HVo7HTa}H zrT(K<2zFs}bX(aWYzkJBk(uGxZSr9<%&G5jZ8mm-XO$9S48KN2A1nno zrA5lW+*x^(`psx<|4*91Rb$VvZR!6QOvv?J7DGjcp;7vP{0XrS^Nx5c73$>pL-|`R zMC9|IjK%1AplfE`+&-q=@XM0Z!F#bi(U?FL>4ciH>?*#B?^om)DA`|Xp zn}!|;y4*aNrFKUT;F_!=!pq36{7LsD=TSNnj-wAUCtalkXgNh*a5gF^5GpA-S)#TZ zjM5W@W(760n;;WwEbepEW9|jSa75B#if6~7GN1-b6YlcM$mh}~@WQj(6$Lt`&!fBe z=Lc7%ozdBZji!5JMea&hwzxYKh4^3Vu zC}s#?gYcT`C0|n9Ac~LodHKGey#7#+<>rxP6+nHmyi(d)s$_leZCz0f><}`{PV_;& zqS+kS?U^sl_0E*Hvv(aejUIa2_&v%tN$28)4Mc0xBeB8L8T<{-VCA`OtekmT>dKx| z@1xDkad|!cLU~4v_I&c}6Q^if$#wFy&~R%+c%NzYNL7ch3h^~@IC~uSREuH%n!EsB ze-(R!@kvMZy7*JQI<|!xtQ?{tnI%Q1PsiU`bI6t8pu?`+6_Zp$+(b;*CTNqGzNQ`G zGK`h}@sINg>Ur zdomh3rF0Aw!RElVh@sv%t_gN^1X>uk9d8DXqlMB%egO5G{)b+Kl_W#9F6O?-3HwKU zH}{wQm}Q=sLcH&OfL3eMQ>A9?;`Fa2vFE1Q!y(O`9Ds3EfnQc``B*Dwt64pA%_=#AQ#zNT&+$#XJy9yZ5?|gSkt%Y-$@yIC!##4J_H!w5# z&c@11p?usA@tx$?8eHgr@yYEtf3vrbz-?2*4LY7Se#YVf+fR;Yu~ z-16S>PM!>I(D&4}fB|z{m?)ETf^p_KWh6xE8s!YJIrJb@*|l0qgkN(e39;Y;%#pWX zf5MZ#gpvU5Ana<1>^NA27=-^U^IBRMqhqB^V|Iq4WyV$RZD_B{Zb=a*f}3)p^Q{fw zKd1@;3mFqPAUDl65`UtNv@cEjB{z1pbln%$(zQZs>3j4GIIQRiunc(A0@w8T*{X&$ z54IES?E^65c46UC>Zg~CCx5wYF51T~@u z$Fgw`TxC%|$7yL%tivCPTLZfthj0m>X>y)dVnxvbq8R96{h%I~W@;T1NYs>1p(;v6 zgstLiaR=TWGL4;g?W#5j_LZxUdn{W; z&ScnkLp_KDvATW)&Ct5%o`!#_$FsNMKY}H|R;rMY1djxc3bn*DOge+fonju~pOg*k zddG9(AyZu0g00e9%UiXX@^rju;X2B4c>=M8>7b8DsvSHggmotTL8?L*vV4TjU?or- zD^1@HF^*--0Y%R)i?^YwnGDb+LX`10cMpg#k~?fPR*NWIwRz~g**=;>jUe85e&ESb zjfmFTYUVKL9`i=OBy}@eOOn_q?ttk+nX8TqHPR1|y0$qncTrXSzR=Xz<~yLgVl$;> z{C7|mEW#(j1hSS?R;|z0#yetwFKe>=ixJtuUPKpKw=N<#5$`%OiK7MF_hkGb<>?d1*Rn` zK^MTcLSdqn&`sFI)!fLlbKICgpY^J|tPgX}GGVAn8i`qtxa!*4M zj>=E&CAeD|o5_CR9qKP;2REKQtc00Aw035aqgm2)p_9^*Ka$m$ zo^O6XZ#dG^%$m8_Mgz`1$^rU@^{dK(IrJAurJg$9Yw!k1`G#B_Wy5vku0P9=DyA#7ptgZQsWb7+_NTwXk(5~A7Js*4?W5L3q+k@7xy3Hw7^)iojeZTCUi49 zH=~tK@(nbeJEClX%VB@DDjm6UA#)Q)#wgfHzm2B5x~bQYS3Tpu;+q~Wt|lrk&58LR zIUA&fay({J2=LOqgdU9?$WDy0^wSvqLiQw7PAToJtacVIXe+#e|A#gaPt>Ywb^UH> z9zIc8<|J)$luQs_``239(bu#-TO)s?{pmQ9=yGdXzA z>`q!0h)r-5!e-Gv?O*#?{gQJ~K@|BIzo||)^Gy-@TgTXf57Juq-Vl-a-ea=qr8{^! z-vJ!ZnX(nt7`TbPLyrK@`6uQ5`Z_YkY}+(sI)JM%EPiFY!t#PMCY$~$yIlAI$4ha> zJ!h}L6!$yx-O?NskQb!5)tlm9pkj(uXet+y`V*jRx1?uG#f*k%f`_Bx9T(J+`c0)X zw}^hHsGe$uoy){$o)V2ePkDuh}K zGD^cr6ePO`vQN#a(%M9ssEDm+q8$5kFQT#5zR0{^f)A#DnPpMA>Ox_yI35A*N!RkX z`!>1O!v^4wq_@-!OZmVoSfhYOlg#V^L#2Rh62Qb&&~@y=k_6-t=b`7;y!5fa zrgxG%m^$qr|94zVS*TgW5NyuUA6QXk2^!GMmN+6>)n@zw%Vcpvs6Um;$J58D z)Lgf8hWkpo@k;2Deq37za)?K^5KI7nDfc2)v9*LXY*D%ciT2;8}*-XUA-C-gr9IV5VT3~q}c;#2^Le$K#JM~-vcK|2T>n+Co~)693#V6i`};Wdd;T1)33qa238OW3Wb^!OfN9dXjxoLRx_ z2gO`rae@5`<>PC@lc<{6o0z~JV$XnrPJ}qjTvzCl|bHI}m+R>LHJk zy5IrD++=3yvA*2VdcawLRYX;^biJ5oRj8>$_nI6zAnQ$Ofx>fDX>1Ha%MvN78^_)VyZ z*9*LmUQj=&j+rfGDFpO^a(6abSs~i#Ny5}fg&d_Fik)jdBLQDtUtu(7stA{fpk_14 z06X*t#3DQjdC_!hpM)N)ApIscVIy@8x48=2e}QYhiF7{NA$s+>d|ACD{4MUMMuf_d z$Ib1_cVanl6BQ=y;R-azXNeQdWbFdfMf~oaDqN53g%4lYo}QEOX~F&&mc8Pj;BURJ zrJNQ`*Fn{otiTSYDNM_4s%WmB*e?98FbtGXre^c<8KpK}mH46;&&&6>(Ygf7^6RmF zDh)OXxk{!C6ZUw{OT%-8;Af$}R68(2u!wtOmg8$!pTBrO5XTr*gbcBzmL->>`-9>+ zI|9|6e+gTbj{>``(ZV`8M&1NpmTBgwDrHFPKrOj9wbju|YdHTEs8Ohn& zIrJ1EhDgGe1do$0&-_v#m}-SW1I@Jdia(_oeLgLb&((`ITK-Otgi)5L6?4ms6+6Xq zwAcLr&xND?g0mVs*XW2_#e0_B%vrK{{BX9NI?~MB%(S1@M|vaa<|>{&REuS@@=ojX z!#l_w)OGDM+PNE17)Iov8#rfJcdo#eS%9Xfd3o z%>YpBVswJtUCmh!m7-n8W0A#Zua*)GfKl;G`<444`)xeLm}^!YA=~zhui) zhnv0Q4(^koLhK3sXVIzlK++EJ3s=h+ispqQmy=%_#iqZ&lG-;L%ct2^OJrM55gbyK3*$X3g6*4>-(g2 zh!4qa+;^w~`aerNU5^QwdG9%)smfZ*W&R0DqazD;NHOeGZ~dEPJ*Z?~LLUxj%6Fk^8fV)CThVXu$7&-u z%&-s@gdj0jDFmKUUi^defW4ox2mDNUs8mH?vG(8~JsXb=lu3W2n-mmK!Sv3JlPfE| zl(EKK7ZLT*wad{Zw9}kwofj0MDX1xYq_Sf3=>$(B^Q1LA?}le00ddjwAhE7#YkBEz z9elys&Dld^{ELzxY>B_;tjHFYYA{8W`{<*iy&9#Ahs2cAOS&bdgG#1}{`cY-)d{@}u55$i=|87gd?knS-~WyB{iWPhx_<3pgp zk(suMTPF2H)pedM=lg2MSi`j!>@#;9pEc7xD4mQf1FxmMQCk|L^-0D_R}Z{9+gRN# zU9${v*P~x7!2NM*g1keYoi#5`qGPnCn906OpOn8;pCkTcV~iDnN$Mq&j4F#I5}#4e z6S{)W#?|0t=V+G&v=F;85XpEIVT>FlE&(@{zswDAb7?+%%iGZPO%(BDJOu>Nn3a8f zd-2`^j$fiDm%#w;gz|uRQo2GM#R+0@<|;=Zq|uvTI?>Vj5kEydmR8Ztd{snFyUOkX z-_<#;isYi;75RAZEq^_IamhThPnXJmw4E@HTaRJq{R=|5RFZt!bwa-iW@`p!PCxYsfm8{}81-`=$*b3!OZWc_jHfLK2l}yudsZff4 zSYQdNnY)!q5g1K@CxTx509>#3!p8=_2|Xb}*yJH>2Yk0F7CsNJO9nelO(Lga3t|Uk z4dl0h0r<_(RJta)%51BIg{2GLk%n-Q%VSRyKbZMK$H?Kt@VL9$-~2auo_`yENInGO z{@(^LoyZ46kNroLv)L_!xcI$z71vbtyJ*wx$})0;e2+TqD+1OA>k%D7^U(n+Rp05K znpKr0xsp*Q=Z%iLj#k6>%ynmRu>hZ9T2ZZ`iqxBQ(Rvw4s@vWp&3-7+DrUM>4lD}{3BVA_u+*Un`bK?uL#Jjyka#Vji)(Nux<{k&;UG_ zH=>-n6Bm?uqwz-vM{%UY0g>sVQL@f zDzA=JXp*bOoiJv?i}(xvR^V$$iRoqDg9E;F?X8*|2nHV0TcyHmuI~U9O%tHMC63&o zm}DpYy_%HRN1w+qTqo(Z^D}!?TN^lPdNo7l88;s-4D@sN_uhjEiF4Uw)G9HbI)J|6 z_pnoWqd79PR%q{Dq9r9x26xehq%!)9#2w*fWeSTij=3&5e}m^Haa|ZkWwgPongIwpFabeKI!dHFJ`(u*5v!w~?YoJC@}?a-G&M$~}$7 zd7R*7JEMuz=-?H)RN_~6MBYmL8h9bp(&u?va`ndjwcRoo4UGZ*s0+o-nicap;9+o;=XsY7@ik;B!5w zH1vjBXuE1A=$-lqI1Mp56LcLc_NH0(v-jA6Ebeis;lz=?A4(l|y?3Z1z$;;YjX^`~+yg{U}#|c&O08u~B;i#=zf*GW5%<1W$6X;Bi5e6@8Oy7}f z2&*`q-dgOm^e#?`d#@7CBc7quad-e!bSbWHL~lyNhoj?h)p!XHlaX{2;y68*ye9XR zb}1{uy}4NYqiY80YTluryZQwWIeVi^i_W2&W{UYMnpgU{l9+T^seny1Wan(r6W0pM z6|1Q4ggfS`;tl=NuXzqhe%%VD=%ck&CL6LhSKP&CS0Pek4wJ3nPRfQC_=g(X+z;i` zLI7{UMZ`4~{=YdqY`Z7_FqS4hr;=lOfDb~FQd;V*B(d+w^Zt`$d3KA?7_>s~_`alH zw+F6xW5~L4eX~U#&F-LUWSkOM6*}wbgyjieov*07<_UOJ z_Br;AHd7sn7oyCbLomhcf)`<@M^&MJi7gQsNW|J=jp9niz2JHX=d!P|+t^HZE^JZ8 z0aBIP+6c92&`hRi$AMP@nz6G+ZIn`Fcu%DmSQKO zL!s7$tcaPXmBf{eoJ63s%M{;)@o&DzXCGd>R*2|Omx(BO> zbH;4Em{QKWoEPRpKTz%kYJ!_(aHSih;a!=Jdm2mpn1*}y5~gQq+&x5pp{FN@C(M!Al8jJ%#k zNn=x>G+2i>Mn~k=q5IBHxrFnjnUd@md=5W@!SWyQndgjV1KA0~*`&}#Vk}(;9!5n$ z3SYrePJf54kQsVEv^V=nsJO}f|H1{3>U@pu2KGSL_*zcrnCKd1ER+XeN7>W#P63V~8E{ zBG?eS6Z0jP*Zx3R@Lz8nUsO+a2NSCUv~W? zZN#5z{X-3mKdEt{2+#!F6T9-4sNU4q^c2QIC#@XpUKsU3IU#>~Lw2;Q zobR--!g*LoLjO`T6aStPZ%7dr>$S8{DsR`~_6N}UMG*st>zsyG+Y!^A}CAX?$L#5VJi%#@6! zpssDP`Sux}@XNIk>lc_0E2vel-QrVr3&=Hd>i397%D%t^e2z3juM`NdQ9_J-lv*9w zZ(L!cgUw+gC}zwCe+YS@StbS4rI;OK*G$s@wh4b1IT0>(m0`Wc6J;qcD{Ea_gUyi# zowigp+8TCOALFlxW`fOr1~I`()H>-Fxh3uZcFLsv6?OsB065&%wCHs84El{xdIw=6 z@dI(_F1)Q2SI!Y1bs3w&Z!^i%2@*rZ!TsU;wmMN1aWX_PU0_Y?6L`p8->9K)h50TA zY=(Y)?+DG=?`L7?AiLeDWKvH?<8!DZ%p7G<{wZ;*y&PGazUTj& zJ#KDfFE0BNy4W{T?;T_XD(wiq6vmx&9b->KhNNi2^yR4<~CR!Au-3=QqH2j~nl&)bS<6UsmX z)eQQq^qd?@Op!X~{E|K4!svzC$k|ppD=uLRi^JG;k~K)Om8o<~H}bf9sa^qCP(z6% z#_EZllL;@l(&~8SZ@Im=23F6jY0mR|36lC;vnnf`kxF4BCjO1dJ$*&SQ)6IHM{V+P zqzWD~2lDR-FUh}@t3-F@x4@$p=%emZJET|B+M=VOcKT0xIG1evwd}TXJ*!*7c5uV! zo0pdK-0~DuVP9)az$FCHTgbWl>Jwu##Nts~(VqdtMG85}Gii*k7M-NGR%_ae61QW1 ziif2zaPQ@kzD{L?SB)_trP)m9dRgGFH zRwmCG9%E9d4Y&htD{F#3iEAL!bCFDUF0$06Dl4Nf3vt}v0=-fn6T8R=y*}N*v)+1^ zDNB~%N}8$mBl>r18)`-FFnWRX(b7w|&`m=eZ6k4$*fteK@I|-^a03VvYeE@%|F}#? zBkBXMxXud2)Jg&`Si-~9J(2aa`cxiyP#9^h@t4tYQkwrVdykkcw+|+W-q0mIQZ7j# z5FlPU-wV5h7GmXKJy?|{v%>Tqm|(njrs>u7OUw!Ap%18)h*)vEw*foTvQb*~^;0slR;ghlOKHrmbVcxwP*dNr;2`5@+G9I`m(7kR0qGC!z?$-R7Z+1ix{~=_ zIs?X9dzSbh8)Q@0ZoF;GH<(XO^tsqkS(D*#EjLgG3^aQmH;JLtu(U&tllk4*rYu)1 zB7Lwrf^VW9bZ#H4ydX3pND^Tt^8p-{S}Bk4afzKp3wVPFR}h?C{)=fwg|l}i z4N-fCOj?AXD(mF{s*%vu%&xtYTEl6A4OQZ&DNl*6mZBW37fA220%dlB?R2)c3crfj z%H(D2z*Rj++KiE`PY%(y?P+2gr^#1JsKxTWzr{692$n*^UIl+kj7nI-*L7S$$=D(x z)%X)kWS5`;)H^g%uSqUNc=jUlKAb@PX_7Dg(JGaQfYbAPGCP%e!R?M->F0!?@3#0z zoE2lv-1T^}1nLvYitegS7w$W|(&Oaf{5`s*vQo}AQUuI;U%NrfAeIEbs1smQ;hf2& zn?vrV2Pg1IP8-7j*u=BOx;eXk;Ew){8JTe2HC?e&X9E-$EtDu)DQ*x-Mbo(d*i&YQ z%;_`@SCZ3k3*U01QLel}Z|;kV=s#~9aVww;ynUDdeIVL9%)DkTl)j(@7;TJjw%0D@ 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 ?!T`0@G=Fc$0ST*%L$ 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

G%wKna|E&ROT_2eMfVKBT7##X_fkk`b|2aSe3hx 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